Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const layerTest = Layer.mergeAll(
repositoryIdentityResolved: false,
}),
invalidate: () => Effect.void,
refreshRepositoryIdentity: () => Effect.void,
subscribeChanges: Effect.never,
}),
),
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ProjectEnrichment from "../project/ProjectEnrichmentService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
Expand Down Expand Up @@ -43,11 +45,14 @@ it.effect.each([
checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }],
checkpoints: [],
}),
getThreadShell: () => Effect.succeed(null),
});
const observer = RunFinalization.layerObserver.pipe(
Layer.provide(
Layer.mergeAll(
projections,
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}),
Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void }),
Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
Expand Down
248 changes: 248 additions & 0 deletions apps/server/src/orchestration-v2/RunFinalizationService.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import {
CheckpointScopeId,
ProjectId,
RunId,
ThreadId,
type OrchestrationV2ThreadShell,
type Project,
type RepositoryIdentity,
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PubSub from "effect/PubSub";
import * as TestClock from "effect/testing/TestClock";

import * as ProcessRunner from "../processRunner.ts";
import * as ProjectEnrichment from "../project/ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts";
import * as CheckpointCapture from "./CheckpointCaptureService.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as RunFinalization from "./RunFinalizationService.ts";

// Without a project the observer never touches enrichment, so its methods stay unimplemented.
const layerNoProject = Layer.mergeAll(
Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedNone }),
Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}),
);

