diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9e4111e3e..84f5cce89 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -249,13 +249,16 @@ describe("DesktopShellEnvironment", () => { "C:\\Windows\\System32", "C:\\Program Files\\Git\\cmd", "C:\\Program Files\\GitHub CLI", + "C:\\Program Files\\nodejs", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps", "C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd", "C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Custom\\Bin", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 281d3e7a0..7e1c58c33 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -5,7 +5,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { hideWindowsConsole } from "@threadlines/shared/childProcess"; -import { resolveKnownWindowsCliDirs } from "@threadlines/shared/shell"; +import { + buildWindowsEnvironmentCaptureCommand, + resolveKnownWindowsCliDirs, +} from "@threadlines/shared/shell"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -120,19 +123,6 @@ const capturePosixEnvironmentCommand = (names: ReadonlyArray) => }) .join("; "); -const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => - [ - "$ErrorActionPreference = 'Stop'", - ...names.flatMap((name) => { - return [ - `Write-Output '${startMarker(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, - "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", - `Write-Output '${endMarker(name)}'`, - ]; - }), - ].join("; "); - const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { const environment: EnvironmentPatch = {}; @@ -219,7 +209,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), "-NonInteractive", "-Command", - captureWindowsEnvironmentCommand(names), + buildWindowsEnvironmentCaptureCommand(names), ]; for (const command of WINDOWS_SHELL_CANDIDATES) { diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index ba269c222..d008b111d 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -2,6 +2,7 @@ import { randomUUIDv4 } from "@threadlines/shared/uuid"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; export const writeFileStringAtomically = (input: { readonly filePath: string; @@ -22,6 +23,22 @@ export const writeFileStringAtomically = (input: { const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`); yield* fs.writeFileString(tempPath, input.contents); - yield* fs.rename(tempPath, input.filePath); + // Windows readers and virus scanners can briefly block replacement. + // Retry the rename while keeping both the old file and completed temp file. + yield* fs.rename(tempPath, input.filePath).pipe( + Effect.retry({ + times: 10, + schedule: Schedule.spaced("50 millis"), + while: (error) => { + const cause = error.cause; + return ( + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause.code === "EPERM" || cause.code === "EACCES" || cause.code === "EBUSY") + ); + }, + }), + ); }), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 2e7903a78..be5ea122c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1774,7 +1774,7 @@ describe("CheckpointReactor", () => { ).toBe(false); }); - it("executes provider revert and emits thread.reverted for claude sessions", async () => { + it("rewinds Claude to the removed user message after a clock rollback", async () => { const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent"), projectWorkspaceRoot: path.join(os.tmpdir(), "t3-isolated-project-root-claude"), @@ -1814,7 +1814,7 @@ describe("CheckpointReactor", () => { }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:01.000Z", + createdAt: "2026-01-01T00:00:10.000Z", }), ); await Effect.runPromise( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index d3a71f73f..a69cd6799 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -19,6 +19,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@threadlines/shared/DrainableWorker"; import { normalizeWorkspacePath } from "@threadlines/shared/path"; +import { compareTranscriptOrder } from "@threadlines/shared/transcriptOrder"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; import { normalizeCheckpointFilePath } from "../../checkpointing/SelectiveRevert.ts"; @@ -116,16 +117,14 @@ function targetUserMessageIdForCheckpointRewind(input: { readonly id: MessageId; readonly role: string; readonly createdAt: string; + readonly eventSequence?: number | undefined; }>; }; readonly targetTurnCount: number; }): MessageId | undefined { const userMessages = input.thread.messages .filter((message) => message.role === "user") - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ); + .toSorted(compareTranscriptOrder); // Native provider file checkpointing rewinds to the state at a user message. // To keep turns 0..N, target the first user message being removed: N + 1. diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index fa61f602a..e5c8d5076 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1,3 +1,4 @@ +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { CheckpointRef, CommandId, @@ -2778,3 +2779,231 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }), ); }); + +it.effect("keeps first transcript event order after clock rollback, updates, and restart", () => + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig; + const persistence = makeSqlitePersistenceLive(dbPath); + const makeLayer = () => + Layer.mergeAll( + OrchestrationProjectionPipelineLive, + OrchestrationProjectionSnapshotQueryLive, + ).pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provideMerge(persistence), + ); + const threadId = ThreadId.make("thread-clock"); + const projectId = ProjectId.make("project-clock"); + const before = "2026-09-05T15:00:00.000Z"; + const after = "2026-09-05T14:00:00.000Z"; + const envelope = (id: string, occurredAt: string) => ({ + eventId: EventId.make(id), + aggregateKind: "thread" as const, + aggregateId: threadId, + occurredAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }); + yield* Effect.gen(function* () { + const store = yield* OrchestrationEventStore; + const pipeline = yield* OrchestrationProjectionPipeline; + yield* store.append({ + ...envelope("clock-project", before), + type: "project.created", + aggregateKind: "project", + aggregateId: projectId, + payload: { + projectId, + title: "Clock", + workspaceRoot: "/tmp/clock", + defaultModelSelection: null, + scripts: [], + createdAt: before, + updatedAt: before, + }, + }); + yield* store.append({ + ...envelope("clock-thread", before), + type: "thread.created", + payload: { + threadId, + projectId, + title: "Clock", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: before, + updatedAt: before, + }, + }); + yield* store.append({ + ...envelope("clock-user", before), + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make("user-clock"), + role: "user", + text: "hello", + turnId: null, + streaming: false, + createdAt: before, + updatedAt: before, + }, + }); + yield* store.append({ + ...envelope("clock-tool", after), + type: "thread.activity-appended", + payload: { + threadId, + activity: { + id: EventId.make("tool-clock"), + tone: "tool", + kind: "tool.started", + summary: "tool", + payload: { + itemType: "collab_agent_tool_call", + data: { + subagentLiveText: "Reading files", + item: { + id: "spawn-clock", + tool: "spawnAgent", + status: "inProgress", + agentThreadId: "agent-clock", + }, + }, + }, + turnId: null, + sequence: 900, + createdAt: after, + }, + }, + }); + yield* store.append({ + ...envelope("clock-assistant", after), + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make("assistant-clock"), + role: "assistant", + text: "reply", + turnId: null, + streaming: true, + createdAt: after, + updatedAt: after, + }, + }); + const proposedPlan = { + id: "plan-clock", + turnId: null, + planMarkdown: "plan", + implementedAt: null, + implementationThreadId: null, + dismissedAt: null, + createdAt: after, + updatedAt: after, + }; + yield* store.append({ + ...envelope("clock-plan", after), + type: "thread.proposed-plan-upserted", + payload: { threadId, proposedPlan }, + }); + yield* store.append({ + ...envelope("clock-tool-update", after), + type: "thread.activity-appended", + payload: { + threadId, + activity: { + id: EventId.make("tool-clock"), + tone: "tool", + kind: "tool.completed", + summary: "done", + payload: { + itemType: "collab_agent_tool_call", + data: { + item: { + id: "spawn-clock", + tool: "spawnAgent", + status: "completed", + agentThreadId: "agent-clock", + agentsStates: { "agent-clock": { status: "completed", message: "Done" } }, + }, + }, + }, + turnId: null, + sequence: 901, + createdAt: after, + }, + }, + }); + yield* store.append({ + ...envelope("clock-assistant-update", after), + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make("assistant-clock"), + role: "assistant", + text: "reply complete", + turnId: null, + streaming: false, + createdAt: after, + updatedAt: after, + }, + }); + yield* store.append({ + ...envelope("clock-plan-update", after), + type: "thread.proposed-plan-upserted", + payload: { threadId, proposedPlan: { ...proposedPlan, planMarkdown: "plan updated" } }, + }); + yield* pipeline.bootstrap; + }).pipe(Effect.provide(makeLayer())); + yield* Effect.gen(function* () { + const pipeline = yield* OrchestrationProjectionPipeline; + const query = yield* ProjectionSnapshotQuery; + yield* pipeline.bootstrap; + const fullSnapshot = yield* query.getSnapshot(); + assert.deepStrictEqual( + fullSnapshot.threads + .find((thread) => thread.id === threadId) + ?.subagents?.map((agent) => [ + agent.id, + agent.resultEventSequence, + agent.liveEventSequence, + ]), + [["agent-clock", 7, 4]], + ); + assert.deepStrictEqual( + fullSnapshot.threads + .find((thread) => thread.id === threadId) + ?.activities.map((activity) => [activity.id, activity.eventSequence, activity.sequence]), + [[EventId.make("tool-clock"), 4, 901]], + ); + for (const snapshot of [fullSnapshot, yield* query.getCommandReadModel()]) { + const thread = snapshot.threads.find((entry) => entry.id === threadId); + assert.ok(thread); + assert.deepStrictEqual( + thread.messages.map((message) => [message.id, message.eventSequence, message.createdAt]), + [ + ["user-clock", 3, before], + ["assistant-clock", 5, after], + ], + ); + assert.strictEqual(thread.messages[1]?.text, "reply complete"); + assert.deepStrictEqual( + thread.proposedPlans.map((plan) => [plan.id, plan.eventSequence, plan.planMarkdown]), + [["plan-clock", 6, "plan updated"]], + ); + } + }).pipe(Effect.provide(makeLayer())); + }).pipe( + Effect.provide( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "threadlines-clock-restart-" }), + NodeServices.layer, + ), + ), + ), +); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0c95e8f7f..340fac9d8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,3 +1,4 @@ +import { compareTranscriptOrder } from "@threadlines/shared/transcriptOrder"; import { ApprovalRequestId, type ChatAttachment, @@ -113,8 +114,7 @@ function derivePendingUserInputCountFromActivities( ): number { const ordered = [...activities].toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || - left.activityId.localeCompare(right.activityId), + compareTranscriptOrder(left, right) || left.activityId.localeCompare(right.activityId), ); return collectOpenPendingRequests(ordered, USER_INPUT_ACTIVITY_KINDS).length; } @@ -165,7 +165,9 @@ function deriveHasActionableProposedPlan(input: { }): boolean { const sorted = [...input.proposedPlans].toSorted( (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), + (left.eventSequence === undefined && right.eventSequence === undefined + ? left.updatedAt.localeCompare(right.updatedAt) + : compareTranscriptOrder(left, right)) || left.planId.localeCompare(right.planId), ); let latestForTurn: ProjectionThreadProposedPlan | null = null; @@ -235,8 +237,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || - left.messageId.localeCompare(right.messageId), + compareTranscriptOrder(left, right) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { @@ -258,8 +259,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || - left.messageId.localeCompare(right.messageId), + compareTranscriptOrder(left, right) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { @@ -998,6 +998,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const nextSkills = event.payload.skills ?? previousMessage?.skills; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, + eventSequence: previousMessage?.eventSequence ?? event.sequence, threadId: event.payload.threadId, turnId: event.payload.turnId, role: event.payload.role, @@ -1025,6 +1026,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const nextSkills = event.payload.skills ?? previousMessage?.skills; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, + eventSequence: previousMessage?.eventSequence ?? event.sequence, threadId: event.payload.threadId, turnId: event.payload.turnId, role: event.payload.role, @@ -1083,6 +1085,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.proposed-plan-upserted": yield* projectionThreadProposedPlanRepository.upsert({ planId: event.payload.proposedPlan.id, + eventSequence: event.sequence, threadId: event.payload.threadId, turnId: event.payload.proposedPlan.turnId, planMarkdown: event.payload.proposedPlan.planMarkdown, @@ -1135,6 +1138,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.activity-appended": { yield* projectionThreadActivityRepository.upsert({ activityId: event.payload.activity.id, + eventSequence: event.sequence, threadId: event.payload.threadId, turnId: event.payload.activity.turnId, tone: event.payload.activity.tone, @@ -1149,7 +1153,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const existingSubagents = yield* projectionThreadSubagentRepository.listByThreadId({ threadId: event.payload.threadId, }); - const nextSubagents = projectSubagentActivity(existingSubagents, event.payload.activity); + const nextSubagents = projectSubagentActivity(existingSubagents, { + ...event.payload.activity, + eventSequence: event.sequence, + }); if (nextSubagents !== existingSubagents) { yield* projectionThreadSubagentRepository.replaceByThreadId( event.payload.threadId, @@ -1193,6 +1200,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti payload: row.payload, turnId: row.turnId, ...(row.sequence !== undefined ? { sequence: row.sequence } : {}), + ...(row.eventSequence !== undefined ? { eventSequence: row.eventSequence } : {}), createdAt: row.createdAt, }), [], diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 255063596..234f3ad15 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1637,6 +1637,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql` INSERT INTO projection_thread_messages ( message_id, + event_sequence, thread_id, turn_id, role, @@ -1648,14 +1649,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) VALUES ( ${messageIdAt(index)}, + ${index}, 'thread-message-cap', NULL, ${index % 2 === 1 ? "user" : "assistant"}, ${`message ${index}`}, NULL, 0, - ${messageCreatedAt(index)}, - ${messageCreatedAt(index)} + ${messageCreatedAt(totalMessages - index)}, + ${messageCreatedAt(totalMessages - index)} ) `; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index aeadf26b8..3c4cf2988 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -90,11 +90,14 @@ const ProjectionProjectCatalogDbRowSchema = Schema.Struct({ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, + eventSequence: Schema.NullOr(NonNegativeInt), attachments: Schema.NullOr(Schema.fromJsonString(ChatAttachmentListLenient)), skills: Schema.NullOr(Schema.fromJsonString(ChatSkillReferenceList)), }), ); -const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; +const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan.mapFields( + Struct.assign({ eventSequence: Schema.NullOr(NonNegativeInt) }), +); const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), @@ -105,6 +108,7 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), sequence: Schema.NullOr(NonNegativeInt), + eventSequence: Schema.NullOr(NonNegativeInt), }), ); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; @@ -310,6 +314,7 @@ function mapThreadMessageRow( ): OrchestrationMessage { return { id: row.messageId, + ...(row.eventSequence !== null ? { eventSequence: row.eventSequence } : {}), role: row.role, text: row.text, ...(row.attachments !== null ? { attachments: row.attachments } : {}), @@ -326,6 +331,7 @@ function mapThreadActivityRow( ): OrchestrationThreadActivity { return { id: row.activityId, + ...(row.eventSequence !== null ? { eventSequence: row.eventSequence } : {}), tone: row.tone, kind: row.kind, summary: row.summary, @@ -403,6 +409,7 @@ function mapProposedPlanRow( ): OrchestrationProposedPlan { return { id: row.planId, + ...(row.eventSequence !== null ? { eventSequence: row.eventSequence } : {}), turnId: row.turnId, planMarkdown: row.planMarkdown, implementedAt: row.implementedAt, @@ -616,6 +623,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT message_id AS "messageId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", role, @@ -626,7 +634,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages - ORDER BY thread_id ASC, created_at ASC, message_id ASC + ORDER BY thread_id ASC, event_sequence ASC, created_at ASC, message_id ASC `, }); @@ -645,6 +653,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ROW_NUMBER() OVER ( PARTITION BY message.thread_id ORDER BY + message.event_sequence DESC, message.created_at DESC, message.message_id DESC ) AS message_rank @@ -652,6 +661,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) SELECT message_id AS "messageId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", role, @@ -663,7 +673,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt" FROM ranked_messages WHERE message_rank <= ${MAX_THREAD_MESSAGES} - ORDER BY thread_id ASC, created_at ASC, message_id ASC + ORDER BY thread_id ASC, event_sequence ASC, created_at ASC, message_id ASC `, }); @@ -674,6 +684,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT plan_id AS "planId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", plan_markdown AS "planMarkdown", @@ -683,7 +694,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans - ORDER BY thread_id ASC, created_at ASC, plan_id ASC + ORDER BY thread_id ASC, event_sequence ASC, created_at ASC, plan_id ASC `, }); @@ -698,6 +709,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ROW_NUMBER() OVER ( PARTITION BY activity.thread_id ORDER BY + activity.event_sequence DESC, activity.sequence DESC, activity.created_at DESC, activity.activity_id DESC @@ -706,6 +718,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) SELECT activity_id AS "activityId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", tone, @@ -718,6 +731,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE activity_rank <= ${MAX_THREAD_ACTIVITIES} ORDER BY thread_id ASC, + event_sequence ASC, sequence ASC, created_at ASC, activity_id ASC @@ -762,6 +776,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { reasoning_effort AS "reasoningEffort", model_provenance AS "modelProvenance", reasoning_effort_provenance AS "reasoningEffortProvenance", result_body AS "resultBody", result_created_at AS "resultCreatedAt", + result_event_sequence AS "resultEventSequence", live_event_sequence AS "liveEventSequence", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_subagents ORDER BY thread_id ASC, created_at ASC, subagent_id ASC @@ -1192,6 +1207,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT message_id AS "messageId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", role, @@ -1203,7 +1219,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt" FROM projection_thread_messages WHERE thread_id = ${threadId} - ORDER BY created_at ASC, message_id ASC + ORDER BY event_sequence ASC, created_at ASC, message_id ASC `, }); @@ -1214,6 +1230,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT plan_id AS "planId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", plan_markdown AS "planMarkdown", @@ -1224,7 +1241,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt" FROM projection_thread_proposed_plans WHERE thread_id = ${threadId} - ORDER BY created_at ASC, plan_id ASC + ORDER BY event_sequence ASC, created_at ASC, plan_id ASC `, }); @@ -1238,6 +1255,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_thread_activities WHERE thread_id = ${threadId} ORDER BY + event_sequence DESC, sequence DESC, created_at DESC, activity_id DESC @@ -1245,6 +1263,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) SELECT activity_id AS "activityId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", tone, @@ -1255,6 +1274,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt" FROM limited_activities ORDER BY + event_sequence ASC, sequence ASC, created_at ASC, activity_id ASC @@ -1313,6 +1333,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { reasoning_effort_provenance AS "reasoningEffortProvenance", result_body AS "resultBody", result_created_at AS "resultCreatedAt", + result_event_sequence AS "resultEventSequence", + live_event_sequence AS "liveEventSequence", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_subagents @@ -1593,6 +1615,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; threadProposedPlans.push({ id: row.planId, + ...(row.eventSequence !== null ? { eventSequence: row.eventSequence } : {}), turnId: row.turnId, planMarkdown: row.planMarkdown, implementedAt: row.implementedAt, diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 9c2b3760a..3fdcc3a6d 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -1,3 +1,4 @@ +import { compareTranscriptOrder } from "@threadlines/shared/transcriptOrder"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -197,16 +198,20 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => : message.text, sourceMessageId: message.id, createdAt: message.createdAt, + ...(message.eventSequence !== undefined ? { eventSequence: message.eventSequence } : {}), })), ...sourceThread.activities .filter((activity) => activity.tone === "tool") - .filter((activity) => activity.createdAt <= sourceMessage.createdAt) + .filter((activity) => compareTranscriptOrder(activity, sourceMessage) <= 0) .map((activity) => ({ kind: "tool" as const, text: activity.summary, createdAt: activity.createdAt, + ...(activity.eventSequence !== undefined + ? { eventSequence: activity.eventSequence } + : {}), })), - ].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); + ].toSorted(compareTranscriptOrder); const split = splitSeedEntriesByBudget( entries.map((entry) => entry.kind === "message" diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 84461831a..680ae8ee6 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,3 +1,4 @@ +import { compareTranscriptOrder } from "@threadlines/shared/transcriptOrder"; import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@threadlines/contracts"; import { OrchestrationCheckpointSummary, @@ -112,10 +113,7 @@ function retainThreadMessagesAfterRevert( !retainedMessageIds.has(message.id) && (message.turnId === null || retainedTurnIds.has(message.turnId)), ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) + .toSorted(compareTranscriptOrder) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { retainedMessageIds.add(message.id); @@ -134,10 +132,7 @@ function retainThreadMessagesAfterRevert( !retainedMessageIds.has(message.id) && (message.turnId === null || retainedTurnIds.has(message.turnId)), ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) + .toSorted(compareTranscriptOrder) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { retainedMessageIds.add(message.id); @@ -178,6 +173,9 @@ function compareThreadActivities( left: OrchestrationThread["activities"][number], right: OrchestrationThread["activities"][number], ): number { + if (left.eventSequence !== undefined || right.eventSequence !== undefined) { + return compareTranscriptOrder(left, right); + } if (left.sequence !== undefined && right.sequence !== undefined) { if (left.sequence !== right.sequence) { return left.sequence - right.sequence; @@ -465,6 +463,7 @@ export function projectEvent( OrchestrationMessage, { id: payload.messageId, + eventSequence: event.sequence, role: payload.role, text: payload.text, ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), @@ -543,6 +542,7 @@ export function projectEvent( OrchestrationMessage, { id: payload.messageId, + eventSequence: event.sequence, role: payload.role, text: payload.text, ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), @@ -558,7 +558,9 @@ export function projectEvent( const existingMessage = thread.messages.find((entry) => entry.id === message.id); const messages = existingMessage - ? thread.messages.map((entry) => (entry.id === message.id ? message : entry)) + ? thread.messages.map((entry) => + entry.id === message.id ? { ...message, eventSequence: entry.eventSequence } : entry, + ) : [...thread.messages, message]; return { @@ -699,14 +701,17 @@ export function projectEvent( return nextBase; } + const existingPlan = thread.proposedPlans.find( + (entry) => entry.id === payload.proposedPlan.id, + ); const proposedPlans = [ ...thread.proposedPlans.filter((entry) => entry.id !== payload.proposedPlan.id), - payload.proposedPlan, + { + ...payload.proposedPlan, + eventSequence: existingPlan ? existingPlan.eventSequence : event.sequence, + }, ] - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) + .toSorted(compareTranscriptOrder) .slice(-MAX_THREAD_PROPOSED_PLANS); return { @@ -921,13 +926,22 @@ export function projectEvent( return nextBase; } + const existingActivity = thread.activities.find( + (entry) => entry.id === payload.activity.id, + ); const activities = [ ...thread.activities.filter((entry) => entry.id !== payload.activity.id), - payload.activity, + { + ...payload.activity, + eventSequence: existingActivity ? existingActivity.eventSequence : event.sequence, + }, ] .toSorted(compareThreadActivities) .slice(-MAX_THREAD_ACTIVITIES); - const subagents = projectSubagentActivity(thread.subagents ?? [], payload.activity); + const subagents = projectSubagentActivity(thread.subagents ?? [], { + ...payload.activity, + eventSequence: event.sequence, + }); return { ...nextBase, diff --git a/apps/server/src/orchestration/subagentProjection.test.ts b/apps/server/src/orchestration/subagentProjection.test.ts index 2989914cc..9daa3413e 100644 --- a/apps/server/src/orchestration/subagentProjection.test.ts +++ b/apps/server/src/orchestration/subagentProjection.test.ts @@ -650,3 +650,47 @@ describe("projectSubagentActivity", () => { expect(roster[0]?.spawnCallId).toBe("call-1"); }); }); + +it("keeps result and live event positions through metadata updates", () => { + let roster = projectSubagentActivity([], { + ...activity({ + id: "live-order", + kind: "tool.updated", + payload: { + itemType: "collab_agent_tool_call", + data: { + subagentLiveText: "Reading files", + item: { + id: "spawn-order", + tool: "spawnAgent", + status: "inProgress", + agentThreadId: "agent-order", + }, + }, + }, + }), + eventSequence: 10, + }); + for (const [eventSequence, payload] of [ + [20, { agentThreadId: "agent-order", status: "completed", resultBody: "Done" }], + [30, { agentThreadId: "agent-order", resultBody: "Done", role: "Reviewer" }], + [40, { agentThreadId: "agent-order", isBackgrounded: true }], + ] as const) { + roster = projectSubagentActivity(roster, { + ...activity({ + id: `metadata-${eventSequence}`, + kind: "subagent.metadata", + payload, + createdAt: "2026-08-14T23:00:00.000Z", + }), + eventSequence, + }); + } + expect(roster[0]).toMatchObject({ + resultBody: "Done", + resultEventSequence: 20, + liveEventSequence: 10, + role: "Reviewer", + isBackgrounded: true, + }); +}); diff --git a/apps/server/src/orchestration/subagentProjection.ts b/apps/server/src/orchestration/subagentProjection.ts index 9889dbcf8..d2d416435 100644 --- a/apps/server/src/orchestration/subagentProjection.ts +++ b/apps/server/src/orchestration/subagentProjection.ts @@ -109,6 +109,8 @@ interface SubagentPatch { readonly reasoningEffortProvenance?: OrchestrationSubagentSettingProvenance | null; readonly resultBody?: string | null; readonly resultCreatedAt?: string | null; + readonly resultEventSequence?: number | undefined; + readonly liveEventSequence?: number | undefined; /** Task id of the notification a replayed agent result was built from. */ readonly notificationTaskId?: string | null; } @@ -156,7 +158,8 @@ function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | n modelProvenance: modelSource === "explicit" || modelSource === "inherited" ? modelSource : null, reasoningEffortProvenance: effortSource, resultBody: typeof payload.resultBody === "string" ? payload.resultBody : null, - resultCreatedAt: text(payload.resultCreatedAt), + resultCreatedAt: + text(payload.resultCreatedAt) ?? (text(payload.resultBody) ? activity.createdAt : null), }; } @@ -233,6 +236,9 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] { reasoningEffortProvenance: reasoningEffort ? "explicit" : null, resultBody: text(state?.message), resultCreatedAt: text(state?.message) ? activity.createdAt : null, + ...(text(data?.subagentLiveText) && activity.eventSequence !== undefined + ? { liveEventSequence: activity.eventSequence } + : {}), notificationTaskId, }; }); @@ -252,6 +258,14 @@ function mergeSubagent( patch: SubagentPatch, activity: OrchestrationThreadActivity, ): OrchestrationSubagent { + const resultIsNew = + patch.resultBody !== null && + patch.resultBody !== undefined && + (activity.kind !== "subagent.metadata" || patch.resultBody !== current?.resultBody); + const resultEventSequence = + patch.resultEventSequence ?? + (resultIsNew ? activity.eventSequence : current?.resultEventSequence); + const liveEventSequence = patch.liveEventSequence ?? current?.liveEventSequence; return { id: patch.agentThreadId ?? current?.agentThreadId ?? patch.id, agentThreadId: mergeValue(patch.agentThreadId, current?.agentThreadId ?? null), @@ -279,7 +293,12 @@ function mergeSubagent( current?.reasoningEffortProvenance ?? null, ), resultBody: mergeValue(patch.resultBody, current?.resultBody ?? null), - resultCreatedAt: mergeValue(patch.resultCreatedAt, current?.resultCreatedAt ?? null), + resultCreatedAt: + activity.kind === "subagent.metadata" && !resultIsNew + ? (current?.resultCreatedAt ?? null) + : mergeValue(patch.resultCreatedAt, current?.resultCreatedAt ?? null), + ...(resultEventSequence !== undefined ? { resultEventSequence } : {}), + ...(liveEventSequence !== undefined ? { liveEventSequence } : {}), createdAt: current?.createdAt ?? activity.createdAt, updatedAt: activity.createdAt, }; @@ -499,5 +518,11 @@ function duplicatePatchFrom(duplicate: OrchestrationSubagent): SubagentPatch { reasoningEffortProvenance: duplicate.reasoningEffortProvenance, resultBody: duplicate.resultBody, resultCreatedAt: duplicate.resultCreatedAt, + ...(duplicate.resultEventSequence !== undefined + ? { resultEventSequence: duplicate.resultEventSequence } + : {}), + ...(duplicate.liveEventSequence !== undefined + ? { liveEventSequence: duplicate.liveEventSequence } + : {}), }; } diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 3285136a6..18cc6c4f4 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,3 +1,5 @@ +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; import { ProjectId, ThreadId, ProviderInstanceId } from "@threadlines/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -133,3 +135,131 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); }); + +it.effect("backfills transcript order from first events without changing timestamps", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 49 }); + const before = "2026-09-05T15:00:00.000Z"; + const after = "2026-09-05T14:00:00.000Z"; + const agentActivity = (completed: boolean) => ({ + threadId: "thread-clock", + activity: { + id: "activity-clock", + turnId: null, + tone: "tool", + kind: "tool.updated", + summary: "Agent", + createdAt: after, + payload: { + itemType: "collab_agent_tool_call", + data: { + subagentLiveText: completed ? null : "Reading files", + item: { + id: "spawn-clock", + tool: "spawnAgent", + agentThreadId: "agent-clock", + status: completed ? "completed" : "inProgress", + agentsStates: completed + ? { "agent-clock": { status: "completed", message: "Done" } } + : {}, + }, + }, + }, + }, + }); + const events = [ + ["thread.message-sent", { messageId: "message-clock" }], + ["thread.activity-appended", agentActivity(false)], + ["thread.proposed-plan-upserted", { proposedPlan: { id: "plan-clock" } }], + ["thread.message-sent", { messageId: "message-clock" }], + ["thread.activity-appended", agentActivity(true)], + ["thread.proposed-plan-upserted", { proposedPlan: { id: "plan-clock" } }], + ["thread.follow-up-accepted", { messageId: "follow-up-clock" }], + ] as const; + for (const [index, [type, payload]] of events.entries()) { + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json + ) VALUES ( + ${`clock-${index}`}, 'thread', 'thread-clock', ${index + 1}, ${type}, + ${index === 0 ? before : after}, 'provider', ${JSON.stringify(payload)}, '{}' + ) + `; + } + for (const messageId of ["message-clock", "follow-up-clock", "legacy-clock"]) { + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${messageId}, 'thread-clock', NULL, 'user', 'text', 0, ${before}, ${after}) + `; + } + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ('activity-clock', 'thread-clock', NULL, 'tool', 'tool.started', 'tool', '{}', 900, ${after}) + `; + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, created_at, updated_at + ) VALUES ('plan-clock', 'thread-clock', NULL, 'plan', ${after}, ${after}) + `; + yield* sql` + INSERT INTO projection_thread_subagents ( + thread_id, subagent_id, agent_thread_id, status, result_body, result_created_at, created_at, updated_at + ) VALUES ('thread-clock', 'agent-clock', 'agent-clock', 'completed', 'Done', ${after}, ${before}, ${after}) + `; + yield* sql` + INSERT INTO projection_thread_subagents ( + thread_id, subagent_id, spawn_call_id, status, result_body, created_at, updated_at + ) VALUES ('thread-clock', 'other-agent', 'spawn-clock', 'completed', 'Done', ${before}, ${after}) + `; + yield* runMigrations(); + assert.deepStrictEqual( + yield* sql` + SELECT result_event_sequence, live_event_sequence, result_body, status, created_at + FROM projection_thread_subagents ORDER BY subagent_id + `, + [ + { + result_event_sequence: 5, + live_event_sequence: 2, + result_body: "Done", + status: "completed", + created_at: before, + }, + { + result_event_sequence: null, + live_event_sequence: null, + result_body: "Done", + status: "completed", + created_at: before, + }, + ], + ); + assert.deepStrictEqual( + yield* sql` + SELECT message_id, event_sequence, created_at, updated_at + FROM projection_thread_messages ORDER BY event_sequence ASC + `, + [ + { message_id: "legacy-clock", event_sequence: null, created_at: before, updated_at: after }, + { message_id: "message-clock", event_sequence: 1, created_at: before, updated_at: after }, + { message_id: "follow-up-clock", event_sequence: 7, created_at: before, updated_at: after }, + ], + ); + assert.deepStrictEqual( + yield* sql` + SELECT event_sequence, sequence, created_at FROM projection_thread_activities + `, + [{ event_sequence: 2, sequence: 900, created_at: after }], + ); + assert.deepStrictEqual( + yield* sql` + SELECT event_sequence, created_at FROM projection_thread_proposed_plans + `, + [{ event_sequence: 3, created_at: after }], + ); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index c14ee2d55..816d313bd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -20,6 +20,7 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), sequence: Schema.NullOr(NonNegativeInt), + eventSequence: Schema.NullOr(NonNegativeInt), }), ); @@ -39,6 +40,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { sql` INSERT INTO projection_thread_activities ( activity_id, + event_sequence, thread_id, turn_id, tone, @@ -50,6 +52,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { ) VALUES ( ${row.activityId}, + ${row.eventSequence ?? null}, ${row.threadId}, ${row.turnId}, ${row.tone}, @@ -79,6 +82,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { sql` SELECT activity_id AS "activityId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", tone, @@ -90,7 +94,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { FROM projection_thread_activities WHERE thread_id = ${threadId} ORDER BY - CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + event_sequence ASC, sequence ASC, created_at ASC, activity_id ASC @@ -127,6 +131,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => ({ activityId: row.activityId, + ...(row.eventSequence !== null ? { eventSequence: row.eventSequence } : {}), threadId: row.threadId, turnId: row.turnId, tone: row.tone, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index e83428a91..d51d8cec8 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,11 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachmentListLenient, ChatSkillReferenceList } from "@threadlines/contracts"; +import { + ChatAttachmentListLenient, + ChatSkillReferenceList, + NonNegativeInt, +} from "@threadlines/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -20,6 +24,7 @@ import { const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, + eventSequence: Schema.NullOr(NonNegativeInt), attachments: Schema.NullOr(Schema.fromJsonString(ChatAttachmentListLenient)), skills: Schema.NullOr(Schema.fromJsonString(ChatSkillReferenceList)), }), @@ -30,6 +35,7 @@ function toProjectionThreadMessage( ): ProjectionThreadMessage { return { messageId: row.messageId, + ...(row.eventSequence !== null ? { eventSequence: row.eventSequence } : {}), threadId: row.threadId, turnId: row.turnId, role: row.role, @@ -54,6 +60,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return sql` INSERT INTO projection_thread_messages ( message_id, + event_sequence, thread_id, turn_id, role, @@ -66,6 +73,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ) VALUES ( ${row.messageId}, + ${row.eventSequence ?? null}, ${row.threadId}, ${row.turnId}, ${row.role}, @@ -118,6 +126,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { sql` SELECT message_id AS "messageId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", role, @@ -140,6 +149,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { sql` SELECT message_id AS "messageId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", role, @@ -151,7 +161,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { updated_at AS "updatedAt" FROM projection_thread_messages WHERE thread_id = ${threadId} - ORDER BY created_at ASC, message_id ASC + ORDER BY event_sequence ASC, created_at ASC, message_id ASC `, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 6c7ef0185..18445a1c9 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -1,3 +1,6 @@ +import { NonNegativeInt } from "@threadlines/contracts"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -12,6 +15,10 @@ import { type ProjectionThreadProposedPlanRepositoryShape, } from "../Services/ProjectionThreadProposedPlans.ts"; +const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan.mapFields( + Struct.assign({ eventSequence: Schema.NullOr(NonNegativeInt) }), +); + const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -20,6 +27,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { execute: (row) => sql` INSERT INTO projection_thread_proposed_plans ( plan_id, + event_sequence, thread_id, turn_id, plan_markdown, @@ -31,6 +39,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ) VALUES ( ${row.planId}, + ${row.eventSequence ?? null}, ${row.threadId}, ${row.turnId}, ${row.planMarkdown}, @@ -55,10 +64,11 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ Request: ListProjectionThreadProposedPlansInput, - Result: ProjectionThreadProposedPlan, + Result: ProjectionThreadProposedPlanDbRowSchema, execute: ({ threadId }) => sql` SELECT plan_id AS "planId", + event_sequence AS "eventSequence", thread_id AS "threadId", turn_id AS "turnId", plan_markdown AS "planMarkdown", @@ -69,7 +79,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { updated_at AS "updatedAt" FROM projection_thread_proposed_plans WHERE thread_id = ${threadId} - ORDER BY created_at ASC, plan_id ASC + ORDER BY event_sequence ASC, created_at ASC, plan_id ASC `, }); @@ -88,6 +98,12 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => listProjectionThreadProposedPlanRows(input).pipe( + Effect.map((rows) => + rows.map(({ eventSequence, ...row }) => ({ + ...row, + ...(eventSequence !== null ? { eventSequence } : {}), + })), + ), Effect.mapError( toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), ), diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts b/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts index 74445dabb..23cfeaf9a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts @@ -1,4 +1,4 @@ -import type { OrchestrationSubagent } from "@threadlines/contracts"; +import { NonNegativeInt, type OrchestrationSubagent } from "@threadlines/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -18,14 +18,21 @@ import { export const ProjectionThreadSubagentDbRowSchema = ProjectionThreadSubagent.mapFields( Struct.assign({ isBackgrounded: Schema.NullOr(Schema.Number), + resultEventSequence: Schema.NullOr(NonNegativeInt), + liveEventSequence: Schema.NullOr(NonNegativeInt), }), ); export function toProjectionThreadSubagent( row: Schema.Schema.Type, ): ProjectionThreadSubagent { - const { isBackgrounded, ...rest } = row; - return isBackgrounded === null ? rest : { ...rest, isBackgrounded: isBackgrounded === 1 }; + const { isBackgrounded, resultEventSequence, liveEventSequence, ...rest } = row; + return { + ...rest, + ...(isBackgrounded !== null ? { isBackgrounded: isBackgrounded === 1 } : {}), + ...(resultEventSequence !== null ? { resultEventSequence } : {}), + ...(liveEventSequence !== null ? { liveEventSequence } : {}), + }; } export function subagentBackgroundedColumn(row: OrchestrationSubagent): number | null { @@ -50,6 +57,7 @@ const makeProjectionThreadSubagentRepository = Effect.gen(function* () { reasoning_effort AS "reasoningEffort", model_provenance AS "modelProvenance", reasoning_effort_provenance AS "reasoningEffortProvenance", result_body AS "resultBody", result_created_at AS "resultCreatedAt", + result_event_sequence AS "resultEventSequence", live_event_sequence AS "liveEventSequence", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_subagents WHERE thread_id = ${threadId} @@ -96,6 +104,8 @@ const makeProjectionThreadSubagentRepository = Effect.gen(function* () { model_provenance: row.modelProvenance, reasoning_effort_provenance: row.reasoningEffortProvenance, result_body: row.resultBody, + result_event_sequence: row.resultEventSequence ?? null, + live_event_sequence: row.liveEventSequence ?? null, result_created_at: row.resultCreatedAt, created_at: row.createdAt, updated_at: row.updatedAt, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9a75ae61d..90c9df208 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -62,6 +62,7 @@ import Migration0046 from "./Migrations/046_ProjectionTurnsCheckpointCompletedAt import Migration0047 from "./Migrations/047_ProjectionThreadSubagents.ts"; import Migration0048 from "./Migrations/048_BackfillThreadSubagents.ts"; import Migration0049 from "./Migrations/049_ProjectionThreadSubagentsBackgrounded.ts"; +import Migration0050 from "./Migrations/050_ProjectionTranscriptEventSequence.ts"; /** * Migration loader with all migrations defined inline. @@ -123,6 +124,7 @@ export const migrationEntries = [ [47, "ProjectionThreadSubagents", Migration0047], [48, "BackfillThreadSubagents", Migration0048], [49, "ProjectionThreadSubagentsBackgrounded", Migration0049], + [50, "ProjectionTranscriptEventSequence", Migration0050], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts b/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts new file mode 100644 index 000000000..abe0edfd4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionTranscriptEventSequence.ts @@ -0,0 +1,124 @@ +import { ThreadActivityAppendedPayload, type OrchestrationSubagent } from "@threadlines/contracts"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import { projectSubagentActivity } from "../../orchestration/subagentProjection.ts"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const decodeActivity = Schema.decodeUnknownOption( + Schema.fromJsonString(ThreadActivityAppendedPayload), +); + +/** Order transcript entries by their first durable event, even after a clock correction. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_thread_messages ADD COLUMN event_sequence INTEGER`; + yield* sql` + WITH first_events AS ( + SELECT stream_id, json_extract(payload_json, '$.messageId') AS item_id, + MIN(sequence) AS first_sequence + FROM orchestration_events + WHERE event_type IN ('thread.message-sent', 'thread.follow-up-accepted') + GROUP BY stream_id, json_extract(payload_json, '$.messageId') + ) + UPDATE projection_thread_messages + SET event_sequence = ( + SELECT first_sequence FROM first_events + WHERE stream_id = projection_thread_messages.thread_id + AND item_id = projection_thread_messages.message_id + ) + `; + yield* sql` + CREATE INDEX idx_projection_thread_messages_event_order + ON projection_thread_messages(thread_id, event_sequence, created_at, message_id) + `; + + yield* sql`ALTER TABLE projection_thread_proposed_plans ADD COLUMN event_sequence INTEGER`; + yield* sql` + WITH first_events AS ( + SELECT stream_id, json_extract(payload_json, '$.proposedPlan.id') AS item_id, + MIN(sequence) AS first_sequence + FROM orchestration_events + WHERE event_type IN ('thread.proposed-plan-upserted') + GROUP BY stream_id, json_extract(payload_json, '$.proposedPlan.id') + ) + UPDATE projection_thread_proposed_plans + SET event_sequence = ( + SELECT first_sequence FROM first_events + WHERE stream_id = projection_thread_proposed_plans.thread_id + AND item_id = projection_thread_proposed_plans.plan_id + ) + `; + yield* sql` + CREATE INDEX idx_projection_thread_proposed_plans_event_order + ON projection_thread_proposed_plans(thread_id, event_sequence, created_at, plan_id) + `; + + yield* sql`ALTER TABLE projection_thread_activities ADD COLUMN event_sequence INTEGER`; + yield* sql` + WITH first_events AS ( + SELECT stream_id, json_extract(payload_json, '$.activity.id') AS item_id, + MIN(sequence) AS first_sequence + FROM orchestration_events + WHERE event_type IN ('thread.activity-appended') + GROUP BY stream_id, json_extract(payload_json, '$.activity.id') + ) + UPDATE projection_thread_activities + SET event_sequence = ( + SELECT first_sequence FROM first_events + WHERE stream_id = projection_thread_activities.thread_id + AND item_id = projection_thread_activities.activity_id + ) + `; + yield* sql` + CREATE INDEX idx_projection_thread_activities_event_order + ON projection_thread_activities(thread_id, event_sequence, sequence, created_at, activity_id) + `; + + yield* sql`ALTER TABLE projection_thread_subagents ADD COLUMN result_event_sequence INTEGER`; + yield* sql`ALTER TABLE projection_thread_subagents ADD COLUMN live_event_sequence INTEGER`; + + // Replay only retained roster activities, so reverted runs cannot restore results. + // Update ordering metadata alone; keep every existing roster field untouched. + const threads = yield* sql<{ + thread_id: string; + }>`SELECT DISTINCT thread_id FROM projection_thread_subagents`; + for (const { thread_id: threadId } of threads) { + const events = yield* sql<{ sequence: number; payload_json: string }>` + SELECT event.sequence, event.payload_json + FROM orchestration_events AS event + WHERE event.aggregate_kind = 'thread' AND event.stream_id = ${threadId} + AND event.event_type = 'thread.activity-appended' + AND json_valid(event.payload_json) + AND ( + json_extract(event.payload_json, '$.activity.kind') IN ('task.started', 'task.progress', 'task.completed', 'subagent.metadata') + OR json_extract(event.payload_json, '$.activity.payload.itemType') = 'collab_agent_tool_call' + ) + AND EXISTS ( + SELECT 1 FROM projection_thread_activities AS activity + WHERE activity.thread_id = ${threadId} + AND activity.activity_id = json_extract(event.payload_json, '$.activity.id') + ) + ORDER BY event.sequence ASC + `; + let subagents: ReadonlyArray = []; + for (const event of events) { + const decoded = decodeActivity(event.payload_json); + if (Option.isNone(decoded)) continue; + subagents = projectSubagentActivity(subagents, { + ...decoded.value.activity, + eventSequence: event.sequence, + }); + } + for (const subagent of subagents) { + yield* sql` + UPDATE projection_thread_subagents + SET result_event_sequence = CASE WHEN result_body IS ${subagent.resultBody} + THEN ${subagent.resultEventSequence ?? null} ELSE NULL END, + live_event_sequence = ${subagent.liveEventSequence ?? null} + WHERE thread_id = ${threadId} AND subagent_id = ${subagent.id} + `; + } + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index ee171768e..151e2f7fb 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -22,6 +22,7 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThreadActivity = Schema.Struct({ activityId: EventId, + eventSequence: Schema.optional(NonNegativeInt), threadId: ThreadId, turnId: Schema.NullOr(TurnId), tone: OrchestrationThreadActivityTone, diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 2e1c7ece4..1c0d630b5 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -10,6 +10,7 @@ import { ChatAttachment, ChatSkillReferenceList, MessageId, + NonNegativeInt, OrchestrationMessageRole, ThreadId, TurnId, @@ -24,6 +25,7 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThreadMessage = Schema.Struct({ messageId: MessageId, + eventSequence: Schema.optional(NonNegativeInt), threadId: ThreadId, turnId: Schema.NullOr(TurnId), role: OrchestrationMessageRole, diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index fd9c581ed..f4735d2c6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -1,5 +1,6 @@ import { IsoDateTime, + NonNegativeInt, OrchestrationProposedPlanId, ThreadId, TrimmedNonEmptyString, @@ -13,6 +14,7 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThreadProposedPlan = Schema.Struct({ planId: OrchestrationProposedPlanId, + eventSequence: Schema.optional(NonNegativeInt), threadId: ThreadId, turnId: Schema.NullOr(TurnId), planMarkdown: TrimmedNonEmptyString, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 6fc79c0d0..661434a3a 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -51,10 +51,14 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + refreshProviderInstanceEnvironment, +} from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, + makeWindowsNativeInstaller, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; @@ -253,6 +257,12 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@anthropic-ai/claude-code", homebrewFormula: "claude-code", + nativeInstall: { + win32: makeWindowsNativeInstaller({ + url: "https://claude.ai/install.ps1", + lockKey: "claude-native-verified-win32", + }), + }, nativeUpdate: { executable: "claude", args: ["update"], @@ -322,7 +332,7 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + let maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, platform: process.platform, @@ -380,8 +390,17 @@ export const ClaudeDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ - maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), + get maintenanceCapabilities() { + return maintenanceCapabilities; + }, + getSettings: Effect.gen(function* () { + refreshProviderInstanceEnvironment(environment, processEnv); + maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + return effectiveConfig; + }), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index bb9587a71..66a527ba6 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -14,6 +14,14 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { + it.effect("uses refreshed PATH when a retained environment is passed to a new process", () => + Effect.gen(function* () { + const baseEnv = { PATH: "/old/bin" }; + const environment = yield* makeClaudeEnvironment({ homePath: "" }, baseEnv); + baseEnv.PATH = "/new/bin"; + expect({ ...environment }.PATH).toBe("/new/bin"); + }), + ); it.effect("uses the process home when no Claude home override is configured", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 4372582d0..f3e10bc7d 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -41,6 +41,19 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function baseEnv: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const environment: NodeJS.ProcessEnv = { ...baseEnv }; + // Adapters retain this environment across turns. Read the driver's refreshed + // PATH at spawn time so a newly installed CLI works without rebuilding it. + if (process.platform === "win32") { + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "PATH") delete environment[key]; + } + } + Object.defineProperty(environment, "PATH", { + enumerable: true, + configurable: true, + get: () => + process.platform === "win32" ? (baseEnv.PATH ?? baseEnv.Path ?? baseEnv.path) : baseEnv.PATH, + }); // The CLI re-runs a turn it considers interrupted when the session is // resumed. The orchestration core already records that turn as // interrupted, and a silent re-run would land its output (and repeat its diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index f5d04221a..eb8a41b05 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -47,10 +47,15 @@ import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + refreshProviderInstanceEnvironment, +} from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, + makeWindowsNativeInstaller, + normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -62,11 +67,27 @@ const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const WINDOWS_NATIVE_INSTALL = makeWindowsNativeInstaller({ + url: "https://chatgpt.com/codex/install.ps1", + lockKey: "codex-native-win32", + environmentPatch: { CODEX_NON_INTERACTIVE: "1" }, +}); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", homebrewFormula: "codex", - nativeUpdate: null, + nativeInstall: { win32: WINDOWS_NATIVE_INSTALL }, + nativeUpdate: { + ...WINDOWS_NATIVE_INSTALL, + isCommandPath: (commandPath) => { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/programs/openai/codex/bin/codex.exe") || + normalized.includes("/packages/standalone/") + ); + }, + unsupportedOneClickPlatforms: ["darwin", "linux"], + }, }); /** @@ -115,6 +136,7 @@ export const CodexDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -142,7 +164,7 @@ export const CodexDriver: ProviderDriver = { enabled, homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + let maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, env: processEnv, }); @@ -164,8 +186,17 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshot = yield* makeManagedServerProvider({ - maintenanceCapabilities, - getSettings: Effect.succeed(effectiveConfig), + get maintenanceCapabilities() { + return maintenanceCapabilities; + }, + getSettings: Effect.gen(function* () { + refreshProviderInstanceEnvironment(environment, processEnv); + maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + return effectiveConfig; + }), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 7daef88bb..0613062b7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -40,6 +40,7 @@ import { buildServerProvider, DEFAULT_TIMEOUT_MS, detailFromResult, + extractAuthBoolean, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -1226,7 +1227,37 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? resolveTokenUsage(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : Effect.succeed(undefined); - if (!capabilities) { + // SDK initialization also succeeds when signed out. Only account credential + // fields establish configured auth; a model catalog alone does not. + const credentialFields = [ + capabilities?.email, + capabilities?.subscriptionType, + capabilities?.tokenSource, + environment[CLAUDE_CODE_OAUTH_TOKEN_ENV], + environment.ANTHROPIC_AUTH_TOKEN, + environment.ANTHROPIC_API_KEY, + ]; + let authenticated: boolean | undefined = credentialFields.some( + (value) => typeof value === "string" && value.trim().length > 0 && value.trim() !== "none", + ) + ? true + : undefined; + if (authenticated === undefined) { + const authProbe = yield* runClaudeCommand(claudeSettings, ["auth", "status"], environment).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { + try { + // A signed-out CLI returns exit code 1 with a valid loggedIn:false result. + authenticated = extractAuthBoolean(JSON.parse(authProbe.success.value.stdout)); + } catch { + // Older CLIs and failed probes may not return structured auth status. + } + } + } + + if (!capabilities || authenticated !== true) { const tokenUsage = yield* tokenUsageEffect; const localTokenAccountUsage = tokenUsage ? { @@ -1247,9 +1278,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: parsedVersion, status: "warning", - auth: { status: "unknown" }, + auth: { status: authenticated === false ? "unauthenticated" : "unknown" }, ...(localTokenAccountUsage ? { accountUsage: localTokenAccountUsage } : {}), - message: "Could not verify Claude authentication status from initialization result.", + message: + authenticated === false + ? "Claude is not signed in. Run `claude auth login` to sign in." + : "Could not verify Claude authentication status from initialization result.", }, }); } diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 4b220872d..84aa46169 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2611,6 +2611,47 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // ── checkClaudeProviderStatus tests ────────────────────────── describe("checkClaudeProviderStatus", () => { + it.effect("does not treat a fresh Claude model catalog as configured credentials", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "none" }), + {}, + () => Effect.die("Signed-out accounts must not probe subscription usage"), + ); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, "2.1.261"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual(status.auth.capabilities, undefined); + assert.strictEqual(status.accountUsage, undefined); + assert.strictEqual( + status.message, + "Claude is not signed in. Run `claude auth login` to sign in.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.261\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: JSON.stringify({ + loggedIn: false, + authMethod: "none", + apiProvider: "firstParty", + analyticsDisabled: false, + projectsDirectory: "C:\\Users\\threadlines\\.claude\\projects", + }), + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns ready when claude is installed and authenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( @@ -3039,6 +3080,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 }; throw new Error(`Unexpected args: ${joined}`); }), ), @@ -3095,6 +3138,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 }; throw new Error(`Unexpected args: ${joined}`); }), ), @@ -3191,6 +3236,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 }; throw new Error(`Unexpected args: ${joined}`); }), ), @@ -3399,6 +3446,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 }; throw new Error(`Unexpected args: ${joined}`); }), ), @@ -3459,6 +3508,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 }; throw new Error(`Unexpected args: ${joined}`); }), ), @@ -3728,7 +3779,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( recorded.commands.map((command) => command.env?.HOME), - [path.resolve(claudeHome)], + [path.resolve(claudeHome), path.resolve(claudeHome)], ); }).pipe(Effect.provide(recorded.layer)); }); @@ -3883,34 +3934,36 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ); - it.effect("returns warning when the Claude initialization result is unavailable", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - noClaudeCapabilities, - ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), + it.effect( + "reports signed out when Claude initialization is unavailable and auth is false", + () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noClaudeCapabilities, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude is not signed in. Run `claude auth login` to sign in.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), ), - ), ); }); }, diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f28..9f91720d0 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,27 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { + mergeProviderInstanceEnvironment, + refreshProviderInstanceEnvironment, +} from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it("refreshes inherited PATH for existing runtimes while retaining an instance override", () => { + const inherited = { PATH: "/old/bin" }; + const overridden = { PATH: "/custom/bin" }; + vi.stubEnv("PATH", "/new/bin"); + try { + refreshProviderInstanceEnvironment(undefined, inherited); + refreshProviderInstanceEnvironment( + [{ name: "PATH", value: "/custom/bin", sensitive: false }], + overridden, + ); + expect(inherited.PATH).toBe("/new/bin"); + expect(overridden.PATH).toBe("/custom/bin"); + } finally { + vi.unstubAllEnvs(); + } + }); it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e81f0c11a..8e3a99409 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,13 @@ import type { ProviderInstanceEnvironment } from "@threadlines/contracts"; +/** Refresh inherited PATH in the environment already held by a driver's runtimes. */ +export function refreshProviderInstanceEnvironment( + environment: ProviderInstanceEnvironment | undefined, + target: NodeJS.ProcessEnv, +): void { + Object.assign(target, mergeProviderInstanceEnvironment(environment)); +} + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 892881cd8..e9e691bf2 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const fastModeCapabilities = createModelCapabilities({ @@ -116,6 +117,31 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("exposes the installation manager discovered during refresh", () => + Effect.scoped( + Effect.gen(function* () { + let capabilities: ProviderMaintenanceCapabilities = maintenanceCapabilities; + const provider = yield* makeManagedServerProvider({ + get maintenanceCapabilities() { + return capabilities; + }, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.sync(() => { + capabilities = { + ...maintenanceCapabilities, + update: { ...maintenanceCapabilities.update, command: "native update" }, + }; + return refreshedSnapshot; + }), + }); + yield* provider.refresh; + assert.strictEqual(provider.maintenanceCapabilities.update?.command, "native update"); + }), + ), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index cbbc540e2..46421c641 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -218,7 +218,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); return { - maintenanceCapabilities: input.maintenanceCapabilities, + get maintenanceCapabilities() { + return input.maintenanceCapabilities; + }, // Reads the cached snapshot without probing or queueing behind the // refresh semaphore — startup paths must never wait on a slow probe. getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index f517c5955..ced1cfe52 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -13,6 +13,7 @@ import { makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, + makeWindowsNativeInstaller, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -33,6 +34,21 @@ const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) { }); const WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"; +const nativeWindowsInstall = makeWindowsNativeInstaller({ + url: "https://example.test/install.ps1", + lockKey: "package-tool-native", + environmentPatch: { PACKAGE_TOOL_NON_INTERACTIVE: "1" }, +}); +const nativeWindowsTool = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + homebrewFormula: null, + nativeInstall: { win32: nativeWindowsInstall }, + nativeUpdate: { + ...nativeWindowsInstall, + isCommandPath: (value) => value.endsWith("package-tool.exe"), + }, +}); /** * Put an executable named `name` in `dir` for the platform the test is @@ -137,6 +153,56 @@ afterEach(() => { }); describe("providerMaintenance", () => { + it("installs a missing Windows provider without npm and keeps its native updater", () => { + const install = nativeWindowsTool.resolve({ + binaryPath: "missing-package-tool", + platform: "win32", + env: { PATH: "" }, + }).install; + expect(install).toMatchObject({ + executable: "powershell.exe", + lockKey: "package-tool-native", + environmentPatch: { PACKAGE_TOOL_NON_INTERACTIVE: "1" }, + }); + const encoded = install?.args.at(-1); + expect(Buffer.from(encoded ?? "", "base64").toString("utf16le")).toContain( + "irm 'https://example.test/install.ps1' | iex", + ); + expect( + nativeWindowsTool.resolve({ + binaryPath: "C:\\Users\\alice\\.local\\bin\\package-tool.exe", + platform: "win32", + env: { PATH: "" }, + }).update, + ).toEqual(install); + }); + + it.effect("preserves an explicit npm install prefix even when a native installer exists", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-native-install-prefix"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(path.join(tempDir, "npm.cmd"), "@echo off\r\n"); + const install = nativeWindowsTool.resolve({ + binaryPath: "missing-package-tool", + platform: "win32", + env: { PATH: tempDir, PATHEXT: WINDOWS_PATHEXT, NPM_CONFIG_PREFIX: tempDir }, + }).install; + expect(install).toMatchObject({ + executable: "npm", + environmentPatch: { NPM_CONFIG_PREFIX: tempDir }, + }); + }), + ); + + it("does not offer a default install for an explicit custom binary path", () => { + expect( + nativeWindowsTool.resolve({ + binaryPath: "C:\\custom\\missing.exe", + platform: "win32", + env: { PATH: "" }, + }).install, + ).toBeNull(); + }); it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index ad44774c6..20e288e84 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -24,9 +24,8 @@ export interface ProviderMaintenanceCapabilities { readonly update: ProviderMaintenanceCommandAction | null; /** * How Threadlines would put this provider's CLI on the machine when it is - * missing. There is no installed binary to inspect in that state, so this - * is only ever the default manager (npm global) and only when `npm` itself - * resolves. `null` means the UI falls back to the provider's install guide. + * missing. Uses the platform installer when available, otherwise npm. + * `null` means the UI falls back to the provider's install guide. */ readonly install: ProviderMaintenanceCommandAction | null; readonly manualUpdateCommand: string | null; @@ -47,6 +46,7 @@ export interface ProviderMaintenanceCommandDefinition { readonly lockKey: string; readonly displayCommand?: string | null | undefined; readonly advisoryMessage?: string | null | undefined; + readonly environmentPatch?: Readonly>; } export interface ProviderMaintenanceCapabilityResolutionOptions { @@ -66,6 +66,7 @@ export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; readonly npmPackageName: string; readonly homebrewFormula: string | null; + readonly nativeInstall?: Partial>; readonly nativeUpdate: | (ProviderMaintenanceCommandDefinition & { readonly isCommandPath: (commandPath: string) => boolean; @@ -170,19 +171,51 @@ function makeNpmGlobalCommandAction(input: { }; } -/** - * The install command for a provider whose CLI could not be located. There is - * no binary whose origin we could inspect, so the manager is the default one - * (npm global) and the only question is whether `npm` is on the server's PATH. - * A configured `NPM_CONFIG_PREFIX` is carried into the command's environment - * patch so the install lands in the same prefix the rest of the process uses. - */ -function resolveNpmGlobalInstallAction( +/** Runs a provider's official Windows installer without interpolating shell arguments. */ +export function makeWindowsNativeInstaller(input: { + readonly url: string; + readonly lockKey: string; + readonly environmentPatch?: Readonly>; +}): ProviderMaintenanceCommandDefinition { + const command = `irm '${input.url.replaceAll("'", "''")}' | iex`; + const script = `$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; ${command}`; + return { + executable: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + lockKey: input.lockKey, + displayCommand: `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${command}"`, + ...(input.environmentPatch ? { environmentPatch: input.environmentPatch } : {}), + }; +} + +/** Prefer a native platform installer, preserving an explicitly configured npm prefix. */ +function resolveDefaultInstallAction( definition: PackageManagedProviderMaintenanceDefinition, options?: ProviderMaintenanceCapabilityResolutionOptions, ): ProviderMaintenanceCommandAction | null { const env = options?.env ?? process.env; const platform = options?.platform ?? process.platform; + const nativeInstall = definition.nativeInstall?.[platform]; + // An explicit npm prefix is a user's installation choice. + if (nativeInstall && !nonEmptyString(env.NPM_CONFIG_PREFIX)) { + return { + executable: nativeInstall.executable, + args: nativeInstall.args, + lockKey: nativeInstall.lockKey, + command: + nativeInstall.displayCommand ?? [nativeInstall.executable, ...nativeInstall.args].join(" "), + ...(nativeInstall.environmentPatch + ? { environmentPatch: nativeInstall.environmentPatch } + : {}), + }; + } if (!resolveCommandPath("npm", { platform, env })) { return null; } @@ -287,6 +320,7 @@ function makeNativeProviderMaintenanceCapabilities( updateArgs: update.args, updateLockKey: update.lockKey, updateDisplayCommand: update.displayCommand, + ...(update.environmentPatch ? { updateEnvironmentPatch: update.environmentPatch } : {}), advisoryMessage: update.advisoryMessage ?? definition.nativeUpdate.advisoryMessage, }); } @@ -403,7 +437,7 @@ export function resolvePackageManagedProviderMaintenance( const platform = options?.platform ?? process.platform; if (!binaryPath) { return makeNpmGlobalProviderMaintenanceCapabilities(definition, { - install: resolveNpmGlobalInstallAction(definition, options), + install: resolveDefaultInstallAction(definition, options), }); } @@ -473,7 +507,7 @@ export function resolvePackageManagedProviderMaintenance( if (!hasPathSeparator(binaryPath)) { return makeNpmGlobalProviderMaintenanceCapabilities(definition, { install: - resolvedCommandPath === null ? resolveNpmGlobalInstallAction(definition, options) : null, + resolvedCommandPath === null ? resolveDefaultInstallAction(definition, options) : null, }); } diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts index 7c456c3c4..0033b79ac 100644 --- a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -59,20 +59,24 @@ export const makeProviderMaintenanceCommandCoordinator = Effect.fn( onQueued, run, }) => - Effect.gen(function* () { - const acquired = yield* acquireTarget(targetKey); - if (!acquired) { - return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); - } - - return yield* Effect.gen(function* () { - const lock = yield* getLock(lockKey); - if (onQueued) { - yield* onQueued; + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const acquired = yield* acquireTarget(targetKey); + if (!acquired) { + return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); } - return yield* lock.withPermits(1)(run); - }).pipe(Effect.ensuring(releaseTarget(targetKey))); - }); + + return yield* restore( + Effect.gen(function* () { + const lock = yield* getLock(lockKey); + if (onQueued) { + yield* onQueued; + } + return yield* lock.withPermits(1)(run); + }), + ).pipe(Effect.ensuring(releaseTarget(targetKey))); + }), + ); return { withCommandLock, diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 23010b772..b1c426986 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, assert } from "@effect/vitest"; +import { afterEach, beforeEach, describe, it, assert } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, @@ -289,16 +289,18 @@ function claudeWindowsUpdateCapabilities(): ProviderMaintenanceCapabilities { } const makeTestRunner = (registry: ProviderRegistryShape) => - Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( - Effect.provide( - ProviderMaintenanceRunner.layer.pipe( - Layer.provide(Layer.succeed(ProviderRegistry, registry)), - ), - ), - ); + ProviderMaintenanceRunner.make().pipe(Effect.provideService(ProviderRegistry, registry)); describe("providerMaintenanceRunner", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + beforeEach(() => { + // Generic command assertions use POSIX argv. Windows cases below opt in explicitly. + Object.defineProperty(process, "platform", { value: "linux" }); + }); afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } clearLatestProviderVersionCacheForTests(); }); @@ -945,7 +947,7 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("prevents concurrent updates for the same provider", () => { + it.effect("keeps an update running and locked after its requesting client disconnects", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; const started = new Promise((resolve) => { @@ -960,6 +962,7 @@ describe("providerMaintenanceRunner", () => { const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); yield* Effect.promise(() => started); + yield* Fiber.interrupt(first); const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); assert.strictEqual(Exit.isFailure(second), true); @@ -972,7 +975,9 @@ describe("providerMaintenanceRunner", () => { } releaseLatch.resolve(); - yield* Fiber.join(first); + while ((yield* registry.getProviders)[0]?.updateState?.status !== "succeeded") { + yield* Effect.yieldNow; + } }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 0aba47b05..506d67b85 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -10,12 +10,14 @@ import { type ServerProviderUpdateState, } from "@threadlines/contracts"; import { hideWindowsConsole } from "@threadlines/shared/childProcess"; +import { refreshWindowsPath } from "@threadlines/shared/shell"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -479,6 +481,7 @@ function makeUpdateState(input: { } export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { + const scope = yield* Effect.scope; const providerRegistry = yield* ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; @@ -723,6 +726,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } + yield* Effect.sync(() => refreshWindowsPath()); const { verifiedProviders } = yield* verifyRefreshedProvider( provider, capabilities, @@ -876,7 +880,14 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }); return ProviderMaintenanceRunner.of({ - updateProvider, + updateProvider: (target) => + Effect.uninterruptibleMask((restore) => + updateProvider(target).pipe( + Effect.interruptible, + Effect.forkIn(scope), + Effect.flatMap((fiber) => restore(Fiber.join(fiber))), + ), + ), resolveUpdateBlockers, }); }); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index ff5831c9f..7422be5cd 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -9,6 +9,9 @@ import { createModelCapabilities } from "@threadlines/shared/model"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as TestClock from "effect/testing/TestClock"; import { hydrateCachedProvider, @@ -42,6 +45,98 @@ const makeProvider = ( }); it.layer(NodeServices.layer)("providerStatusCache", (it) => { + it.effect( + "replaces the cache after a temporary Windows file lock without removing the old file", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-lock-" }); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: tempDir, + instanceId: defaultInstanceIdForDriver(CODEX_DRIVER), + }); + const original = makeProvider(CODEX_DRIVER); + const refreshed = { ...original, checkedAt: "2026-04-11T00:01:00.000Z" }; + yield* writeProviderStatusCache({ filePath, provider: original }); + let attempts = 0; + const lockedFileSystem = { + ...fs, + rename: (source, target) => + Effect.gen(function* () { + attempts += 1; + if (attempts <= 2) { + assert.deepStrictEqual( + yield* readProviderStatusCache(filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + original, + ); + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "rename", + cause: { code: "EPERM", syscall: "rename" }, + }); + } + return yield* fs.rename(source, target); + }), + } satisfies FileSystem.FileSystem; + + yield* writeProviderStatusCache({ filePath, provider: refreshed }).pipe( + Effect.provideService(FileSystem.FileSystem, lockedFileSystem), + TestClock.withLive, + ); + + assert.deepStrictEqual(yield* readProviderStatusCache(filePath), refreshed); + assert.deepStrictEqual(yield* fs.readDirectory(tempDir), ["codex.json"]); + }), + ); + + for (const code of ["EPERM", "ENOSPC"] as const) { + it.effect(`preserves the cache and reports a persistent ${code} replacement failure`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-error-" }); + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: tempDir, + instanceId: defaultInstanceIdForDriver(CODEX_DRIVER), + }); + const original = makeProvider(CODEX_DRIVER); + yield* writeProviderStatusCache({ filePath, provider: original }); + const failure = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "rename", + cause: { code, syscall: "rename" }, + }); + let attempts = 0; + const failingFileSystem = { + ...fs, + rename: () => + Effect.suspend(() => { + attempts += 1; + return Effect.fail(failure); + }), + } satisfies FileSystem.FileSystem; + + const result = yield* writeProviderStatusCache({ + filePath, + provider: { ...original, checkedAt: "2026-04-11T00:01:00.000Z" }, + }).pipe( + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + TestClock.withLive, + Effect.result, + ); + + assert.ok(Result.isFailure(result)); + assert.strictEqual(result.failure, failure); + assert.strictEqual(attempts, code === "EPERM" ? 11 : 1); + assert.deepStrictEqual(yield* readProviderStatusCache(filePath), original); + assert.deepStrictEqual(yield* fs.readDirectory(tempDir), ["codex.json"]); + }), + ); + } + it.effect("writes and reads provider status snapshots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 86459f6b4..8fbc8d0af 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -64,6 +64,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; +import * as SourceControlToolMaintenance from "./sourceControl/SourceControlToolMaintenance.ts"; +import * as GitHubAuth from "./sourceControl/GitHubAuth.ts"; +import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import { AutomaticGitFetchSupervisorLive } from "./vcs/AutomaticGitFetchSupervisor.ts"; @@ -406,7 +409,13 @@ export const makeRoutesLayer = Layer.mergeAll( serverEnvironmentRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, -).pipe(Layer.provide(browserApiCorsLayer)); +).pipe( + Layer.provide(browserApiCorsLayer), + // Build setup services once for the HTTP server, not once per WebSocket. + Layer.provide(SourceControlToolMaintenance.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide(GitHubAuth.layer), + Layer.provide(ProviderMaintenanceRunner.layer), +); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubAuth.test.ts b/apps/server/src/sourceControl/GitHubAuth.test.ts new file mode 100644 index 000000000..6a3325c5e --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAuth.test.ts @@ -0,0 +1,298 @@ +import { assert, it } from "@effect/vitest"; +import type { GitHubAuthState } from "@threadlines/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { make, type GitHubAuthShape } from "./GitHubAuth.ts"; + +const encoder = new TextEncoder(); + +function handle( + input: { + readonly chunks?: ReadonlyArray; + readonly exitCode?: Effect.Effect; + readonly kill?: () => Effect.Effect; + } = {}, +) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(true), + kill: input.kill ?? (() => Effect.void), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.fromIterable((input.chunks ?? []).map((chunk) => encoder.encode(chunk))), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const waitForState = (auth: GitHubAuthShape, predicate: (state: GitHubAuthState) => boolean) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 100; attempt += 1) { + const state = yield* auth.getState; + if (predicate(state)) return state; + yield* Effect.yieldNow; + } + assert.fail("GitHub sign-in did not reach the expected state."); + }); + +it.effect("keeps the device code available until login and credential verification finish", () => + Effect.gen(function* () { + const loginExit = yield* Deferred.make(); + const calls: ReadonlyArray[] = []; + const spawner = ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + const standard = command as ChildProcess.StandardCommand; + calls.push(standard.args); + return Effect.succeed( + calls.length === 1 + ? handle({ + chunks: [ + "! First copy your one-time co", + "de: ABCD-1234\nOpen this URL in your web browser: https://github.com/login/device\n", + "private auth transcript should not appear in state", + ], + exitCode: Deferred.await(loginExit), + }) + : handle(), + ); + }); + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.strictEqual((yield* auth.start).status, "running"); + const prompt = yield* waitForState(auth, (state) => state.userCode !== null); + assert.strictEqual(prompt?.userCode, "ABCD-1234"); + assert.strictEqual(prompt?.verificationUrl, "https://github.com/login/device"); + assert.strictEqual((yield* auth.start).userCode, "ABCD-1234"); + assert.strictEqual(calls.length, 1); + + yield* Deferred.succeed(loginExit, ChildProcessSpawner.ExitCode(0)); + const completed = yield* waitForState(auth, (state) => state.status === "succeeded"); + assert.deepStrictEqual(completed, { + status: "succeeded", + userCode: null, + verificationUrl: null, + message: "Signed in to GitHub.", + }); + assert.deepStrictEqual(calls, [ + ["auth", "login", "--hostname", "github.com", "--web"], + ["api", "--hostname", "github.com", "user", "--silent"], + ["auth", "setup-git", "--hostname", "github.com"], + ]); + }).pipe(Effect.scoped), +); + +it.effect("cancels only its login process and can start a fresh sign-in", () => + Effect.gen(function* () { + let killed = 0; + let spawned = 0; + const spawner = ChildProcessSpawner.make(() => { + spawned += 1; + return Effect.succeed( + handle({ + chunks: ["First copy your one-time code: ABCD-1234"], + exitCode: Effect.never, + kill: () => + Effect.sync(() => { + killed += 1; + }), + }), + ); + }); + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* auth.start; + yield* waitForState(auth, (state) => state.userCode !== null); + yield* auth.cancel; + assert.strictEqual(killed, 1); + assert.deepStrictEqual(yield* auth.getState, { + status: "cancelled", + userCode: null, + verificationUrl: null, + message: "GitHub sign-in cancelled.", + }); + yield* auth.start; + yield* waitForState(auth, (state) => state.userCode !== null); + assert.strictEqual(spawned, 2); + yield* auth.cancel; + }).pipe(Effect.scoped), +); + +it.effect("configures Git access when Git is installed after GitHub sign-in", () => + Effect.gen(function* () { + let gitInstalled = false; + let gitConfigured = false; + const spawner = ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + const standard = command as ChildProcess.StandardCommand; + if (standard.args.includes("setup-git")) { + assert.strictEqual(gitInstalled, true); + gitConfigured = true; + } + return Effect.succeed(handle()); + }); + const options = { + commandAvailable: (command: string) => command === "gh" || gitInstalled, + environment: () => ({}), + }; + const createAuth = make(options).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const auth = yield* createAuth; + yield* auth.start; + yield* waitForState(auth, (state) => state.status === "succeeded"); + assert.strictEqual(gitConfigured, false); + + // A restart clears the in-memory sign-in state, but the CLI keeps its credential. + const restarted = yield* createAuth; + gitInstalled = true; + yield* restarted.configureGit; + assert.strictEqual(gitConfigured, true); + assert.strictEqual((yield* restarted.getState).status, "idle"); + }).pipe(Effect.scoped), +); + +it.effect("leaves Git credentials alone when GitHub has no working sign-in", () => + Effect.gen(function* () { + let gitConfigured = false; + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + const standard = command as ChildProcess.StandardCommand; + gitConfigured ||= standard.args.includes("setup-git"); + return Effect.succeed( + handle({ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)) }), + ); + }), + ), + ); + yield* auth.configureGit; + assert.strictEqual(gitConfigured, false); + assert.strictEqual((yield* auth.getState).status, "idle"); + }).pipe(Effect.scoped), +); + +it.effect("reports a Git configuration failure without exposing the CLI output", () => + Effect.gen(function* () { + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + const standard = command as ChildProcess.StandardCommand; + return Effect.succeed( + handle({ + chunks: ["private-token-value"], + exitCode: Effect.succeed( + ChildProcessSpawner.ExitCode(standard.args.includes("setup-git") ? 1 : 0), + ), + }), + ); + }), + ), + ); + const result = yield* Effect.result(auth.configureGit); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.operation, "configureGit"); + assert.match(result.failure.detail, /Git is installed/); + assert.match(result.failure.detail, /gh auth setup-git --hostname github.com/); + assert.notMatch(result.failure.detail, /private-token-value/); + } + }).pipe(Effect.scoped), +); + +it.effect("rejects credential overrides before starting and never exposes their values", () => + Effect.gen(function* () { + let spawned = false; + const auth = yield* make({ + commandAvailable: () => true, + environment: () => ({ GH_TOKEN: "private-token-value" }), + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawned = true; + return Effect.succeed(handle()); + }), + ), + ); + const result = yield* Effect.result(auth.start); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.match(result.failure.detail, /GH_TOKEN/); + assert.notMatch(result.failure.detail, /private-token-value/); + } + assert.strictEqual(spawned, false); + assert.strictEqual((yield* auth.getState).status, "idle"); + }).pipe(Effect.scoped), +); + +it.effect("clears the device code after timeout and stops the login process", () => + Effect.gen(function* () { + let killed = 0; + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + handle({ + chunks: ["First copy your one-time code: ABCD-1234"], + exitCode: Effect.never, + kill: () => + Effect.sync(() => { + killed += 1; + }), + }), + ), + ), + ), + ); + yield* auth.start; + yield* waitForState(auth, (state) => state.userCode !== null); + yield* TestClock.adjust("15 minutes"); + const failed = yield* waitForState(auth, (state) => state.status === "failed"); + assert.strictEqual(failed?.userCode, null); + assert.match(failed?.message ?? "", /timed out/); + assert.strictEqual(killed, 1); + }).pipe(Effect.scoped), +); + +it.effect( + "does not report success or configure Git when the saved credential fails verification", + () => + Effect.gen(function* () { + let spawned = 0; + const auth = yield* make({ commandAvailable: () => true, environment: () => ({}) }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawned += 1; + return Effect.succeed( + handle({ + chunks: ["private-token-value"], + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(spawned === 1 ? 0 : 1)), + }), + ); + }), + ), + ); + yield* auth.start; + const failed = yield* waitForState(auth, (state) => state.status === "failed"); + assert.strictEqual(spawned, 2); + assert.strictEqual(failed?.userCode, null); + assert.match(failed?.message ?? "", /could not verify/); + assert.notMatch(failed?.message ?? "", /private-token-value/); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/sourceControl/GitHubAuth.ts b/apps/server/src/sourceControl/GitHubAuth.ts new file mode 100644 index 000000000..a39014ab9 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAuth.ts @@ -0,0 +1,218 @@ +import { SourceControlProviderError, type GitHubAuthState } from "@threadlines/contracts"; +import { hideWindowsConsole } from "@threadlines/shared/childProcess"; +import { isCommandAvailable } from "@threadlines/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { planCliSpawn } from "../cliSpawn.ts"; +import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts"; + +const DEVICE_URL = "https://github.com/login/device"; +const AUTH_OUTPUT_LIMIT = 8_192; + +export interface GitHubAuthShape { + readonly getState: Effect.Effect; + readonly start: Effect.Effect; + readonly cancel: Effect.Effect; + readonly configureGit: Effect.Effect; +} + +export class GitHubAuth extends Context.Service()( + "threadlines/source-control/GitHubAuth", +) {} + +interface GitHubAuthOptions { + readonly commandAvailable?: (command: string) => boolean; + readonly environment?: () => NodeJS.ProcessEnv; +} + +const authError = (detail: string) => + new SourceControlProviderError({ provider: "github", operation: "signIn", detail }); + +const authState = (status: GitHubAuthState["status"], message: string | null): GitHubAuthState => ({ + status, + verificationUrl: null, + userCode: null, + message, +}); + +/** The server owns the sign-in process, so reconnecting clients can resume its device prompt. */ +export const make = Effect.fn("makeGitHubAuth")(function* (options: GitHubAuthOptions = {}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Effect.scope; + const state = yield* Ref.make(authState("idle", null)); + const lock = yield* Semaphore.make(1); + const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const environment = options.environment ?? (() => process.env); + let activeFiber: Fiber.Fiber | null = null; + + const runCommand = Effect.fn("GitHubAuth.runCommand")(function* ( + args: ReadonlyArray, + onStderr?: (chunk: string) => Effect.Effect, + ) { + const env = { + ...environment(), + ...THREADLINES_GITHUB_CLI_ENV, + GH_PROMPT_DISABLED: "1", + NO_COLOR: "1", + }; + const plan = planCliSpawn("gh", args, env); + const child = yield* spawner.spawn( + ChildProcess.make( + plan.command, + [...plan.args], + hideWindowsConsole({ + ...plan.options, + env, + extendEnv: false, + stdin: "ignore", + forceKillAfter: "5 seconds", + }), + ), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const [, , exitCode] = yield* Effect.all( + [ + Stream.runDrain(child.stdout), + child.stderr.pipe(Stream.decodeText(), Stream.runForEach(onStderr ?? (() => Effect.void))), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + return Number(exitCode); + }); + + const setupGit = runCommand(["auth", "setup-git", "--hostname", "github.com"]).pipe( + Effect.scoped, + Effect.timeout("30 seconds"), + Effect.flatMap((exitCode) => + exitCode === 0 ? Effect.void : Effect.fail(new Error("GitHub Git credential setup failed.")), + ), + Effect.mapError( + () => + new SourceControlProviderError({ + provider: "github", + operation: "configureGit", + detail: + "Git is installed, but GitHub could not configure Git access. Run `gh auth setup-git --hostname github.com` to use your GitHub account for Git.", + }), + ), + ); + + const verifyCredential = runCommand(["api", "--hostname", "github.com", "user", "--silent"]).pipe( + Effect.scoped, + Effect.timeout("30 seconds"), + ); + + const run = Effect.gen(function* () { + let output = ""; + const exitCode = yield* runCommand( + ["auth", "login", "--hostname", "github.com", "--web"], + (chunk) => + Effect.gen(function* () { + output = `${output}${chunk}`.slice(-AUTH_OUTPUT_LIMIT); + const userCode = /one-time code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})\b/i.exec(output)?.[1]; + if (userCode) { + yield* Ref.update(state, (current) => + current.status === "running" + ? { + ...current, + userCode: userCode.toUpperCase(), + verificationUrl: DEVICE_URL, + message: "Enter this code on GitHub to finish signing in.", + } + : current, + ); + } + }), + ).pipe(Effect.scoped); + if (exitCode !== 0) { + return yield* authError( + "GitHub sign-in did not finish. Try again and complete the browser step.", + ); + } + + // Check the active credential directly, without printing account data or tokens. + const verified = yield* verifyCredential; + if (verified !== 0) { + return yield* authError( + "GitHub could not verify the sign-in. Check your connection and try again.", + ); + } + let message = "Signed in to GitHub."; + if (commandAvailable("git")) { + const configured = yield* setupGit.pipe(Effect.result); + if (configured._tag === "Failure") { + message = + "Signed in to GitHub. Run `gh auth setup-git` to enable Git access with this account."; + } + } + yield* Ref.update(state, (current) => + current.status === "running" ? authState("succeeded", message) : current, + ); + }).pipe( + Effect.timeoutOption("15 minutes"), + Effect.flatMap((result) => + Option.isNone(result) + ? Effect.fail(authError("GitHub sign-in timed out. Start again to get a new code.")) + : Effect.void, + ), + Effect.catch((error) => + Ref.update(state, (current) => + current.status === "running" + ? authState( + "failed", + error instanceof SourceControlProviderError + ? error.detail + : "GitHub sign-in could not run. Check that GitHub CLI is installed and try again.", + ) + : current, + ), + ), + ); + + return GitHubAuth.of({ + getState: Ref.get(state), + // Git may be installed after GitHub sign-in, including across server restarts. + configureGit: Effect.gen(function* () { + if (!commandAvailable("git") || !commandAvailable("gh")) return; + const verified = yield* verifyCredential.pipe(Effect.catch(() => Effect.succeed(-1))); + if (verified === 0) yield* setupGit; + }), + start: Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.status === "running") return current; + const env = environment(); + if (env.GH_TOKEN || env.GITHUB_TOKEN) { + return yield* authError( + "GitHub credentials are set through GH_TOKEN or GITHUB_TOKEN on this server. Update those credentials, or remove the override and restart Threadlines before signing in here.", + ); + } + if (!commandAvailable("gh")) { + return yield* authError("Install GitHub CLI before signing in."); + } + const next = authState("running", "Starting GitHub sign-in..."); + yield* Ref.set(state, next); + activeFiber = yield* run.pipe(Effect.interruptible, Effect.forkIn(scope)); + return next; + }).pipe(lock.withPermits(1), Effect.uninterruptible), + cancel: Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.status !== "running") return; + yield* Ref.set(state, authState("cancelled", "GitHub sign-in cancelled.")); + if (activeFiber) { + yield* Fiber.interrupt(activeFiber); + activeFiber = null; + } + }).pipe(lock.withPermits(1), Effect.uninterruptible), + }); +}); + +export const layer = Layer.effect(GitHubAuth, make()); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 26c2155dd..227fdf5a4 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -220,49 +220,88 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); - it.effect("lists authenticated user repositories", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - nameWithOwner: "octocat/example-app", - url: "https://github.com/octocat/example-app", - sshUrl: "git@github.com:octocat/example-app.git", - }, - ]), + it.effect( + "lists personal, organization, and collaborator repositories across archived entries", + () => + Effect.gen(function* () { + const repository = (nameWithOwner: string, archived = false) => ({ + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, + archived, + }); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + repository("octocat/example-app"), + repository("octocat/archived", true), + repository("example-org/team-app"), + ]), + ), ), - ), - ); + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + repository("collaborator/shared-app"), + repository("octocat/beyond-limit"), + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listRepositories({ cwd: "/repo", limit: 3 }); + + assert.deepStrictEqual(result, [ + { + nameWithOwner: "octocat/example-app", + url: "https://github.com/octocat/example-app", + sshUrl: "git@github.com:octocat/example-app.git", + }, + { + nameWithOwner: "example-org/team-app", + url: "https://github.com/example-org/team-app", + sshUrl: "git@github.com:example-org/team-app.git", + }, + { + nameWithOwner: "collaborator/shared-app", + url: "https://github.com/collaborator/shared-app", + sshUrl: "git@github.com:collaborator/shared-app.git", + }, + ]); + assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "api", + "user/repos?affiliation=owner,collaborator,organization_member&sort=updated&direction=desc&per_page=3&page=1", + "--jq", + "map({nameWithOwner: .full_name, url: .html_url, sshUrl: .ssh_url, archived: .archived})", + ], + cwd: "/repo", + env: GITHUB_CLI_BACKGROUND_ENV, + timeoutMs: 30_000, + }); + assert.match(mockRun.mock.calls[1]?.[0].args[1] ?? "", /per_page=3&page=2$/); + assert.strictEqual(mockRun.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + + it.effect("stops listing when the accessible repositories run out", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.listRepositories({ cwd: "/repo", limit: 25 }); + const result = yield* gh.listRepositories({ cwd: "/repo", limit: 150 }); - assert.deepStrictEqual(result, [ - { - nameWithOwner: "octocat/example-app", - url: "https://github.com/octocat/example-app", - sshUrl: "git@github.com:octocat/example-app.git", - }, - ]); - assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], { - operation: "GitHubCli.execute", - command: "gh", - args: [ - "repo", - "list", - "--no-archived", - "--limit", - "25", - "--json", - "nameWithOwner,url,sshUrl", - ], - cwd: "/repo", - env: GITHUB_CLI_BACKGROUND_ENV, - timeoutMs: 30_000, - }); + assert.deepStrictEqual(result, []); + assert.strictEqual(mockRun.mock.calls.length, 1); + assert.match(mockRun.mock.calls[0]?.[0].args[1] ?? "", /per_page=100&page=1$/); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 1f3e49899..f57bdaa22 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -183,6 +183,13 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); +const RawGitHubRepositoryListSchema = Schema.Array( + Schema.Struct({ + ...RawGitHubRepositoryCloneUrlsSchema.fields, + archived: Schema.Boolean, + }), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -371,29 +378,42 @@ export const make = Effect.fn("makeGitHubCli")(function* () { Effect.map(normalizeRepositoryCloneUrls), ), listRepositories: (input) => - execute({ - cwd: input.cwd, - args: [ - "repo", - "list", - "--no-archived", - "--limit", - String(input.limit ?? 50), - "--json", - "nameWithOwner,url,sshUrl", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitHubJson( + Effect.gen(function* () { + const limit = input.limit ?? 50; + const pageSize = Math.min(limit, 100); + const repositories: GitHubRepositoryCloneUrls[] = []; + + // The authenticated-user endpoint includes organization and collaborator + // access. `gh repo list` only lists repositories the account owns. + for (let page = 1; repositories.length < limit; page += 1) { + const result = yield* execute({ + cwd: input.cwd, + args: [ + "api", + `user/repos?affiliation=owner,collaborator,organization_member&sort=updated&direction=desc&per_page=${pageSize}&page=${page}`, + "--jq", + "map({nameWithOwner: .full_name, url: .html_url, sshUrl: .ssh_url, archived: .archived})", + ], + }); + const raw = result.stdout.trim(); + const entries = yield* decodeGitHubJson( raw.length === 0 ? "[]" : raw, - Schema.Array(RawGitHubRepositoryCloneUrlsSchema), + RawGitHubRepositoryListSchema, "listRepositories", "GitHub CLI returned invalid repository list JSON.", - ), - ), - Effect.map((repositories) => repositories.map(normalizeRepositoryCloneUrls)), - ), + ); + for (const entry of entries) { + if (!entry.archived && repositories.length < limit) { + repositories.push(normalizeRepositoryCloneUrls(entry)); + } + } + if (entries.length < pageSize) { + break; + } + } + + return repositories; + }), createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 4c7d2add0..3ec256676 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -63,6 +63,96 @@ const processOutput = ( stderrTruncated: false, }); +it.effect( + "discovers tools installed or removed after startup and refreshes install actions", + () => { + const commands = new Set(); + const commandAvailable = (command: string) => commands.has(command); + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => + Effect.succeed( + processOutput( + input.command === "git" + ? "git version 2.55.0.windows.4" + : input.args[0] === "--version" + ? "gh version 2.98.0" + : '{"hosts":{}}', + ), + ), + } satisfies Partial; + const testLayer = Layer.effect( + SourceControlDiscovery.SourceControlDiscovery, + SourceControlDiscovery.make({ + commandAvailable, + platform: "win32", + latestVersionResolver: noLatestToolVersion, + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-rescan-" })), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + commandAvailable, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const initial = yield* discovery.discover; + assert.equal( + initial.versionControlSystems.find((item) => item.kind === "git")?.status, + "missing", + ); + commands.add("winget"); + const installable = yield* discovery.discover; + assert.ok( + installable.versionControlSystems + .find((item) => item.kind === "git") + ?.versionAdvisory?.actions.some( + (action) => action.kind === "runUpdate" && action.operation === "install", + ), + ); + commands.add("git"); + commands.add("gh"); + const installed = yield* discovery.discover; + assert.equal( + installed.versionControlSystems.find((item) => item.kind === "git")?.status, + "available", + ); + assert.equal( + installed.sourceControlProviders.find((item) => item.kind === "github")?.status, + "available", + ); + commands.clear(); + const removed = yield* discovery.discover; + assert.equal( + removed.versionControlSystems.find((item) => item.kind === "git")?.status, + "missing", + ); + assert.equal( + removed.sourceControlProviders.find((item) => item.kind === "github")?.status, + "missing", + ); + assert.equal( + removed.versionControlSystems + .find((item) => item.kind === "git") + ?.versionAdvisory?.actions.some((action) => action.kind === "runUpdate") ?? false, + false, + ); + }).pipe(Effect.provide(testLayer)); + }, +); + it.effect("reports implemented tools separately from locally available executables", () => { const processCommands: Array = []; const processMock = { diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index d9fbe6398..ca18687d9 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -117,10 +117,8 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( options?.commandAvailable ?? ((command) => isCommandAvailable(command, { platform })); const latestVersionResolver = options?.latestVersionResolver ?? (() => Effect.succeed(null)); - const sourceControlToolPackageManager = selectSourceControlToolPackageManager({ - platform, - commandAvailable, - }); + const packageManager = () => + selectSourceControlToolPackageManager({ platform, commandAvailable }); const homebrewManagedExecutable = options?.homebrewManagedExecutable ?? ((executable: string): boolean => { @@ -147,8 +145,9 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, ): boolean => { if (platform === "win32" && item.status === "available" && item.kind === "git") return true; - if (sourceControlToolPackageManager === "winget") return true; - if (sourceControlToolPackageManager !== "homebrew") return false; + const manager = packageManager(); + if (manager === "winget") return true; + if (manager !== "homebrew") return false; return item.executable !== undefined && homebrewManagedExecutable(item.executable); }; @@ -211,8 +210,9 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( const withVersionAdvisory = ( item: Item, - ): Effect.Effect => - SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ + ): Effect.Effect => { + const sourceControlToolPackageManager = packageManager(); + return SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({ item, platform, latestVersionResolver, @@ -227,27 +227,30 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* ( item.executable !== undefined && !commandAvailable(item.executable), }); + }; return SourceControlDiscovery.of({ - discover: Effect.all({ - versionControlSystems: Effect.all( - VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, - { concurrency: "unbounded" }, - ).pipe( - Effect.flatMap((items) => - Effect.forEach(items, withVersionAdvisory, { - concurrency: "unbounded", - }), + discover: Effect.suspend(() => + Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ).pipe( + Effect.flatMap((items) => + Effect.forEach(items, withVersionAdvisory, { + concurrency: "unbounded", + }), + ), ), - ), - sourceControlProviders: sourceControlProviders.discover.pipe( - Effect.flatMap((items) => - Effect.forEach(items, withVersionAdvisory, { - concurrency: "unbounded", - }), + sourceControlProviders: sourceControlProviders.discover.pipe( + Effect.flatMap((items) => + Effect.forEach(items, withVersionAdvisory, { + concurrency: "unbounded", + }), + ), ), - ), - }), + }), + ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index c202e0d22..0db44952f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -162,56 +162,58 @@ function probeCli(input: { readonly cwd: string; readonly commandAvailable: CommandAvailability; }): Effect.Effect { - if (!input.commandAvailable(input.spec.executable)) { - return Effect.succeed({ - kind: input.spec.kind, - label: input.spec.label, - executable: input.spec.executable, - status: "missing" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.some(`${input.spec.executable} was not found on the server PATH.`), - } satisfies DiscoveryProbeResult); - } + return Effect.suspend(() => { + if (!input.commandAvailable(input.spec.executable)) { + return Effect.succeed({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "missing" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.some(`${input.spec.executable} was not found on the server PATH.`), + } satisfies DiscoveryProbeResult); + } - return input.process - .run({ - operation: "source-control.discovery.probe", - command: input.spec.executable, - args: input.spec.versionArgs, - cwd: input.cwd, - ...(input.spec.env !== undefined ? { env: input.spec.env } : {}), - timeoutMs: 5_000, - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ + return input.process + .run({ + operation: "source-control.discovery.probe", + command: input.spec.executable, + args: input.spec.versionArgs, + cwd: input.cwd, + ...(input.spec.env !== undefined ? { env: input.spec.env } : {}), + timeoutMs: 5_000, + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.spec.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ kind: input.spec.kind, label: input.spec.label, executable: input.spec.executable, - status: "available" as const, - version: Option.orElse(firstNonEmptyLine(result.stdout), () => - firstNonEmptyLine(result.stderr), - ), + status: "missing" as const, + version: Option.none(), installHint: input.spec.installHint, - detail: Option.none(), - }) satisfies DiscoveryProbeResult, - ), - Effect.catch((cause) => - Effect.succeed({ - kind: input.spec.kind, - label: input.spec.label, - executable: input.spec.executable, - status: "missing" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), - ); + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); + }); } export function probeSourceControlProvider(input: { diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts index 6ee80c251..8d471b594 100644 --- a/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts @@ -6,7 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@threadlines/contracts"; +import { VcsProcessExitError, VcsProcessTimeoutError } from "@threadlines/contracts"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -333,6 +333,105 @@ it.effect("explains when WinGet has no applicable GitHub CLI update", () => { }).pipe(Effect.provide(layer)); }); +it.effect("explains a cancelled Windows installer and lets the user retry", () => { + const cancelledExitCodes = [0x8a15010c, 0x8a15010c - 2 ** 32]; + const layer = Layer.effect( + SourceControlToolMaintenance.SourceControlToolMaintenance, + SourceControlToolMaintenance.make({ + platform: "win32", + commandAvailable: (command) => command === "winget", + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + const exitCode = cancelledExitCodes.shift(); + return exitCode === undefined + ? Effect.succeed(processOutput) + : Effect.fail( + new VcsProcessExitError({ + operation: input.operation, + command: [input.command, ...input.args].join(" "), + cwd: input.cwd, + exitCode, + detail: "Installer transcript", + }), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* Effect.result( + maintenance.update({ target: "git", operation: "install" }), + ); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual( + result.failure.reason, + "Git installation was cancelled. Try again and approve the Windows permission prompt.", + ); + assert.strictEqual((yield* maintenance.getState)[0]?.message, result.failure.reason); + } + } + yield* maintenance.update({ target: "git", operation: "install" }); + assert.strictEqual((yield* maintenance.getState)[0]?.status, "succeeded"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("asks the user to rescan after a Windows installer times out", () => { + const layer = Layer.effect( + SourceControlToolMaintenance.SourceControlToolMaintenance, + SourceControlToolMaintenance.make({ + platform: "win32", + commandAvailable: (command) => command === "winget", + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: [input.command, ...input.args].join(" "), + cwd: input.cwd, + timeoutMs: 300_000, + }), + ), + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const result = yield* Effect.result( + maintenance.update({ target: "github-cli", operation: "install" }), + ); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual( + result.failure.reason, + "GitHub CLI installation timed out. Check for a Windows permission prompt, then rescan before retrying. The installer may still finish.", + ); + assert.deepStrictEqual(yield* maintenance.getState, [ + { + target: "github-cli", + operation: "install", + status: "failed", + message: result.failure.reason, + }, + ]); + } + }).pipe(Effect.provide(layer)); +}); + it.effect("explains when Homebrew does not manage the tool it was asked to upgrade", () => { const layer = Layer.effect( SourceControlToolMaintenance.SourceControlToolMaintenance, @@ -372,44 +471,63 @@ it.effect("explains when Homebrew does not manage the tool it was asked to upgra }).pipe(Effect.provide(layer)); }); -it.effect("serializes all source control updates through one WinGet lock", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let calls = 0; - const layer = Layer.effect( - SourceControlToolMaintenance.SourceControlToolMaintenance, - SourceControlToolMaintenance.make({ - platform: "win32", - commandAvailable: (command) => command === "winget" || command === "git", - }), - ).pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: () => { - calls += 1; - return Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.as(processOutput), - ); - }, +it.effect( + "queues different tools, rejects duplicates, and keeps installing after the caller leaves", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const layer = Layer.effect( + SourceControlToolMaintenance.SourceControlToolMaintenance, + SourceControlToolMaintenance.make({ + platform: "win32", + commandAvailable: (command) => command === "winget" || command === "git", }), - ), - Layer.provideMerge(NodeServices.layer), - ); + ).pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => { + calls += 1; + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(processOutput), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); - yield* Effect.gen(function* () { - const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; - const first = yield* maintenance.update({ target: "github-cli" }).pipe(Effect.forkScoped); - yield* Deferred.await(started); + yield* Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const first = yield* maintenance.update({ target: "github-cli" }).pipe(Effect.forkScoped); + yield* Deferred.await(started); - const second = yield* Effect.result(maintenance.update({ target: "git" })); - assert.strictEqual(second._tag, "Failure"); - assert.strictEqual(calls, 1); + const duplicate = yield* Effect.result(maintenance.update({ target: "github-cli" })); + assert.strictEqual(duplicate._tag, "Failure"); + const second = yield* maintenance.update({ target: "git" }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + assert.deepStrictEqual( + (yield* maintenance.getState).map((state) => [state.target, state.status]), + [ + ["github-cli", "running"], + ["git", "queued"], + ], + ); + assert.strictEqual(calls, 1); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(first); - }).pipe(Effect.provide(layer)); - }), + yield* Fiber.interrupt(first); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(second); + assert.strictEqual(calls, 2); + assert.deepStrictEqual( + (yield* maintenance.getState).map((state) => state.status), + ["succeeded", "succeeded"], + ); + }).pipe(Effect.provide(layer)); + }), ); diff --git a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts index d3d3c17ea..00ef9309b 100644 --- a/apps/server/src/sourceControl/SourceControlToolMaintenance.ts +++ b/apps/server/src/sourceControl/SourceControlToolMaintenance.ts @@ -3,14 +3,18 @@ import { type SourceControlDiscoveryResult, type SourceControlToolUpdateInput, type SourceControlToolUpdateTarget, + type SourceControlToolMaintenanceState, type VcsError, } from "@threadlines/contracts"; -import { isCommandAvailable } from "@threadlines/shared/shell"; +import { isCommandAvailable, refreshWindowsPath } from "@threadlines/shared/shell"; import * as Context from "effect/Context"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Fiber from "effect/Fiber"; +import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -21,14 +25,16 @@ import { sourceControlToolPackageRecipe, } from "./SourceControlToolPackages.ts"; import { parseGitHubCliVersion, parseGitVersion } from "./SourceControlToolVersionAdvisory.ts"; -import { isWinGetUpdateNotApplicable } from "./SourceControlWinGet.ts"; +import { isWinGetInstallCancelled, isWinGetUpdateNotApplicable } from "./SourceControlWinGet.ts"; const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; export interface SourceControlToolMaintenanceShape { + readonly getState: Effect.Effect>; readonly update: ( input: SourceControlToolUpdateInput, + verify?: () => Effect.Effect, ) => Effect.Effect; } @@ -65,6 +71,12 @@ function packageManagerFailureReason(input: { readonly manager: "homebrew" | "winget"; readonly cause: VcsError; }): string { + if (input.manager === "winget" && input.cause._tag === "VcsProcessTimeoutError") { + return `${sourceControlToolLabel(input.target)} ${input.operation === "install" ? "installation" : "update"} timed out. Check for a Windows permission prompt, then rescan before retrying. The installer may still finish.`; + } + if (input.manager === "winget" && isWinGetInstallCancelled(input.cause)) { + return `${sourceControlToolLabel(input.target)} ${input.operation === "install" ? "installation" : "update"} was cancelled. Try again and approve the Windows permission prompt.`; + } if ( input.manager === "winget" && input.operation === "update" && @@ -151,11 +163,17 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( const platform = options?.platform ?? process.platform; const commandAvailable = options?.commandAvailable ?? ((command: string) => isCommandAvailable(command, { platform })); - const updateActive = yield* Ref.make(false); + const scope = yield* Effect.scope; + const installerLock = yield* Semaphore.make(1); + const states = yield* Ref.make< + ReadonlyMap + >(new Map()); + const setState = (state: SourceControlToolMaintenanceState) => + Ref.update(states, (current) => new Map(current).set(state.target, state)); const update: SourceControlToolMaintenanceShape["update"] = Effect.fn( "SourceControlToolMaintenance.update", - )(function* (input) { + )(function* (input, verify) { const { target } = input; const operation = input.operation ?? "update"; const useGitForWindowsUpdater = @@ -189,60 +207,128 @@ export const make = Effect.fn("makeSourceControlToolMaintenance")(function* ( ); } - const acquired = yield* Ref.modify(updateActive, (active) => [!active, true] as const); - if (!acquired) { - return yield* updateError(target, "Another source control tool update is already running."); - } - - return yield* Effect.gen(function* () { - let updaterStarted = false; - for (const step of recipe.steps) { - const output = yield* vcsProcess - .run({ - operation: `source-control.tool.${operation}`, - command: step.command, - args: step.args, - cwd: config.cwd, - timeoutMs: UPDATE_TIMEOUT_MS, - maxOutputBytes: UPDATE_OUTPUT_MAX_BYTES, - appendTruncationMarker: true, - ...(useGitForWindowsUpdater ? { allowNonZeroExit: true } : {}), - }) - .pipe( - Effect.mapError((cause) => - useGitForWindowsUpdater - ? updateError( - target, - `The official Git for Windows updater failed: ${cause.message || "unknown process error"}`, - ) - : updateError( - target, - packageManagerFailureReason({ - target, - operation, - manager: packageManager!, - cause, - }), - ), - ), + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const acquired = yield* Ref.modify(states, (current) => { + const existing = current.get(target); + if (existing && ["queued", "running", "checking"].includes(existing.status)) + return [false, current] as const; + return [ + true, + new Map(current).set(target, { + target, + operation, + status: "queued", + message: "Waiting for another install to finish.", + } satisfies SourceControlToolMaintenanceState), + ] as const; + }); + if (!acquired) { + return yield* updateError( + target, + "This tool already has an install or update in progress.", ); + } - if (useGitForWindowsUpdater) { - if (output.exitCode !== 0 && output.exitCode !== 2) { - const detail = output.stderr.trim() || output.stdout.trim(); - return yield* updateError( - target, - `The official Git for Windows updater exited with code ${output.exitCode}${detail ? `: ${detail}` : "."}`, - ); + const run = Effect.gen(function* () { + yield* setState({ + target, + operation, + status: "running", + message: `${operation === "install" ? "Installing." : "Updating."}${platform === "win32" ? " Check for a Windows permission prompt." : ""}`, + }); + let updaterStarted = false; + for (const step of recipe.steps) { + const output = yield* vcsProcess + .run({ + operation: `source-control.tool.${operation}`, + command: step.command, + args: step.args, + cwd: config.cwd, + timeoutMs: UPDATE_TIMEOUT_MS, + maxOutputBytes: UPDATE_OUTPUT_MAX_BYTES, + appendTruncationMarker: true, + ...(useGitForWindowsUpdater ? { allowNonZeroExit: true } : {}), + }) + .pipe( + Effect.mapError((cause) => + useGitForWindowsUpdater + ? updateError( + target, + `The official Git for Windows updater failed: ${cause.message || "unknown process error"}`, + ) + : updateError( + target, + packageManagerFailureReason({ + target, + operation, + manager: packageManager!, + cause, + }), + ), + ), + ); + + if (useGitForWindowsUpdater) { + if (output.exitCode !== 0 && output.exitCode !== 2) { + const detail = output.stderr.trim() || output.stdout.trim(); + return yield* updateError( + target, + `The official Git for Windows updater exited with code ${output.exitCode}${detail ? `: ${detail}` : "."}`, + ); + } + updaterStarted ||= output.exitCode === 2; + } } - updaterStarted ||= output.exitCode === 2; - } - } - return { status: updaterStarted ? "started" : "completed" } as const; - }).pipe(Effect.ensuring(Ref.set(updateActive, false))); + yield* Effect.sync(() => refreshWindowsPath({ platform })); + yield* setState({ + target, + operation, + status: "checking", + message: "Checking installation.", + }); + if (verify) yield* verify(); + yield* setState({ + target, + operation, + status: updaterStarted ? "started" : "succeeded", + message: updaterStarted + ? "Finish the Windows installer, then rescan." + : operation === "install" + ? "Installed." + : "Update finished.", + }); + return { status: updaterStarted ? "started" : "completed" } as const; + }); + // The environment owns this job. Closing Settings or reconnecting only + // detaches the caller; it does not interrupt the installer. + return yield* installerLock + .withPermits(1)(run) + .pipe( + Effect.catchCause((cause) => { + const error = Cause.squash(cause); + return setState({ + target, + operation, + status: "failed", + message: + error instanceof SourceControlToolUpdateError + ? error.reason + : "The installer stopped unexpectedly. Try again.", + }).pipe(Effect.andThen(Effect.failCause(cause))); + }), + Effect.interruptible, + Effect.forkIn(scope), + Effect.flatMap((fiber) => restore(Fiber.join(fiber))), + ); + }), + ); }); - return SourceControlToolMaintenance.of({ update }); + return SourceControlToolMaintenance.of({ + update, + getState: Ref.get(states).pipe(Effect.map((current) => [...current.values()])), + }); }); export const layer = Layer.effect(SourceControlToolMaintenance, make()); diff --git a/apps/server/src/sourceControl/SourceControlWinGet.ts b/apps/server/src/sourceControl/SourceControlWinGet.ts index d5560190f..91795d9a2 100644 --- a/apps/server/src/sourceControl/SourceControlWinGet.ts +++ b/apps/server/src/sourceControl/SourceControlWinGet.ts @@ -80,6 +80,11 @@ export function isWinGetUpdateNotApplicable(cause: VcsError): boolean { ); } +export function isWinGetInstallCancelled(cause: VcsError): boolean { + // APPINSTALLER_CLI_ERROR_INSTALL_CANCELLED_BY_USER may be signed by the process bridge. + return cause._tag === "VcsProcessExitError" && cause.exitCode >>> 0 === 0x8a15010c; +} + export function makeLatestWinGetVersionResolver(input: { readonly cwd: string; readonly vcsProcess: VcsProcess.VcsProcessShape; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 10a33eae4..2c2aef78b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -132,6 +132,8 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlToolMaintenance from "./sourceControl/SourceControlToolMaintenance.ts"; +import * as GitHubAuth from "./sourceControl/GitHubAuth.ts"; +import { refreshWindowsPath } from "@threadlines/shared/shell"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -273,6 +275,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; const sourceControlToolMaintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const githubAuth = yield* GitHubAuth.GitHubAuth; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -1089,10 +1092,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() - ).pipe(Effect.map((providers) => ({ providers }))), + Effect.sync(() => refreshWindowsPath()).pipe( + Effect.andThen( + input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(), + ), + Effect.map((providers) => ({ providers })), + ), { "rpc.aggregate": "server" }, ), [WS_METHODS.serverStartProviderReview]: (input) => @@ -1244,11 +1251,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, - sourceControlDiscovery.discover, + Effect.sync(() => refreshWindowsPath()).pipe( + Effect.andThen(sourceControlDiscovery.discover), + ), { "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetSourceControlSetup]: () => + Effect.all({ + tools: sourceControlToolMaintenance.getState, + githubAuth: githubAuth.getState, + }), + [WS_METHODS.serverStartGitHubAuth]: () => githubAuth.start, + [WS_METHODS.serverCancelGitHubAuth]: () => githubAuth.cancel, [WS_METHODS.serverUpdateSourceControlTool]: (input) => observeRpcEffect( WS_METHODS.serverUpdateSourceControlTool, @@ -1273,10 +1289,42 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => input.target, ); - const maintenanceResult = yield* sourceControlToolMaintenance.update({ - ...input, - operation, - }); + const maintenanceResult = yield* sourceControlToolMaintenance.update( + { + ...input, + operation, + }, + () => + sourceControlDiscovery.discover.pipe( + Effect.flatMap((after) => + SourceControlToolMaintenance.currentSourceControlToolVersion( + after, + input.target, + ) !== null + ? Effect.void + : Effect.fail( + new SourceControlToolUpdateError({ + target: input.target, + reason: + "The installer finished, but the tool could not be found. Check the installer, then rescan or retry.", + }), + ), + ), + Effect.andThen(() => + input.target === "git" && operation === "install" + ? githubAuth.configureGit.pipe( + Effect.mapError( + (error) => + new SourceControlToolUpdateError({ + target: input.target, + reason: error.detail, + }), + ), + ) + : Effect.void, + ), + ), + ); const discovery = yield* sourceControlDiscovery.discover; const currentVersion = SourceControlToolMaintenance.currentSourceControlToolVersion( @@ -1287,7 +1335,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => return yield* new SourceControlToolUpdateError({ target: input.target, reason: - "The package-manager command finished, but Threadlines could not verify the installed tool version afterward. Rescan after restarting the desktop app.", + "The installer finished, but the tool could not be found. Check the installer, then rescan or retry.", }); } @@ -2329,8 +2377,11 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.succeed( - HttpRouter.add( + Effect.gen(function* () { + const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance; + const providerMaintenance = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; + const githubSignIn = yield* GitHubAuth.GitHubAuth; + return HttpRouter.add( "GET", "/ws", Effect.gen(function* () { @@ -2343,11 +2394,20 @@ export const websocketRpcRouteLayer = Layer.unwrap( }).pipe( Effect.provide( makeWsRpcLayer(session.sessionId).pipe( - Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( - SourceControlToolMaintenance.layer.pipe(Layer.provide(VcsProcess.layer)), + Layer.succeed( + SourceControlToolMaintenance.SourceControlToolMaintenance, + maintenance, + ), ), + Layer.provide( + Layer.succeed( + ProviderMaintenanceRunner.ProviderMaintenanceRunner, + providerMaintenance, + ), + ), + Layer.provide(Layer.succeed(GitHubAuth.GitHubAuth, githubSignIn)), + Layer.provideMerge(RpcSerialization.layerJson), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( Layer.provide( @@ -2401,6 +2461,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( () => sessions.markDisconnected(session.sessionId), ); }).pipe(Effect.scoped, Effect.catchTag("AuthError", respondToAuthError)), - ), - ), + ); + }), ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d661cbaa4..54422e338 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -8,7 +8,7 @@ import { type EnvironmentApi, type DesktopPreviewUserControl, type DesktopPreviewTarget, - type MessageId, + MessageId, type OrchestrationEvent, type PreviewAutomationRequest, type PreviewAutomationResponse, @@ -2131,6 +2131,90 @@ async function mountChatView(options: { } describe("ChatView timeline estimator parity (full app)", () => { + it("keeps an optimistic follow-up after acknowledged history despite an earlier timestamp", async () => { + const initial = createSnapshotForTargetUser({ + targetMessageId: MessageId.make("original"), + targetText: "Original request", + }); + const snapshot = { + ...initial, + threads: initial.threads.map((thread) => ({ + ...thread, + messages: [ + { + ...createUserMessage({ + id: MessageId.make("original"), + text: "Original request", + offsetSeconds: 0, + }), + eventSequence: 10, + }, + { + ...createAssistantMessage({ + id: MessageId.make("answer"), + text: "Original answer", + offsetSeconds: 1, + }), + eventSequence: 11, + }, + ], + })), + }; + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot }); + try { + useOptimisticThreadMessagesStore.getState().addMessage(THREAD_REF, { + id: MessageId.make("pending-clock-follow-up"), + role: "user", + text: "Follow-up while clock is behind", + createdAt: new Date(BASE_TIME_MS - 60_000).toISOString(), + streaming: false, + }); + await expect + .element(page.getByText("Follow-up while clock is behind", { exact: true })) + .toBeVisible(); + const answer = page.getByText("Original answer", { exact: true }).element(); + const followUp = page.getByText("Follow-up while clock is behind", { exact: true }).element(); + expect( + answer.compareDocumentPosition(followUp) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeThread, { + kind: "event", + event: { + sequence: 12, + eventId: EventId.make("clock-follow-up-accepted"), + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: NOW_ISO, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: THREAD_ID, + messageId: MessageId.make("pending-clock-follow-up"), + role: "user", + text: "Acknowledged follow-up", + turnId: null, + streaming: false, + createdAt: new Date(BASE_TIME_MS - 60_000).toISOString(), + updatedAt: NOW_ISO, + }, + } satisfies OrchestrationEvent, + }); + await expect.element(page.getByText("Acknowledged follow-up", { exact: true })).toBeVisible(); + const acknowledged = page.getByText("Acknowledged follow-up", { exact: true }).element(); + expect( + answer.compareDocumentPosition(acknowledged) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + await expect + .element(page.getByText("Follow-up while clock is behind", { exact: true })) + .not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + beforeAll(async () => { fixture = buildFixture( createSnapshotForTargetUser({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3dbfbfa93..ccd29cb0c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2345,7 +2345,15 @@ export default function ChatView(props: ChatViewProps) { if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; + // Pending rows have no server event yet. Keep their submission order at + // the end until the server replaces them with acknowledged messages. + return [ + ...serverMessagesWithPreviewHandoff, + ...pendingMessages.map((message, index) => ({ + ...message, + eventSequence: Number.MAX_SAFE_INTEGER - pendingMessages.length + index, + })), + ]; }, [ serverMessages, queuedSteeringMessageIds, diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx index cbf149b2b..0b61df200 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx @@ -3,6 +3,8 @@ import "../../index.css"; import { ProviderDriverKind, ProviderInstanceId, + EnvironmentId, + type SourceControlDiscoveryResult, type ServerProvider, } from "@threadlines/contracts"; import { @@ -15,6 +17,9 @@ import { import { page } from "vite-plus/test/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; +import * as Option from "effect/Option"; +import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; +import { resetSourceControlDiscoveryStateForTests } from "../../lib/sourceControlDiscoveryState"; /** * The sign-in row drives the real server-side auth flow, so the card needs a @@ -42,12 +47,28 @@ const providerAuthHarness = vi.hoisted(() => { }>(); const startCalls: Array<{ instanceId: string; flow: string }> = []; const installCalls: Array<{ provider: string; instanceId?: string; action?: string }> = []; + let discovery: SourceControlDiscoveryResult = { + versionControlSystems: [], + sourceControlProviders: [], + }; + const externalUrls: string[] = []; + const remoteDiscovery = new Map(); return { startCalls, installCalls, + externalUrls, + remoteDiscovery, + setDiscovery(value: SourceControlDiscoveryResult) { + discovery = value; + }, // The install row calls the same server RPC the Update button uses. server: { + discoverSourceControl: async () => discovery, + getSourceControlSetup: async () => ({ + tools: [], + githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null }, + }), updateProvider: (input: { provider: string; instanceId?: string; action?: string }) => { installCalls.push(input); return Promise.resolve({ providers: [] }); @@ -57,6 +78,9 @@ const providerAuthHarness = vi.hoisted(() => { listeners.clear(); startCalls.length = 0; installCalls.length = 0; + externalUrls.length = 0; + remoteDiscovery.clear(); + discovery = { versionControlSystems: [], sourceControlProviders: [] }; }, emit(event: AuthEvent) { for (const entry of listeners) { @@ -84,6 +108,11 @@ const providerAuthHarness = vi.hoisted(() => { }; }); +vi.mock("../../lib/externalLinks", () => ({ + openExternalUrl: (url: string) => providerAuthHarness.externalUrls.push(url), +})); +vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null })); + vi.mock("../../environments/runtime", () => { const primaryConnection = { client: { providerAuth: providerAuthHarness.client, server: providerAuthHarness.server }, @@ -113,7 +142,19 @@ vi.mock("../../environments/runtime", () => { getPrimaryEnvironmentConnection: () => primaryConnection, markRelaySavedEnvironmentLinkExpired: notUsed, readBackendEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, + readEnvironmentConnection: (environmentId: string) => + providerAuthHarness.remoteDiscovery.has(environmentId) + ? ({ + client: { + providerAuth: providerAuthHarness.client, + server: { + ...providerAuthHarness.server, + discoverSourceControl: async () => + providerAuthHarness.remoteDiscovery.get(environmentId)!, + }, + }, + } as never) + : primaryConnection, reconnectSavedEnvironment: notUsed, RELAY_LINK_EXPIRED_MESSAGE: "", removeSavedEnvironment: notUsed, @@ -216,6 +257,8 @@ const SIGNED_IN_CLAUDE = buildProvider({ * throwaway memory router rather than special-casing one test. */ function renderCard(props: { + readonly setupEnvironmentId?: EnvironmentId; + readonly projectEnvironmentId?: EnvironmentId; readonly providers: ReadonlyArray; readonly projectName: string | null; readonly onChooseProject?: () => void; @@ -225,10 +268,11 @@ function renderCard(props: { const rootRoute = createRootRoute({ component: () => ( ); + return render( + + + , + ); } function rowStates(): Record { @@ -260,13 +308,102 @@ function rowStates(): Record { describe("FirstRunSetupCard", () => { beforeEach(() => { + resetSourceControlDiscoveryStateForTests(); + resetAppAtomRegistryForTests(); providerAuthHarness.reset(); }); afterEach(() => { + resetSourceControlDiscoveryStateForTests(); document.body.innerHTML = ""; }); + it("offers Git setup and keeps GitHub optional when an agent is ready", async () => { + providerAuthHarness.setDiscovery({ + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "missing", + version: Option.none(), + detail: Option.none(), + installHint: "Install Git.", + versionAdvisory: { + status: "install_available", + severity: "info", + currentVersion: null, + latestVersion: null, + recommendedVersion: null, + checkedAt: null, + message: null, + notificationKey: null, + actions: [{ kind: "runUpdate", target: "git", operation: "install", label: "Install" }], + }, + }, + ], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("2.98.0"), + detail: Option.none(), + installHint: "Install GitHub CLI.", + auth: { + status: "unauthenticated", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }, + }, + ], + }); + await renderCard({ providers: [SIGNED_IN_CLAUDE], projectName: "B-git-project" }); + await expect + .element(page.getByRole("button", { name: "Install Git", exact: true })) + .toBeVisible(); + await expect.element(page.getByRole("button", { name: "Sign in to GitHub" })).toBeVisible(); + await expect + .element(page.getByText("Optional: browse your GitHub repositories and pull requests.")) + .toBeVisible(); + await expect.element(page.getByTestId("first-run-setup-start")).toBeEnabled(); + const card = document.querySelector("[data-testid='first-run-setup-card']")!; + card.style.width = "320px"; + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth); + }); + + it("checks tools in the setup environment when the chosen project lives elsewhere", async () => { + providerAuthHarness.remoteDiscovery.set("setup-environment", { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("2.55.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [], + }); + providerAuthHarness.remoteDiscovery.set("project-environment", { + versionControlSystems: [], + sourceControlProviders: [], + }); + await renderCard({ + providers: [SIGNED_IN_CLAUDE], + projectName: "B-git-project", + setupEnvironmentId: EnvironmentId.make("setup-environment"), + projectEnvironmentId: EnvironmentId.make("project-environment"), + }); + await expect.poll(() => rowStates().git).toBe("available"); + }); + it("gives every provider state its own dot and action, and holds the start button back", async () => { const screen = await renderCard({ providers: [SIGNED_OUT_CODEX, MISSING_CLAUDE], diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx index 5edc6f814..c54de9909 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -31,9 +31,19 @@ import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../../commandPaletteStore"; import { cn } from "../../lib/utils"; +import { + useSourceControlDiscovery, + useSourceControlSetup, +} from "../../lib/sourceControlDiscoveryState"; +import { openExternalUrl } from "../../lib/externalLinks"; import { selectWorkspaceProjectsAcrossEnvironments, useStore } from "../../store"; import { ProjectFavicon } from "../ProjectFavicon"; import { ProviderInstallAction } from "../settings/ProviderInstallAction"; +import { + CompactVersionAdvisory, + SourceControlToolProgress, +} from "../settings/CompactVersionAdvisory"; +import { GitHubSignInAction, GitHubSignInStatus } from "../settings/GitHubSignInAction"; import { useProviderConnectFlow } from "../settings/useProviderConnectFlow"; import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; import { Button } from "../ui/button"; @@ -74,7 +84,7 @@ function SetupRow({ }) { return (
  • ) : null} {description} - {action} + + {action} +
  • ); } @@ -144,7 +156,96 @@ function providerRowAction(row: FirstRunProviderRow): ReactNode { ); } +function SourceControlSetupRows({ + environmentId, +}: { + readonly environmentId: EnvironmentId | null; +}) { + const { data } = useSourceControlDiscovery({ environmentId }); + useSourceControlSetup({ environmentId }); + const git = data?.versionControlSystems.find((item) => item.kind === "git"); + const github = data?.sourceControlProviders.find((item) => item.kind === "github"); + return ( + <> + {git ? ( + + ) : git.versionAdvisory ? ( + + ) : ( + + ) + } + /> + ) : null} + {github ? ( + + ) : ( + + ) + ) : ( + <> + + {github.auth.status === "authenticated" ? ( + + ) : null} + {github.auth.status !== "authenticated" ? ( + + ) : null} + + ) + } + /> + ) : null} + + ); +} + export interface FirstRunSetupCardProps { + readonly setupEnvironmentId?: EnvironmentId | null; /** Enabled and disabled instances alike; disabled ones are filtered out. */ readonly providers: ReadonlyArray; readonly projectName: string | null; @@ -159,6 +260,7 @@ export interface FirstRunSetupCardProps { } export function FirstRunSetupCard({ + setupEnvironmentId, providers, projectName, projectCwd, @@ -225,7 +327,9 @@ export function FirstRunSetupCard({ className="flex w-full max-w-140 flex-col items-center pb-10" data-testid="first-run-setup-card" > - +
    + +

    ) : null} + {projectRowLeads ? null : projectSetupRow} @@ -365,6 +470,7 @@ export function useFirstRunSetupCard(input: UseFirstRunSetupCardInput): FirstRun } return ( ({ isAtEnd: true })); @@ -278,6 +279,56 @@ function buildTurnSubagent( } describe("MessagesTimeline", () => { + it("shows a follow-up below the original conversation after a clock correction", async () => { + const entries = deriveTimelineEntries( + [ + { + id: MessageId.make("original"), + role: "user", + text: "Original request", + createdAt: "2026-09-05T06:53:37.000Z", + eventSequence: 10, + streaming: false, + }, + { + id: MessageId.make("answer"), + role: "assistant", + text: "Original answer", + createdAt: "2026-09-05T06:53:41.000Z", + eventSequence: 11, + streaming: false, + }, + { + id: MessageId.make("follow-up"), + role: "user", + text: "Request after restart", + createdAt: "2026-09-05T06:53:05.000Z", + eventSequence: 12, + streaming: false, + }, + ], + [], + [], + ); + const screen = await renderTimeline( + , + ); + try { + await expect.element(page.getByText("Request after restart", { exact: true })).toBeVisible(); + const original = page.getByText("Original request", { exact: true }).element(); + const answer = page.getByText("Original answer", { exact: true }).element(); + const followUp = page.getByText("Request after restart", { exact: true }).element(); + expect( + original.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + answer.compareDocumentPosition(followUp) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } finally { + await screen.unmount(); + } + }); + afterEach(() => { scrollToEndSpy.mockReset(); getStateSpy.mockClear(); diff --git a/apps/web/src/components/settings/CompactVersionAdvisory.tsx b/apps/web/src/components/settings/CompactVersionAdvisory.tsx index 894091d4d..1582e81ca 100644 --- a/apps/web/src/components/settings/CompactVersionAdvisory.tsx +++ b/apps/web/src/components/settings/CompactVersionAdvisory.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId, SourceControlToolVersionAdvisory } from "@threadlines/contracts"; +import type { + EnvironmentId, + SourceControlToolUpdateTarget, + SourceControlToolVersionAdvisory, +} from "@threadlines/contracts"; +import { isSourceControlToolBusy } from "@threadlines/client-runtime"; import { AlertCircleIcon, ArrowUpCircleIcon, @@ -12,13 +17,36 @@ import { useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { openExternalUrl } from "../../lib/externalLinks"; import { cn } from "../../lib/utils"; -import { updateSourceControlTool } from "../../lib/sourceControlDiscoveryState"; +import { + updateSourceControlTool, + useSourceControlSetup, +} from "../../lib/sourceControlDiscoveryState"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; +export function SourceControlToolProgress({ + target, + environmentId, +}: { + readonly target: SourceControlToolUpdateTarget; + readonly environmentId?: EnvironmentId | null | undefined; +}) { + const setup = useSourceControlSetup({ environmentId }); + const job = setup.tools.find((tool) => tool.target === target); + return job && + (isSourceControlToolBusy(job.status) || job.status === "failed" || job.status === "started") ? ( + + {job.message} + + ) : null; +} + interface CompactVersionAdvisoryProps { readonly advisory: SourceControlToolVersionAdvisory; readonly environmentId: EnvironmentId | null | undefined; @@ -42,8 +70,19 @@ export function CompactVersionAdvisory({ environmentId, label, }: CompactVersionAdvisoryProps) { - const [isUpdating, setIsUpdating] = useState(false); + const [requestPending, setIsUpdating] = useState(false); + const setup = useSourceControlSetup({ environmentId }); const updateAction = advisory.actions.find((action) => action.kind === "runUpdate"); + const job = setup.tools.find((tool) => tool.target === updateAction?.target); + const isUpdating = requestPending || (job !== undefined && isSourceControlToolBusy(job.status)); + const busyLabel = + job?.status === "queued" + ? "Queued" + : job?.status === "checking" + ? "Checking" + : updateAction?.operation === "install" + ? "Installing" + : "Updating"; const copyActionCandidate = advisory.actions.find((action) => action.kind === "copyCommand"); const copyAction = copyActionCandidate?.kind === "copyCommand" ? copyActionCandidate : undefined; const openActionCandidate = advisory.actions.find((action) => action.kind === "openUrl"); @@ -113,18 +152,28 @@ export function CompactVersionAdvisory({ if (advisory.status === "install_available" && updateAction) { return ( - + + + {job && (isUpdating || job.status === "failed" || job.status === "started") ? ( + + {job.message} + + ) : null} + ); } @@ -198,9 +247,17 @@ export function CompactVersionAdvisory({ ) : ( )} - {isUpdating ? "Updating" : updateAction.label} + {isUpdating ? busyLabel : updateAction.label} ) : null} + {job && (isUpdating || job.status === "failed" || job.status === "started") ? ( +

    + {job.message} +

    + ) : null} {copyAction ? (
    diff --git a/apps/web/src/components/settings/GitHubSignInAction.tsx b/apps/web/src/components/settings/GitHubSignInAction.tsx new file mode 100644 index 000000000..eacf80adf --- /dev/null +++ b/apps/web/src/components/settings/GitHubSignInAction.tsx @@ -0,0 +1,104 @@ +import type { EnvironmentId } from "@threadlines/contracts"; +import { useEffect, useRef, useState } from "react"; + +import { openExternalUrl } from "../../lib/externalLinks"; +import { + cancelGitHubSignIn, + startGitHubSignIn, + useSourceControlSetup, +} from "../../lib/sourceControlDiscoveryState"; +import { Button } from "../ui/button"; + +const GITHUB_DEVICE_URL = "https://github.com/login/device"; + +export function GitHubSignInStatus({ + environmentId, +}: { + readonly environmentId?: EnvironmentId | null | undefined; +}) { + const { githubAuth } = useSourceControlSetup({ environmentId }); + return githubAuth.status === "succeeded" && githubAuth.message ? ( + + {githubAuth.message} + + ) : null; +} + +export function GitHubSignInAction({ + environmentId, +}: { + readonly environmentId?: EnvironmentId | null | undefined; +}) { + const { githubAuth } = useSourceControlSetup({ environmentId }); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const openWhenReady = useRef(false); + const running = pending || githubAuth.status === "running"; + const canOpen = githubAuth.verificationUrl === GITHUB_DEVICE_URL; + + useEffect(() => { + if (openWhenReady.current && canOpen && githubAuth.userCode) { + openWhenReady.current = false; + openExternalUrl(GITHUB_DEVICE_URL); + } + }, [canOpen, githubAuth.userCode]); + + const start = () => { + setPending(true); + setError(null); + openWhenReady.current = true; + void startGitHubSignIn({ environmentId }) + .catch((cause: unknown) => { + openWhenReady.current = false; + setError(cause instanceof Error ? cause.message : "Could not start GitHub sign-in."); + }) + .finally(() => setPending(false)); + }; + + return ( +
    + {running ? ( + <> + + {githubAuth.userCode ? ( + <> + Enter code{" "} + {githubAuth.userCode}{" "} + on GitHub. + + ) : ( + "Starting GitHub sign-in…" + )} + + {canOpen ? ( + + ) : null} + + + ) : ( + + )} + {error || githubAuth.status === "failed" ? ( + + {error ?? githubAuth.message} + + ) : null} +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 3eb59588e..83bd5dcf1 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -16,6 +16,7 @@ import { type ServerProcessResourceHistoryResult, type ServerProvider, type SourceControlDiscoveryResult, + type SourceControlSetupState, } from "@threadlines/contracts"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as DateTime from "effect/DateTime"; @@ -44,6 +45,7 @@ import { ConnectionsSettings } from "./ConnectionsSettings"; import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; import { SourceControlSettingsPanel } from "./SourceControlSettings"; +import { resetSourceControlDiscoveryStateForTests } from "../../lib/sourceControlDiscoveryState"; /** * The app-wide providers these panels are always mounted under. Settings rows @@ -2456,15 +2458,115 @@ describe("SourceControlSettingsPanel discovery states", () => { function setSourceControlDiscoveryStub( discoverSourceControl: () => Promise, updateSourceControlTool?: LocalApi["server"]["updateSourceControlTool"], + setup: Partial< + Pick + > = {}, ) { + resetSourceControlDiscoveryStateForTests(); window.nativeApi = { server: { discoverSourceControl, + getSourceControlSetup: async () => ({ + tools: [], + githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null }, + }), + ...setup, ...(updateSourceControlTool ? { updateSourceControlTool } : {}), }, - } as LocalApi; + shell: { openExternal: vi.fn(async () => {}) }, + } as unknown as LocalApi; } + it("restores an installation check and supports GitHub browser sign-in, cancellation, and safe links", async () => { + const discovery: SourceControlDiscoveryResult = { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("2.55.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + executable: "gh", + status: "available", + version: Option.some("2.98.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "unauthenticated", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }, + }, + ], + }; + let state: SourceControlSetupState = { + tools: [ + { + target: "git", + operation: "install", + status: "checking", + message: "Checking the installed Git version…", + }, + ], + githubAuth: { status: "idle", verificationUrl: null, userCode: null, message: null }, + }; + let verificationUrl = "https://github.com/login/device"; + const cancel = vi.fn(async () => { + state = { + ...state, + githubAuth: { status: "cancelled", verificationUrl: null, userCode: null, message: null }, + }; + }); + setSourceControlDiscoveryStub(async () => discovery, undefined, { + getSourceControlSetup: async () => state, + startGitHubAuth: async () => { + state = { + ...state, + githubAuth: { status: "running", verificationUrl, userCode: "ABCD-1234", message: null }, + }; + return state.githubAuth; + }, + cancelGitHubAuth: cancel, + }); + mounted = await renderWithTestRouter( + + + , + ); + await expect.element(page.getByText("Checking the installed Git version…")).toBeVisible(); + await page.getByRole("button", { name: "Sign in to GitHub" }).click(); + await expect.element(page.getByText("ABCD-1234", { exact: true })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open GitHub", exact: true })) + .toBeVisible(); + await expect + .poll(() => vi.mocked(window.nativeApi!.shell.openExternal).mock.calls.length) + .toBe(1); + expect(window.nativeApi!.shell.openExternal).toHaveBeenCalledWith( + "https://github.com/login/device", + ); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect.element(page.getByRole("button", { name: "Sign in to GitHub" })).toBeVisible(); + expect(cancel).toHaveBeenCalledOnce(); + verificationUrl = "https://github.com.evil.example/login/device"; + await page.getByRole("button", { name: "Sign in to GitHub" }).click(); + await expect.element(page.getByText("ABCD-1234", { exact: true })).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open GitHub", exact: true })) + .not.toBeInTheDocument(); + expect(window.nativeApi!.shell.openExternal).toHaveBeenCalledTimes(1); + }); + it("shows skeleton sections while the first source control scan is pending", async () => { setSourceControlDiscoveryStub(() => new Promise(() => {})); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 24038df9d..01fc4ce49 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -26,6 +26,7 @@ import { cn } from "../../lib/utils"; import { refreshSourceControlDiscovery, useSourceControlDiscovery, + useSourceControlSetup, } from "../../lib/sourceControlDiscoveryState"; import { useStore } from "../../store"; import { Badge } from "../ui/badge"; @@ -71,7 +72,8 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; -import { CompactVersionAdvisory } from "./CompactVersionAdvisory"; +import { CompactVersionAdvisory, SourceControlToolProgress } from "./CompactVersionAdvisory"; +import { GitHubSignInAction, GitHubSignInStatus } from "./GitHubSignInAction"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], @@ -220,6 +222,8 @@ function itemSummary({ } if (auth.status === "unauthenticated") { + if (item.kind === "github") + return Sign in to browse your repositories and use pull requests.; return ( {item.label} is not authenticated on this server. Sign in or configure credentials using @@ -277,6 +281,11 @@ function DiscoveryItemRow({ environmentId={environmentId} label={item.label} /> + ) : item.kind === "git" || item.kind === "github" ? ( + ) : null} {isVcsNotReady(item) ? ( @@ -294,6 +303,17 @@ function DiscoveryItemRow({

    + {isProviderDiscoveryItem(item) && + item.kind === "github" && + item.auth.status === "authenticated" ? ( + + ) : null} + {isProviderDiscoveryItem(item) && + item.kind === "github" && + item.status === "available" && + item.auth.status !== "authenticated" ? ( + + ) : null} {hasDetails ? (