it.effect("completes finalization when the post-checkpoint workspace refresh fails", () => {
const threadId = ThreadId.make("thread_refresh_failure");
const runId = RunId.make("run_refresh_failure");
Expand Down Expand Up @@ -103,6 +120,7 @@ it.effect.each(
const layer = RunFinalization.layerObserver.pipe(
Layer.provide(
Layer.mergeAll(
layerNoProject,
Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
Layer.mock(PullRequestService.PullRequestService)({
refreshAfterTurn: () => Effect.void,
Expand Down Expand Up @@ -150,6 +168,7 @@ it.effect("returns from the post-turn refresh without waiting for the PR lookup"
const layer = RunFinalization.layerObserver.pipe(
Layer.provide(
Layer.mergeAll(
layerNoProject,
Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
Layer.mock(PullRequestService.PullRequestService)({
refreshAfterTurn: () => Effect.void,
Expand Down Expand Up @@ -200,6 +219,7 @@ it.effect("replaces a still-running PR lookup when a newer turn finishes", () =>
const layer = RunFinalization.layerObserver.pipe(
Layer.provide(
Layer.mergeAll(
layerNoProject,
Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
Layer.mock(PullRequestService.PullRequestService)({
refreshAfterTurn: () => Effect.void,
Expand Down Expand Up @@ -241,3 +261,231 @@ it.effect("replaces a still-running PR lookup when a newer turn finishes", () =>
assert.strictEqual(lookups, 2);
}).pipe(Effect.provide(layer));
});

it.effect("resolves the identity of a project whose remote was added during the turn", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-run-finalization-identity-test-",
});
const git = (args: ReadonlyArray<string>) =>
Effect.gen(function* () {
const processRunner = yield* ProcessRunner.ProcessRunner;
return yield* processRunner.run({ command: "git", args: ["-C", workspaceRoot, ...args] });
}).pipe(Effect.provide(ProcessRunner.layer));
yield* git(["init"]);

const projectId = ProjectId.make("project-remote-added");
const threadId = ThreadId.make("thread-remote-added");
const runId = RunId.make("completed-run");
const layerEnrichment = Layer.effect(
ProjectEnrichment.ProjectEnrichmentService,
ProjectEnrichment.make(),
).pipe(
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
invalidate: () => Effect.void,
resolvePath: () => Effect.succeed(null),
}),
),
);
const layer = RunFinalization.layerObserver.pipe(
Layer.provideMerge(
Layer.mergeAll(
layerEnrichment,
Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
Layer.mock(PullRequestService.PullRequestService)({
refreshAfterTurn: () => Effect.void,
}),
Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
refreshLocalStatus: () =>
Effect.succeed({
isRepo: true,
hasPrimaryRemote: true,
isDefaultRef: true,
refName: "main",
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
}),
}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({
getThreadShell: () =>
Effect.succeed({
id: threadId,
projectId,
branch: "main",
worktreePath: null,
activeRunId: null,
} as OrchestrationV2ThreadShell),
}),
Layer.mock(ProjectService.ProjectService)({
getById: () => Effect.succeedSome({ id: projectId, workspaceRoot } as Project),
}),
),
),
);

yield* Effect.gen(function* () {
const enrichment = yield* ProjectEnrichment.ProjectEnrichmentService;
// A shell read before the turn caches the folder as having no repository.
assert.isNull(yield* enrichment.readRepositoryIdentity(workspaceRoot));

yield* git(["remote", "add", "origin", "https://github.com/t3-triage/example-29929.git"]);
const changes = yield* enrichment.subscribeChanges;
const observer = yield* RunFinalization.RunFinalizationObserver;
yield* observer.refresh({ cwd: workspaceRoot, threadId, runId });

// The refresh runs in the background; until it publishes, the cached null still reads as resolved.
const change = yield* PubSub.take(changes);
assert.strictEqual(
change.enrichment.repositoryIdentity?.locator.remoteUrl,
"https://github.com/t3-triage/example-29929.git",
"the project's repository identity is still null after the turn added a remote",
);
}).pipe(Effect.scoped, Effect.provide(layer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

const stubbedIdentityProject = {
projectId: ProjectId.make("project-stubbed-identity"),
threadId: ThreadId.make("thread-stubbed-identity"),
runId: RunId.make("completed-run"),
workspaceRoot: "/repo-stubbed-identity",
};
const stubbedIdentity: RepositoryIdentity = {
canonicalKey: "example.test/stubbed-identity",
locator: {
source: "git-remote",
remoteName: "origin",
remoteUrl: "https://example.test/stubbed-identity.git",
},
};

const layerStubbedIdentityResolver = (
resolve: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"],
) =>
RunFinalization.layerObserver.pipe(
Layer.provideMerge(
Layer.mergeAll(
Layer.effect(ProjectEnrichment.ProjectEnrichmentService, ProjectEnrichment.make()).pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve }),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
invalidate: () => Effect.void,
resolvePath: () => Effect.succeed(null),
}),
),
),
),
Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }),
Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void }),
Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
refreshLocalStatus: () =>
Effect.succeed({
isRepo: true,
hasPrimaryRemote: true,
isDefaultRef: true,
refName: "main",
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
}),
}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({
getThreadShell: () =>
Effect.succeed({
id: stubbedIdentityProject.threadId,
projectId: stubbedIdentityProject.projectId,
branch: "main",
activeRunId: null,
} as OrchestrationV2ThreadShell),
}),
Layer.mock(ProjectService.ProjectService)({
getById: () =>
Effect.succeedSome({
id: stubbedIdentityProject.projectId,
workspaceRoot: stubbedIdentityProject.workspaceRoot,
} as Project),
}),
),
),
);

it.effect(
"keeps a known identity's resolver caches when a turn outlasts the enrichment TTL",
() => {
const { workspaceRoot, threadId, runId } = stubbedIdentityProject;
const refreshFlags: boolean[] = [];
const resolvedAfterTurn = Deferred.makeUnsafe<void>();
return Effect.gen(function* () {
const enrichment = yield* ProjectEnrichment.ProjectEnrichmentService;
assert.isNotNull(yield* enrichment.readRepositoryIdentity(workspaceRoot));
yield* TestClock.adjust("61 seconds");
const observer = yield* RunFinalization.RunFinalizationObserver;
yield* observer.refresh({ cwd: workspaceRoot, threadId, runId });
yield* Deferred.await(resolvedAfterTurn);
assert.deepEqual(
refreshFlags,
[false, false],
"a project with a known identity had its resolver caches forced after the turn",
);
}).pipe(
Effect.provide(
layerStubbedIdentityResolver((_cwd, options) =>
Effect.sync(() => {
refreshFlags.push(options?.refresh === true);
return stubbedIdentity;
}).pipe(
Effect.tap(() =>
refreshFlags.length > 1
? Deferred.succeed(resolvedAfterTurn, undefined)
: Effect.void,
),
),
),
),
);
},
);

it.effect("returns from the post-turn refresh while the identity resolver is still running", () => {
const { workspaceRoot, threadId, runId } = stubbedIdentityProject;
const releaseResolver = Deferred.makeUnsafe<void>();
let remoteAdded = false;
return Effect.gen(function* () {
const enrichment = yield* ProjectEnrichment.ProjectEnrichmentService;
assert.isNull(yield* enrichment.readRepositoryIdentity(workspaceRoot));
const changes = yield* enrichment.subscribeChanges;
const observer = yield* RunFinalization.RunFinalizationObserver;
const refresh = yield* observer
.refresh({ cwd: workspaceRoot, threadId, runId })
.pipe(Effect.timeoutOption("1 minute"), Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust("1 minute");
assert.isTrue(
Option.isSome(yield* Fiber.join(refresh)),
"turn finalization waited on the identity resolver, blocking the thread's next turn",
);

yield* Deferred.succeed(releaseResolver, undefined);
const change = yield* PubSub.take(changes);
assert.strictEqual(
change.enrichment.repositoryIdentity?.canonicalKey,
stubbedIdentity.canonicalKey,
);
}).pipe(
Effect.scoped,
Effect.provide(
layerStubbedIdentityResolver((_cwd, options) =>
options?.refresh
? Deferred.await(releaseResolver).pipe(
Effect.map(() => {
remoteAdded = true;
return stubbedIdentity;
}),
)
: Effect.sync(() => (remoteAdded ? stubbedIdentity : null)),
),
),
);
});
37 changes: 35 additions & 2 deletions apps/server/src/orchestration-v2/RunFinalizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FiberMap from "effect/FiberMap";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as ProjectEnrichment from "../project/ProjectEnrichmentService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts";
Expand Down Expand Up @@ -97,7 +100,32 @@ export const layerObserver = Layer.effect(
const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const pullRequests = yield* PullRequestService.PullRequestService;
const projects = yield* ProjectService.ProjectService;
const enrichment = yield* ProjectEnrichment.ProjectEnrichmentService;
const pullRequestLookups = yield* FiberMap.make<string>();
const identityRefreshes = yield* FiberMap.make<ProjectId>();
// A turn can add the first remote (`git remote add`, `gh repo create --push`), which the
// enrichment and resolver caches would otherwise keep reporting as missing for a minute.
const refreshMissingRepositoryIdentity = Effect.fn(
"RunFinalizationService.refreshMissingRepositoryIdentity",
)(
function* (projectId: ProjectId) {
const project = yield* projects.getById(projectId);
if (Option.isNone(project)) return;
const { workspaceRoot } = project.value;
if ((yield* enrichment.readRepositoryIdentity(workspaceRoot)) !== null) return;
yield* enrichment.refreshRepositoryIdentity(workspaceRoot);
},
(effect, projectId) =>
effect.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh repository identity after run completion", {
projectId,
detail: error.message,
}),
),
),
);
return {
refreshAfterTurn: pullRequests.refreshAfterTurn,
refresh: Effect.fn("RunFinalizationService.refresh")(
Expand All @@ -106,9 +134,14 @@ export const layerObserver = Layer.effect(
[workspaceEntries.refresh(cwd), vcsStatus.refreshLocalStatus(cwd)],
{ concurrency: "unbounded" },
);
if (local.refName === null || local.isDefaultRef) return;
const thread = yield* projections.getThreadShell(threadId);
if (!thread || thread.branch !== local.refName) return;
if (!thread) return;
// Forge discovery probes can take seconds; like the PR lookup, the next turn must not wait.
yield* refreshMissingRepositoryIdentity(thread.projectId).pipe(
FiberMap.run(identityRefreshes, thread.projectId),
);
if (local.refName === null || local.isDefaultRef) return;
if (thread.branch !== local.refName) return;
if (thread.activeRunId !== null && thread.activeRunId !== runId) return;
// The forge CLI can take minutes, so the next turn must not wait for it. A newer
// turn's lookup replaces a running one instead of queueing behind the cwd's lock.
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll(
repositoryIdentityResolved: false,
}),
invalidate: () => Effect.void,
refreshRepositoryIdentity: () => Effect.void,
subscribeChanges: Effect.never,
}),
),
Expand Down
Loading
Loading