| author | |
| committer | |
| log | e967bf6d83336edcf6c8bf6ea1e22edb45f32a16 |
| tree | e838b45ddc69813a9b2b43c2331bde5a0f47171d |
| parent | b3a01d86bc6c4c62f7469c43b02aa53b97fe3f44 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
Clover wants to settle finished work without deleting its workspace immediately or leaving unused dev servers running. Create managed workspaces from private host configuration through the sidebar and command palette, preserving permissions, drafts, and navigation.
Manual Settle closes every chat-owned terminal and browser tab. Warn only when running programs or Browser state will be lost; empty chats and idle shells settle directly. Unknown resource state still warns. Other chats sharing the folder keep their resources. Undo restores the chat, not its commands or page state. Bulk settlement confirms once; cancellation preserves selection. Automatic settlement skips open resources, including a guarded compensation for resources opened while settlement commits.
Archive quiet settled chats Saturday at 9 AM in server-local time, catching up after downtime. Keep managed workspaces until archival and release of other references. Archive settled work chats once all linked PRs are verified merged, including settlement after a merge. A new current-head CI failure returns a settled chat to Active and adds a Mergebot notice without starting an agent; settling again acknowledges that failure, while failed reruns can wake it again.
Use verified live Mergebot data for work PR decisions. Disconnection does not certify a merge or failure; Saturday archival and inactivity rules remain independent. Guard lifecycle mutations against newer activity or manual intent. Reconcile unused dev frontends at startup, on lifecycle events, and during sweeps, preserving active peers and pending work.
Keep Archive all in the Settled header context menu and offer Archive for selected chats. Both include hidden selections and group Undo over actual successes. Guard each settled archive against its current snapshot, skip raced state changes, and continue with later eligible chats. Show the full hidden count and expand it in one action. Skip revived chats and preserve navigation when the reader moves during archival.
Use the central inbox grant for offline organization in the settled-chat lifecycle. Skip source resource queries while that machine is disconnected and explain that cleanup waits for reconnection; keep deletion and execution tied to source grants.
Assisted-by: gpt-6.1-sol59 files changed, 5514 insertions(+), 393 deletions(-)
apps/mobile/src/features/home/useThreadListActions.test.ts+208-28| ... | @@ -11,19 +11,55 @@ import * as Cause from "effect/Cause"; | ... | @@ -11,19 +11,55 @@ import * as Cause from "effect/Cause"; |
| 11 | import { AsyncResult } from "effect/reactivity"; | 11 | import { AsyncResult } from "effect/reactivity"; |
| 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 13 | 13 | ||
| 14 | const resourceQueries = vi.hoisted(() => ({ | ||
| 15 | calls: 0, | ||
| 16 | terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, | ||
| 17 | tabs: null as unknown[] | null, | ||
| 18 | })); | ||
| 19 | vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ | ||
| 20 | ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()), | ||
| 21 | executeAtomQuery: async (_registry: unknown, atom: string) => { | ||
| 22 | resourceQueries.calls++; | ||
| 23 | const value = | ||
| 24 | atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs }; | ||
| 25 | return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) === | ||
| 26 | null | ||
| 27 | ? { _tag: "Failure", cause: {} } | ||
| 28 | : { _tag: "Success", value }; | ||
| 29 | }, | ||
| 30 | })); | ||
| 31 | vi.mock("../../connection/hub", () => ({ | ||
| 32 | hubConnection: { organizationPermissionAtom: (id: string) => `organization:${id}` }, | ||
| 33 | })); | ||
| 34 | vi.mock("../../connection/catalog", () => ({ | ||
| 35 | environmentCatalog: { stateAtom: (id: string) => `connection:${id}` }, | ||
| 36 | })); | ||
| 37 | vi.mock("../../state/terminal", () => ({ | ||
| 38 | terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, | ||
| 39 | })); | ||
| 40 | vi.mock("../../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); | ||
| 41 | |||
| 14 | const state = vi.hoisted(() => ({ | 42 | const state = vi.hoisted(() => ({ |
| 15 | pendingOrder: null as object | null, | 43 | pendingOrder: null as object | null, |
| 16 | dropBusy: false, | 44 | dropBusy: false, |
| 17 | scopes: new Map<string, Set<string>>(), | 45 | scopes: new Map<string, Set<string>>(), |
| 46 | organization: new Set<string>(), | ||
| 18 | shells: [] as EnvironmentThreadShell[], | 47 | shells: [] as EnvironmentThreadShell[], |
| 19 | requests: [] as { | 48 | requests: [] as { |
| 20 | action: string; | 49 | action: string; |
| 21 | environmentId: string; | 50 | environmentId: string; |
| 22 | input: { threadId: string; orderKey?: string }; | 51 | input: { threadId: string; orderKey?: string }; |
| 23 | }[], | 52 | }[], |
| 24 | dialogs: [] as { onConfirm: () => void }[], | 53 | autoConfirm: true, |
| 54 | dialogs: [] as { | ||
| 55 | title: string; | ||
| 56 | message?: string; | ||
| 57 | onConfirm: () => void; | ||
| 58 | onCancel?: () => void; | ||
| 59 | }[], | ||
| 25 | alerts: [] as { | 60 | alerts: [] as { |
| 26 | title: string; | 61 | title: string; |
| 62 | message?: string; | ||
| 27 | buttons?: { text: string; onPress?: () => void }[]; | 63 | buttons?: { text: string; onPress?: () => void }[]; |
| 28 | }[], | 64 | }[], |
| 29 | afterRequest: undefined as (() => void) | undefined, | 65 | afterRequest: undefined as (() => void) | undefined, |
| ... | @@ -35,8 +71,12 @@ vi.mock("react", () => ({ | ... | @@ -35,8 +71,12 @@ vi.mock("react", () => ({ |
| 35 | })); | 71 | })); |
| 36 | vi.mock("react-native", () => ({ | 72 | vi.mock("react-native", () => ({ |
| 37 | Alert: { | 73 | Alert: { |
| 38 | alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => | 74 | alert: (title: string, message: string, buttons?: { text: string; onPress?: () => void }[]) => { |
| 39 | state.alerts.push({ title, buttons }), | 75 | state.alerts.push({ title, message, buttons }); |
| 76 | if (state.autoConfirm && title.startsWith("Settle ")) { | ||
| 77 | buttons?.find((button) => button.text === "Settle")?.onPress?.(); | ||
| 78 | } | ||
| 79 | }, | ||
| 40 | }, | 80 | }, |
| 41 | })); | 81 | })); |
| 42 | vi.mock("expo-haptics", () => ({ | 82 | vi.mock("expo-haptics", () => ({ |
| ... | @@ -44,17 +84,29 @@ vi.mock("expo-haptics", () => ({ | ... | @@ -44,17 +84,29 @@ vi.mock("expo-haptics", () => ({ |
| 44 | ImpactFeedbackStyle: { Light: "light" }, | 84 | ImpactFeedbackStyle: { Light: "light" }, |
| 45 | })); | 85 | })); |
| 46 | vi.mock("../../components/ConfirmDialogHost", () => ({ | 86 | vi.mock("../../components/ConfirmDialogHost", () => ({ |
| 47 | showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), | 87 | showConfirmDialog: (dialog: { |
| 88 | title: string; | ||
| 89 | message?: string; | ||
| 90 | onConfirm: () => void; | ||
| 91 | onCancel?: () => void; | ||
| 92 | }) => { | ||
| 93 | state.dialogs.push(dialog); | ||
| 94 | if (state.autoConfirm && dialog.title.startsWith("Settle ")) dialog.onConfirm(); | ||
| 95 | }, | ||
| 48 | })); | 96 | })); |
| 49 | vi.mock("../archive/useArchivedThreadSnapshots", () => ({ | 97 | vi.mock("../archive/useArchivedThreadSnapshots", () => ({ |
| 50 | refreshArchivedThreadsForEnvironment: () => {}, | 98 | refreshArchivedThreadsForEnvironment: () => {}, |
| 51 | })); | 99 | })); |
| 52 | vi.mock("../../state/session", () => ({ | 100 | vi.mock("../../state/session", () => ({ |
| 101 | readThreadOrganizationPermission: (environmentId: string) => | ||
| 102 | state.organization.has(environmentId) || | ||
| 103 | state.scopes.get(environmentId)?.has("orchestration:operate") === true, | ||
| 53 | readEnvironmentScope: (environmentId: string, scope: string) => | 104 | readEnvironmentScope: (environmentId: string, scope: string) => |
| 54 | state.scopes.get(environmentId)?.has(scope) === true, | 105 | state.scopes.get(environmentId)?.has(scope) === true, |
| 55 | })); | 106 | })); |
| 56 | vi.mock("../../state/server", () => ({ | 107 | vi.mock("../../state/server", () => ({ |
| 57 | environmentServerConfigsAtom: "server-configs", | 108 | environmentCapabilitiesAtom: "environment-capabilities", |
| 109 | threadListEnvironmentsAtom: "thread-list-environments", | ||
| 58 | })); | 110 | })); |
| 59 | vi.mock("../../state/atom-registry", () => ({ | 111 | vi.mock("../../state/atom-registry", () => ({ |
| 60 | appAtomRegistry: { | 112 | appAtomRegistry: { |
| ... | @@ -62,28 +114,35 @@ vi.mock("../../state/atom-registry", () => ({ | ... | @@ -62,28 +114,35 @@ vi.mock("../../state/atom-registry", () => ({ |
| 62 | state.dropBusy = value; | 114 | state.dropBusy = value; |
| 63 | }, | 115 | }, |
| 64 | get: (atom: string) => | 116 | get: (atom: string) => |
| 65 | atom === "thread-drop-busy" | 117 | atom.startsWith("organization:") |
| 66 | ? state.dropBusy | 118 | ? state.organization.has(atom.slice(13)) |
| 67 | : atom === "thread-shells" | 119 | : atom.startsWith("connection:") |
| 68 | ? state.shells | 120 | ? AsyncResult.success({ phase: "offline" }) |
| 69 | : atom === "queued-thread-keys" | 121 | : atom === "thread-drop-busy" |
| 70 | ? new Set<string>() | 122 | ? state.dropBusy |
| 71 | : new Map( | 123 | : atom === "thread-shells" |
| 72 | [...state.scopes.keys()].map((environmentId) => [ | 124 | ? state.shells |
| 73 | environmentId, | 125 | : atom === "queued-thread-keys" |
| 74 | { | 126 | ? new Set<string>() |
| 75 | environment: { | 127 | : atom === "thread-list-environments" |
| 76 | capabilities: { | 128 | ? { |
| 77 | threadSettlement: true, | 129 | settlementEnvironmentIds: new Set(state.scopes.keys()), |
| 78 | threadSnooze: true, | 130 | snoozeEnvironmentIds: new Set(state.scopes.keys()), |
| 79 | threadPinning: true, | 131 | } |
| 80 | threadPinReorder: true, | 132 | : new Map( |
| 81 | threadTitleRegeneration: true, | 133 | [...state.scopes.keys()].map((environmentId) => [ |
| 82 | }, | 134 | environmentId, |
| 83 | }, | 135 | { |
| 84 | }, | 136 | threadSettlement: true, |
| 85 | ]), | 137 | threadSnooze: true, |
| 86 | ), | 138 | threadPinning: true, |
| 139 | threadActiveReorder: true, | ||
| 140 | threadPinReorder: true, | ||
| 141 | threadTitleRegeneration: true, | ||
| 142 | threadAutoSettleOptOut: true, | ||
| 143 | }, | ||
| 144 | ]), | ||
| 145 | ), | ||
| 87 | }, | 146 | }, |
| 88 | })); | 147 | })); |
| 89 | vi.mock("../../state/use-atom-command", () => ({ | 148 | vi.mock("../../state/use-atom-command", () => ({ |
| ... | @@ -131,7 +190,22 @@ vi.mock("../../state/threads", () => ({ | ... | @@ -131,7 +190,22 @@ vi.mock("../../state/threads", () => ({ |
| 131 | input: { threadId: string; orderKey?: string }; | 190 | input: { threadId: string; orderKey?: string }; |
| 132 | }) => { | 191 | }) => { |
| 133 | state.requests.push({ action, ...request }); | 192 | state.requests.push({ action, ...request }); |
| 134 | if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { | 193 | if ( |
| 194 | !state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope) && | ||
| 195 | !( | ||
| 196 | state.organization.has(request.environmentId) && | ||
| 197 | [ | ||
| 198 | "settle", | ||
| 199 | "unsettle", | ||
| 200 | "snooze", | ||
| 201 | "unsnooze", | ||
| 202 | "pin", | ||
| 203 | "unpin", | ||
| 204 | "reorderPin", | ||
| 205 | "setAutoSettle", | ||
| 206 | ].includes(action) | ||
| 207 | ) | ||
| 208 | ) { | ||
| 135 | return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); | 209 | return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); |
| 136 | } | 210 | } |
| 137 | state.afterRequest?.(); | 211 | state.afterRequest?.(); |
| ... | @@ -169,6 +243,10 @@ const mutationCases = [ | ... | @@ -169,6 +243,10 @@ const mutationCases = [ |
| 169 | ] as const; | 243 | ] as const; |
| 170 | 244 | ||
| 171 | beforeEach(() => { | 245 | beforeEach(() => { |
| 246 | resourceQueries.calls = 0; | ||
| 247 | state.organization.clear(); | ||
| 248 | resourceQueries.terminals = null; | ||
| 249 | resourceQueries.tabs = null; | ||
| 172 | state.pendingOrder = null; | 250 | state.pendingOrder = null; |
| 173 | state.dropBusy = false; | 251 | state.dropBusy = false; |
| 174 | state.scopes = new Map([ | 252 | state.scopes = new Map([ |
| ... | @@ -177,6 +255,7 @@ beforeEach(() => { | ... | @@ -177,6 +255,7 @@ beforeEach(() => { |
| 177 | ]); | 255 | ]); |
| 178 | state.requests = []; | 256 | state.requests = []; |
| 179 | state.shells = []; | 257 | state.shells = []; |
| 258 | state.autoConfirm = true; | ||
| 180 | state.dialogs = []; | 259 | state.dialogs = []; |
| 181 | state.alerts = []; | 260 | state.alerts = []; |
| 182 | state.afterRequest = undefined; | 261 | state.afterRequest = undefined; |
| ... | @@ -184,6 +263,89 @@ beforeEach(() => { | ... | @@ -184,6 +263,89 @@ beforeEach(() => { |
| 184 | 263 | ||
| 185 | afterEach(() => vi.unstubAllEnvs()); | 264 | afterEach(() => vi.unstubAllEnvs()); |
| 186 | 265 | ||
| 266 | describe("settle resource warning", () => { | ||
| 267 | it.each([ | ||
| 268 | { terminals: [] }, | ||
| 269 | { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] }, | ||
| 270 | ])("settles empty or idle-shell chats without prompting", async ({ terminals }) => { | ||
| 271 | resourceQueries.terminals = terminals; | ||
| 272 | resourceQueries.tabs = []; | ||
| 273 | await expect(useThreadListActions().settleThread(makeThread())).resolves.toBe(true); | ||
| 274 | expect(state.dialogs).toHaveLength(0); | ||
| 275 | expect(state.alerts).toHaveLength(0); | ||
| 276 | expect(state.requests.map((request) => request.action)).toEqual(["settle"]); | ||
| 277 | }); | ||
| 278 | |||
| 279 | it.each([ | ||
| 280 | { | ||
| 281 | terminals: [{ threadId: "thread", hasRunningSubprocess: true }], | ||
| 282 | tabs: [], | ||
| 283 | text: "Undo cannot restart them.", | ||
| 284 | }, | ||
| 285 | { terminals: [], tabs: [{}], text: "Undo cannot restore page state." }, | ||
| 286 | { terminals: [], tabs: null, text: "could not be checked" }, | ||
| 287 | ])( | ||
| 288 | "warns for programs, browser tabs, or unavailable resource data: $text", | ||
| 289 | async ({ terminals, tabs, text }) => { | ||
| 290 | resourceQueries.terminals = terminals; | ||
| 291 | resourceQueries.tabs = tabs; | ||
| 292 | await useThreadListActions().settleThread(makeThread()); | ||
| 293 | expect(state.dialogs[0]?.message).toContain(text); | ||
| 294 | expect(state.requests.map((request) => request.action)).toEqual(["settle"]); | ||
| 295 | }, | ||
| 296 | ); | ||
| 297 | |||
| 298 | it.each(["ios", "android"])("cancels %s settlement before dismissal or mutation", async (os) => { | ||
| 299 | vi.stubEnv("EXPO_OS", os); | ||
| 300 | state.autoConfirm = false; | ||
| 301 | const actions = useThreadListActions(); | ||
| 302 | const pending = actions.settleThread(makeThread()); | ||
| 303 | await vi.waitFor(() => expect(os === "ios" ? state.alerts : state.dialogs).toHaveLength(1)); | ||
| 304 | const warning = os === "ios" ? state.alerts[0] : state.dialogs[0]; | ||
| 305 | expect(warning?.message).toContain("stops any running commands"); | ||
| 306 | expect(warning?.message).toContain("Undo cannot restore commands or page state"); | ||
| 307 | expect(state.requests).toEqual([]); | ||
| 308 | |||
| 309 | if (os === "ios") | ||
| 310 | state.alerts[0]?.buttons?.find((button) => button.text === "Cancel")?.onPress?.(); | ||
| 311 | else state.dialogs[0]?.onCancel?.(); | ||
| 312 | |||
| 313 | await expect(pending).resolves.toBe(false); | ||
| 314 | expect(state.requests).toEqual([]); | ||
| 315 | }); | ||
| 316 | |||
| 317 | it.each(["ios", "android"])("rechecks permission after %s confirmation", async (os) => { | ||
| 318 | vi.stubEnv("EXPO_OS", os); | ||
| 319 | state.autoConfirm = false; | ||
| 320 | const pending = useThreadListActions().settleThread(makeThread()); | ||
| 321 | await vi.waitFor(() => expect(os === "ios" ? state.alerts : state.dialogs).toHaveLength(1)); | ||
| 322 | state.scopes.get(primaryEnvironmentId)!.clear(); | ||
| 323 | if (os === "ios") | ||
| 324 | state.alerts[0]?.buttons?.find((button) => button.text === "Settle")?.onPress?.(); | ||
| 325 | else state.dialogs[0]?.onConfirm(); | ||
| 326 | |||
| 327 | await expect(pending).resolves.toBe(false); | ||
| 328 | expect(state.requests).toEqual([]); | ||
| 329 | }); | ||
| 330 | |||
| 331 | it("warns for a move to Settled before issuing its command", async () => { | ||
| 332 | state.autoConfirm = false; | ||
| 333 | const thread = makeThread(); | ||
| 334 | state.shells = [thread]; | ||
| 335 | const pending = useThreadListActions().moveThread(thread, { | ||
| 336 | section: "settled", | ||
| 337 | targetId: null, | ||
| 338 | placement: "after", | ||
| 339 | }); | ||
| 340 | expect(state.requests).toEqual([]); | ||
| 341 | await vi.waitFor(() => expect(state.dialogs).toHaveLength(1)); | ||
| 342 | state.dialogs[0]?.onConfirm(); | ||
| 343 | |||
| 344 | await expect(pending).resolves.toBe(true); | ||
| 345 | expect(state.requests.map((request) => request.action)).toEqual(["settle"]); | ||
| 346 | }); | ||
| 347 | }); | ||
| 348 | |||
| 187 | describe("thread list operation permissions", () => { | 349 | describe("thread list operation permissions", () => { |
| 188 | it.each(mutationCases)( | 350 | it.each(mutationCases)( |
| 189 | "%s rejects a retained callback after its environment loses permission", | 351 | "%s rejects a retained callback after its environment loses permission", |
| ... | @@ -324,3 +486,21 @@ describe("pinned thread operation permissions", () => { | ... | @@ -324,3 +486,21 @@ describe("pinned thread operation permissions", () => { |
| 324 | expect(state.requests).toHaveLength(1); | 486 | expect(state.requests).toHaveLength(1); |
| 325 | }); | 487 | }); |
| 326 | }); | 488 | }); |
| 489 | |||
| 490 | it("organizes a chat through the central grant while source mutations remain denied", async () => { | ||
| 491 | resourceQueries.terminals = []; | ||
| 492 | resourceQueries.tabs = []; | ||
| 493 | state.organization.add(otherEnvironmentId); | ||
| 494 | const thread = makeThread({ environmentId: otherEnvironmentId }); | ||
| 495 | const actions = useThreadListActions(); | ||
| 496 | expect(await actions.settleThread(thread)).toBe(true); | ||
| 497 | expect(resourceQueries.calls).toBe(0); | ||
| 498 | expect(await actions.unsettleThread(thread)).toBe(true); | ||
| 499 | expect(await actions.snoozeThread(thread, "2027-01-01T00:00:00.000Z")).toBe(true); | ||
| 500 | expect(await actions.regenerateThreadTitle(thread)).toBe(false); | ||
| 501 | actions.confirmDeleteThread(thread); | ||
| 502 | expect(state.requests.map((request) => request.action)).toEqual(["settle", "unsettle", "snooze"]); | ||
| 503 | state.organization.clear(); | ||
| 504 | expect(await actions.pinThread(thread)).toBe(false); | ||
| 505 | expect(state.requests).toHaveLength(3); | ||
| 506 | }); |
apps/mobile/src/features/home/useThreadListActions.ts+137-41| ... | @@ -1,6 +1,14 @@ | ... | @@ -1,6 +1,14 @@ |
| 1 | import * as Option from "effect/Option"; | ||
| 2 | import { AsyncResult } from "effect/reactivity"; | ||
| 3 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 4 | import { hubConnection } from "../../connection/hub"; | ||
| 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; | 5 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 6 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | 7 | import { |
| 8 | canSnooze, | ||
| 9 | effectiveSnoozed, | ||
| 10 | resolveThreadSettlementWarning, | ||
| 11 | } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 4 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | 12 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 5 | import * as Cause from "effect/Cause"; | 13 | import * as Cause from "effect/Cause"; |
| 6 | import * as Haptics from "expo-haptics"; | 14 | import * as Haptics from "expo-haptics"; |
| ... | @@ -13,8 +21,11 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; | ... | @@ -13,8 +21,11 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 13 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; | 21 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; |
| 14 | import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; | 22 | import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; |
| 15 | import { appAtomRegistry } from "../../state/atom-registry"; | 23 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 16 | import { environmentServerConfigsAtom } from "../../state/server"; | 24 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 17 | import { readEnvironmentScope } from "../../state/session"; | 25 | import { terminalEnvironment } from "../../state/terminal"; |
| 26 | import { previewEnvironment } from "../../state/preview"; | ||
| 27 | import { environmentCapabilitiesAtom, threadListEnvironmentsAtom } from "../../state/server"; | ||
| 28 | import { readEnvironmentScope, readThreadOrganizationPermission } from "../../state/session"; | ||
| 18 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; | 29 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 19 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; | 30 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; |
| 20 | import { useAtomCommand } from "../../state/use-atom-command"; | 31 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | @@ -36,29 +47,23 @@ import { resolveThreadTitleRename } from "../threads/thread-title-rename"; | ... | @@ -36,29 +47,23 @@ import { resolveThreadTitleRename } from "../threads/thread-title-rename"; |
| 36 | (capability defaults false on decode for older servers). */ | 47 | (capability defaults false on decode for older servers). */ |
| 37 | function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { | 48 | function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { |
| 38 | return ( | 49 | return ( |
| 39 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 50 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true |
| 40 | .threadSettlement === true | ||
| 41 | ); | 51 | ); |
| 42 | } | 52 | } |
| 43 | 53 | ||
| 44 | function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { | 54 | function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { |
| 45 | return ( | 55 | return appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSnooze === true; |
| 46 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | ||
| 47 | .threadSnooze === true | ||
| 48 | ); | ||
| 49 | } | 56 | } |
| 50 | 57 | ||
| 51 | function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { | 58 | function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { |
| 52 | return ( | 59 | return ( |
| 53 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 60 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true |
| 54 | .threadPinning === true | ||
| 55 | ); | 61 | ); |
| 56 | } | 62 | } |
| 57 | 63 | ||
| 58 | function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { | 64 | function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { |
| 59 | return ( | 65 | return ( |
| 60 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 66 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true |
| 61 | .threadPinReorder === true | ||
| 62 | ); | 67 | ); |
| 63 | } | 68 | } |
| 64 | 69 | ||
| ... | @@ -66,8 +71,8 @@ function environmentSupportsAutoSettleOptOut( | ... | @@ -66,8 +71,8 @@ function environmentSupportsAutoSettleOptOut( |
| 66 | environmentId: EnvironmentThreadShell["environmentId"], | 71 | environmentId: EnvironmentThreadShell["environmentId"], |
| 67 | ) { | 72 | ) { |
| 68 | return ( | 73 | return ( |
| 69 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 74 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut === |
| 70 | .threadAutoSettleOptOut === true | 75 | true |
| 71 | ); | 76 | ); |
| 72 | } | 77 | } |
| 73 | 78 | ||
| ... | @@ -75,8 +80,8 @@ function environmentSupportsTitleRegeneration( | ... | @@ -75,8 +80,8 @@ function environmentSupportsTitleRegeneration( |
| 75 | environmentId: EnvironmentThreadShell["environmentId"], | 80 | environmentId: EnvironmentThreadShell["environmentId"], |
| 76 | ) { | 81 | ) { |
| 77 | return ( | 82 | return ( |
| 78 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | 83 | appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration === |
| 79 | .threadTitleRegeneration === true | 84 | true |
| 80 | ); | 85 | ); |
| 81 | } | 86 | } |
| 82 | 87 | ||
| ... | @@ -110,8 +115,17 @@ function actionFailureTitle(action: ThreadListAction): string { | ... | @@ -110,8 +115,17 @@ function actionFailureTitle(action: ThreadListAction): string { |
| 110 | return "Could not delete thread"; | 115 | return "Could not delete thread"; |
| 111 | } | 116 | } |
| 112 | 117 | ||
| 113 | function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean { | 118 | function checkThreadOperationPermission( |
| 114 | if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; | 119 | thread: EnvironmentThreadShell, |
| 120 | title: string, | ||
| 121 | organization = false, | ||
| 122 | ): boolean { | ||
| 123 | if ( | ||
| 124 | organization | ||
| 125 | ? readThreadOrganizationPermission(thread.environmentId) | ||
| 126 | : readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope) | ||
| 127 | ) | ||
| 128 | return true; | ||
| 115 | Alert.alert(title, "This connection cannot change threads."); | 129 | Alert.alert(title, "This connection cannot change threads."); |
| 116 | return false; | 130 | return false; |
| 117 | } | 131 | } |
| ... | @@ -129,14 +143,20 @@ function useThreadActionExecutor( | ... | @@ -129,14 +143,20 @@ function useThreadActionExecutor( |
| 129 | 143 | ||
| 130 | const executeAction = useCallback( | 144 | const executeAction = useCallback( |
| 131 | async (action: ThreadListAction, thread: EnvironmentThreadShell) => { | 145 | async (action: ThreadListAction, thread: EnvironmentThreadShell) => { |
| 132 | if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; | 146 | if ( |
| 147 | !checkThreadOperationPermission( | ||
| 148 | thread, | ||
| 149 | actionFailureTitle(action), | ||
| 150 | action === "settle" || action === "unsettle", | ||
| 151 | ) | ||
| 152 | ) | ||
| 153 | return false; | ||
| 133 | const key = scopedThreadKey(thread.environmentId, thread.id); | 154 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 134 | if (inFlightThreadKeys.current.has(key)) { | 155 | if (inFlightThreadKeys.current.has(key)) { |
| 135 | return false; | 156 | return false; |
| 136 | } | 157 | } |
| 137 | 158 | ||
| 138 | inFlightThreadKeys.current.add(key); | 159 | inFlightThreadKeys.current.add(key); |
| 139 | selectionHaptic(); | ||
| 140 | try { | 160 | try { |
| 141 | if ( | 161 | if ( |
| 142 | (action === "settle" || action === "unsettle") && | 162 | (action === "settle" || action === "unsettle") && |
| ... | @@ -148,6 +168,85 @@ function useThreadActionExecutor( | ... | @@ -148,6 +168,85 @@ function useThreadActionExecutor( |
| 148 | ); | 168 | ); |
| 149 | return false; | 169 | return false; |
| 150 | } | 170 | } |
| 171 | if (action === "settle") { | ||
| 172 | if (thread.settledOverride === "settled") return false; | ||
| 173 | const queryOptions = { | ||
| 174 | refresh: true, | ||
| 175 | reportFailure: false, | ||
| 176 | signal: AbortSignal.timeout(3_000), | ||
| 177 | }; | ||
| 178 | const sourceOffline = | ||
| 179 | appAtomRegistry.get(hubConnection.organizationPermissionAtom(thread.environmentId)) && | ||
| 180 | Option.getOrNull( | ||
| 181 | AsyncResult.value( | ||
| 182 | appAtomRegistry.get(environmentCatalog.stateAtom(thread.environmentId)), | ||
| 183 | ), | ||
| 184 | )?.phase !== "connected"; | ||
| 185 | const [terminals, previews] = sourceOffline | ||
| 186 | ? [null, null] | ||
| 187 | : await Promise.all([ | ||
| 188 | executeAtomQuery( | ||
| 189 | appAtomRegistry, | ||
| 190 | terminalEnvironment.metadataSnapshot({ | ||
| 191 | environmentId: thread.environmentId, | ||
| 192 | input: null, | ||
| 193 | }), | ||
| 194 | queryOptions, | ||
| 195 | ), | ||
| 196 | executeAtomQuery( | ||
| 197 | appAtomRegistry, | ||
| 198 | previewEnvironment.list({ | ||
| 199 | environmentId: thread.environmentId, | ||
| 200 | input: { threadId: thread.id }, | ||
| 201 | }), | ||
| 202 | queryOptions, | ||
| 203 | ), | ||
| 204 | ]); | ||
| 205 | const resourceWarning = resolveThreadSettlementWarning({ | ||
| 206 | runningCommands: | ||
| 207 | terminals?._tag === "Success" | ||
| 208 | ? terminals.value.some( | ||
| 209 | (terminal) => terminal.threadId === thread.id && terminal.hasRunningSubprocess, | ||
| 210 | ) | ||
| 211 | : null, | ||
| 212 | browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null, | ||
| 213 | }); | ||
| 214 | const warning = | ||
| 215 | resourceWarning !== null && sourceOffline | ||
| 216 | ? `${resourceWarning} Cleanup waits until the computer reconnects.` | ||
| 217 | : resourceWarning; | ||
| 218 | const confirmed = | ||
| 219 | warning === null || | ||
| 220 | (await new Promise<boolean>((resolve) => { | ||
| 221 | const title = `Settle "${thread.title}"?`; | ||
| 222 | if (process.env.EXPO_OS === "ios") { | ||
| 223 | Alert.alert(title, warning, [ | ||
| 224 | { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, | ||
| 225 | { text: "Settle", style: "destructive", onPress: () => resolve(true) }, | ||
| 226 | ]); | ||
| 227 | } else { | ||
| 228 | showConfirmDialog({ | ||
| 229 | title, | ||
| 230 | message: warning, | ||
| 231 | confirmText: "Settle", | ||
| 232 | destructive: true, | ||
| 233 | onConfirm: () => resolve(true), | ||
| 234 | onCancel: () => resolve(false), | ||
| 235 | }); | ||
| 236 | } | ||
| 237 | })); | ||
| 238 | if ( | ||
| 239 | !confirmed || | ||
| 240 | !checkThreadOperationPermission( | ||
| 241 | thread, | ||
| 242 | actionFailureTitle(action), | ||
| 243 | action === "settle" || action === "unsettle", | ||
| 244 | ) | ||
| 245 | ) { | ||
| 246 | return false; | ||
| 247 | } | ||
| 248 | } | ||
| 249 | selectionHaptic(); | ||
| 151 | // Archive keeps its original, narrower guard: never interrupt a | 250 | // Archive keeps its original, narrower guard: never interrupt a |
| 152 | // thread mid-turn. | 251 | // thread mid-turn. |
| 153 | if (action === "archive" && !threadCanArchive(thread.runtime)) { | 252 | if (action === "archive" && !threadCanArchive(thread.runtime)) { |
| ... | @@ -291,7 +390,7 @@ export function useThreadListActions(): { | ... | @@ -291,7 +390,7 @@ export function useThreadListActions(): { |
| 291 | ); | 390 | ); |
| 292 | const snoozeThread = useCallback( | 391 | const snoozeThread = useCallback( |
| 293 | async (thread: EnvironmentThreadShell, snoozedUntil: string) => { | 392 | async (thread: EnvironmentThreadShell, snoozedUntil: string) => { |
| 294 | if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; | 393 | if (!checkThreadOperationPermission(thread, "Could not snooze thread", true)) return false; |
| 295 | const key = scopedThreadKey(thread.environmentId, thread.id); | 394 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 296 | if (snoozeInFlightThreadKeys.current.has(key)) { | 395 | if (snoozeInFlightThreadKeys.current.has(key)) { |
| 297 | return false; | 396 | return false; |
| ... | @@ -349,7 +448,7 @@ export function useThreadListActions(): { | ... | @@ -349,7 +448,7 @@ export function useThreadListActions(): { |
| 349 | ); | 448 | ); |
| 350 | const unsnoozeThread = useCallback( | 449 | const unsnoozeThread = useCallback( |
| 351 | async (thread: EnvironmentThreadShell) => { | 450 | async (thread: EnvironmentThreadShell) => { |
| 352 | if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; | 451 | if (!checkThreadOperationPermission(thread, "Could not wake thread", true)) return false; |
| 353 | const key = scopedThreadKey(thread.environmentId, thread.id); | 452 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 354 | if (snoozeInFlightThreadKeys.current.has(key)) { | 453 | if (snoozeInFlightThreadKeys.current.has(key)) { |
| 355 | return false; | 454 | return false; |
| ... | @@ -397,7 +496,7 @@ export function useThreadListActions(): { | ... | @@ -397,7 +496,7 @@ export function useThreadListActions(): { |
| 397 | ); | 496 | ); |
| 398 | const pinThread = useCallback( | 497 | const pinThread = useCallback( |
| 399 | async (thread: EnvironmentThreadShell) => { | 498 | async (thread: EnvironmentThreadShell) => { |
| 400 | if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; | 499 | if (!checkThreadOperationPermission(thread, "Could not pin thread", true)) return false; |
| 401 | if (!environmentSupportsPinning(thread.environmentId)) { | 500 | if (!environmentSupportsPinning(thread.environmentId)) { |
| 402 | Alert.alert( | 501 | Alert.alert( |
| 403 | "Could not pin thread", | 502 | "Could not pin thread", |
| ... | @@ -438,7 +537,7 @@ export function useThreadListActions(): { | ... | @@ -438,7 +537,7 @@ export function useThreadListActions(): { |
| 438 | ); | 537 | ); |
| 439 | const unpinThread = useCallback( | 538 | const unpinThread = useCallback( |
| 440 | async (thread: EnvironmentThreadShell) => { | 539 | async (thread: EnvironmentThreadShell) => { |
| 441 | if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; | 540 | if (!checkThreadOperationPermission(thread, "Could not unpin thread", true)) return false; |
| 442 | if (!environmentSupportsPinning(thread.environmentId)) { | 541 | if (!environmentSupportsPinning(thread.environmentId)) { |
| 443 | Alert.alert( | 542 | Alert.alert( |
| 444 | "Could not unpin thread", | 543 | "Could not unpin thread", |
| ... | @@ -467,6 +566,8 @@ export function useThreadListActions(): { | ... | @@ -467,6 +566,8 @@ export function useThreadListActions(): { |
| 467 | ); | 566 | ); |
| 468 | const setThreadAutoSettle = useCallback( | 567 | const setThreadAutoSettle = useCallback( |
| 469 | async (thread: EnvironmentThreadShell, enabled: boolean) => { | 568 | async (thread: EnvironmentThreadShell, enabled: boolean) => { |
| 569 | if (!checkThreadOperationPermission(thread, "Could not update auto-settle", true)) | ||
| 570 | return false; | ||
| 470 | if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { | 571 | if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { |
| 471 | Alert.alert( | 572 | Alert.alert( |
| 472 | "Could not update auto-settle", | 573 | "Could not update auto-settle", |
| ... | @@ -589,7 +690,7 @@ export function useThreadListActions(): { | ... | @@ -589,7 +690,7 @@ export function useThreadListActions(): { |
| 589 | }); | 690 | }); |
| 590 | const moveThread = useCallback( | 691 | const moveThread = useCallback( |
| 591 | async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { | 692 | async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { |
| 592 | if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; | 693 | if (!checkThreadOperationPermission(thread, "Could not move thread", true)) return false; |
| 593 | if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; | 694 | if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; |
| 594 | const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); | 695 | const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); |
| 595 | const current = shells.find( | 696 | const current = shells.find( |
| ... | @@ -612,9 +713,10 @@ export function useThreadListActions(): { | ... | @@ -612,9 +713,10 @@ export function useThreadListActions(): { |
| 612 | appAtomRegistry.set(threadDropBusyAtom, false); | 713 | appAtomRegistry.set(threadDropBusyAtom, false); |
| 613 | } | 714 | } |
| 614 | } | 715 | } |
| 615 | const configs = appAtomRegistry.get(environmentServerConfigsAtom); | 716 | const capabilitiesByEnvironment = appAtomRegistry.get(environmentCapabilitiesAtom); |
| 717 | const listEnvironments = appAtomRegistry.get(threadListEnvironmentsAtom); | ||
| 616 | const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { | 718 | const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { |
| 617 | const capabilities = configs.get(environmentId)?.environment.capabilities; | 719 | const capabilities = capabilitiesByEnvironment.get(environmentId); |
| 618 | return section === "pinned" | 720 | return section === "pinned" |
| 619 | ? capabilities?.threadPinReorder === true | 721 | ? capabilities?.threadPinReorder === true |
| 620 | : capabilities?.threadActiveReorder === true; | 722 | : capabilities?.threadActiveReorder === true; |
| ... | @@ -631,22 +733,16 @@ export function useThreadListActions(): { | ... | @@ -631,22 +733,16 @@ export function useThreadListActions(): { |
| 631 | section, | 733 | section, |
| 632 | now: new Date().toISOString(), | 734 | now: new Date().toISOString(), |
| 633 | queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), | 735 | queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), |
| 634 | settlementEnvironmentIds: new Set( | 736 | settlementEnvironmentIds: listEnvironments.settlementEnvironmentIds, |
| 635 | [...configs].flatMap(([id, config]) => | 737 | snoozeEnvironmentIds: listEnvironments.snoozeEnvironmentIds, |
| 636 | config.environment.capabilities.threadSettlement === true ? [id] : [], | ||
| 637 | ), | ||
| 638 | ), | ||
| 639 | snoozeEnvironmentIds: new Set( | ||
| 640 | [...configs].flatMap(([id, config]) => | ||
| 641 | config.environment.capabilities.threadSnooze === true ? [id] : [], | ||
| 642 | ), | ||
| 643 | ), | ||
| 644 | }); | 738 | }); |
| 645 | const assignments = createThreadMovePlanner({ | 739 | const assignments = createThreadMovePlanner({ |
| 646 | allThreads: shells, | 740 | allThreads: shells, |
| 647 | ordered, | 741 | ordered, |
| 648 | section, | 742 | section, |
| 649 | reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), | 743 | reorderableEnvironmentIds: new Set( |
| 744 | [...capabilitiesByEnvironment.keys()].filter(supportsReorder), | ||
| 745 | ), | ||
| 650 | })(scopedThreadKey(thread.environmentId, thread.id), direction); | 746 | })(scopedThreadKey(thread.environmentId, thread.id), direction); |
| 651 | if (assignments === null) return false; | 747 | if (assignments === null) return false; |
| 652 | const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); | 748 | const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); |
| ... | @@ -668,7 +764,7 @@ export function useThreadListActions(): { | ... | @@ -668,7 +764,7 @@ export function useThreadListActions(): { |
| 668 | ); | 764 | ); |
| 669 | for (const assignment of assignments) { | 765 | for (const assignment of assignments) { |
| 670 | const target = shellByKey.get(assignment.id); | 766 | const target = shellByKey.get(assignment.id); |
| 671 | if (target && !checkThreadOperationPermission(target, "Could not move thread")) | 767 | if (target && !checkThreadOperationPermission(target, "Could not move thread", true)) |
| 672 | return false; | 768 | return false; |
| 673 | } | 769 | } |
| 674 | selectionHaptic(); | 770 | selectionHaptic(); |
| ... | @@ -717,7 +813,7 @@ export function useThreadListActions(): { | ... | @@ -717,7 +813,7 @@ export function useThreadListActions(): { |
| 717 | if (pending !== null && !pending.isPending()) return false; | 813 | if (pending !== null && !pending.isPending()) return false; |
| 718 | const target = shellByKey.get(assignment.id); | 814 | const target = shellByKey.get(assignment.id); |
| 719 | if (target === undefined) continue; | 815 | if (target === undefined) continue; |
| 720 | if (!checkThreadOperationPermission(target, "Could not move thread")) return false; | 816 | if (!checkThreadOperationPermission(target, "Could not move thread", true)) return false; |
| 721 | const result = await reorder({ | 817 | const result = await reorder({ |
| 722 | environmentId: target.environmentId, | 818 | environmentId: target.environmentId, |
| 723 | input: { threadId: target.id, orderKey: assignment.orderKey }, | 819 | input: { threadId: target.id, orderKey: assignment.orderKey }, |
apps/mobile/src/features/threads/ThreadFeed.tsx+5-5| ... | @@ -1011,7 +1011,7 @@ function renderFeedEntry( | ... | @@ -1011,7 +1011,7 @@ function renderFeedEntry( |
| 1011 | readonly markdownLinkHandlers: MarkdownLinkHandlers; | 1011 | readonly markdownLinkHandlers: MarkdownLinkHandlers; |
| 1012 | readonly renderMarkdownImage: MarkdownImageRenderer; | 1012 | readonly renderMarkdownImage: MarkdownImageRenderer; |
| 1013 | readonly renderViewedImage: MarkdownImageRenderer; | 1013 | readonly renderViewedImage: MarkdownImageRenderer; |
| 1014 | readonly renderReasoning: (text: string) => ReactNode; | 1014 | readonly renderMarkdown: (text: string) => ReactNode; |
| 1015 | readonly iconSubtleColor: string | import("react-native").ColorValue; | 1015 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 1016 | readonly screenColor: string; | 1016 | readonly screenColor: string; |
| 1017 | readonly userBubbleColor: string | import("react-native").ColorValue; | 1017 | readonly userBubbleColor: string | import("react-native").ColorValue; |
| ... | @@ -1481,7 +1481,7 @@ function renderFeedEntry( | ... | @@ -1481,7 +1481,7 @@ function renderFeedEntry( |
| 1481 | onCopyRow={props.onCopyWorkRow} | 1481 | onCopyRow={props.onCopyWorkRow} |
| 1482 | onToggleRow={props.onToggleWorkRow} | 1482 | onToggleRow={props.onToggleWorkRow} |
| 1483 | renderImage={props.renderViewedImage} | 1483 | renderImage={props.renderViewedImage} |
| 1484 | renderReasoning={props.renderReasoning} | 1484 | renderMarkdown={props.renderMarkdown} |
| 1485 | onPressPreview={props.onPressPreview} | 1485 | onPressPreview={props.onPressPreview} |
| 1486 | /> | 1486 | /> |
| 1487 | ); | 1487 | ); |
| ... | @@ -2013,7 +2013,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2013,7 +2013,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2013 | [props.environmentId, props.threadId, props.workspaceRoot], | 2013 | [props.environmentId, props.threadId, props.workspaceRoot], |
| 2014 | ); | 2014 | ); |
| 2015 | const markdownStyles = useMarkdownStyles(); | 2015 | const markdownStyles = useMarkdownStyles(); |
| 2016 | const renderReasoning = useCallback( | 2016 | const renderMarkdown = useCallback( |
| 2017 | (text: string) => ( | 2017 | (text: string) => ( |
| 2018 | <AssistantMarkdownContent | 2018 | <AssistantMarkdownContent |
| 2019 | markdown={text} | 2019 | markdown={text} |
| ... | @@ -2537,7 +2537,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2537,7 +2537,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2537 | markdownLinkHandlers, | 2537 | markdownLinkHandlers, |
| 2538 | renderMarkdownImage, | 2538 | renderMarkdownImage, |
| 2539 | renderViewedImage, | 2539 | renderViewedImage, |
| 2540 | renderReasoning, | 2540 | renderMarkdown, |
| 2541 | iconSubtleColor, | 2541 | iconSubtleColor, |
| 2542 | screenColor, | 2542 | screenColor, |
| 2543 | userBubbleColor, | 2543 | userBubbleColor, |
| ... | @@ -2603,7 +2603,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2603,7 +2603,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2603 | props.workspaceRoot, | 2603 | props.workspaceRoot, |
| 2604 | renderMarkdownImage, | 2604 | renderMarkdownImage, |
| 2605 | renderViewedImage, | 2605 | renderViewedImage, |
| 2606 | renderReasoning, | 2606 | renderMarkdown, |
| 2607 | ], | 2607 | ], |
| 2608 | ); | 2608 | ); |
| 2609 | 2609 |
apps/mobile/src/features/threads/thread-work-log.tsx+33-5| ... | @@ -487,7 +487,7 @@ interface ThreadWorkLogProps { | ... | @@ -487,7 +487,7 @@ interface ThreadWorkLogProps { |
| 487 | readonly onCopyRow: (rowId: string, value: string) => void; | 487 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 488 | readonly onToggleRow: (rowId: string, anchorKey: string) => void; | 488 | readonly onToggleRow: (rowId: string, anchorKey: string) => void; |
| 489 | readonly renderImage: MarkdownImageRenderer; | 489 | readonly renderImage: MarkdownImageRenderer; |
| 490 | readonly renderReasoning: (text: string) => ReactNode; | 490 | readonly renderMarkdown: (text: string) => ReactNode; |
| 491 | readonly onPressPreview: (source: FilePreviewSource) => void; | 491 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 492 | } | 492 | } |
| 493 | 493 | ||
| ... | @@ -505,7 +505,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { | ... | @@ -505,7 +505,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { |
| 505 | onCopyRow={props.onCopyRow} | 505 | onCopyRow={props.onCopyRow} |
| 506 | onToggleRow={props.onToggleRow} | 506 | onToggleRow={props.onToggleRow} |
| 507 | renderImage={props.renderImage} | 507 | renderImage={props.renderImage} |
| 508 | renderReasoning={props.renderReasoning} | 508 | renderMarkdown={props.renderMarkdown} |
| 509 | onPressPreview={props.onPressPreview} | 509 | onPressPreview={props.onPressPreview} |
| 510 | themeAppearance={props.themeAppearance} | 510 | themeAppearance={props.themeAppearance} |
| 511 | /> | 511 | /> |
| ... | @@ -519,7 +519,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { | ... | @@ -519,7 +519,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { |
| 519 | props.onCopyRow, | 519 | props.onCopyRow, |
| 520 | props.onToggleRow, | 520 | props.onToggleRow, |
| 521 | props.renderImage, | 521 | props.renderImage, |
| 522 | props.renderReasoning, | 522 | props.renderMarkdown, |
| 523 | props.onPressPreview, | 523 | props.onPressPreview, |
| 524 | props.themeAppearance, | 524 | props.themeAppearance, |
| 525 | ], | 525 | ], |
| ... | @@ -540,7 +540,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { | ... | @@ -540,7 +540,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { |
| 540 | return ( | 540 | return ( |
| 541 | <ScrollView nestedScrollEnabled className="ml-7 max-h-96 py-1"> | 541 | <ScrollView nestedScrollEnabled className="ml-7 max-h-96 py-1"> |
| 542 | {props.activities.map((row) => ( | 542 | {props.activities.map((row) => ( |
| 543 | <View key={row.id}>{props.renderReasoning(row.workEntry.detail ?? "")}</View> | 543 | <View key={row.id}>{props.renderMarkdown(row.workEntry.detail ?? "")}</View> |
| 544 | ))} | 544 | ))} |
| 545 | </ScrollView> | 545 | </ScrollView> |
| 546 | ); | 546 | ); |
| ... | @@ -907,6 +907,34 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -907,6 +907,34 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 907 | : null, | 907 | : null, |
| 908 | ); | 908 | ); |
| 909 | const failureItem = row.projectedItem.item; | 909 | const failureItem = row.projectedItem.item; |
| 910 | if (failureItem.type === "notification" && failureItem.source.kind === "system") { | ||
| 911 | const failed = failureItem.outcome === "failed"; | ||
| 912 | return ( | ||
| 913 | <View accessibilityLiveRegion="polite" className="py-1"> | ||
| 914 | <WorkLogPressable onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}> | ||
| 915 | <WorkLogIconSlot> | ||
| 916 | <WorkLogIcon | ||
| 917 | icon={failed ? "exclamationmark.circle" : "t3-code"} | ||
| 918 | color={props.iconSubtleColor} | ||
| 919 | colorClassName={failed ? "accent-danger-foreground" : undefined} | ||
| 920 | /> | ||
| 921 | </WorkLogIconSlot> | ||
| 922 | <Text | ||
| 923 | selectable | ||
| 924 | className={cn( | ||
| 925 | "min-w-0 flex-1 font-t3-medium text-sm text-foreground-muted", | ||
| 926 | failed && "text-adaptive-rose-600-400", | ||
| 927 | )} | ||
| 928 | > | ||
| 929 | {failureItem.summary} | ||
| 930 | </Text> | ||
| 931 | </WorkLogPressable> | ||
| 932 | {failureItem.detail ? ( | ||
| 933 | <View className="ml-7 pt-1">{props.renderMarkdown(failureItem.detail)}</View> | ||
| 934 | ) : null} | ||
| 935 | </View> | ||
| 936 | ); | ||
| 937 | } | ||
| 910 | if (failureItem.type === "error" && failureItem.status === "failed") { | 938 | if (failureItem.type === "error" && failureItem.status === "failed") { |
| 911 | const warning = failureItem.failure.class === "usage_limit"; | 939 | const warning = failureItem.failure.class === "usage_limit"; |
| 912 | const timestamp = new Date(row.createdAt); | 940 | const timestamp = new Date(row.createdAt); |
| ... | @@ -1224,7 +1252,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -1224,7 +1252,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 1224 | contentContainerStyle={{ paddingRight: 8 }} | 1252 | contentContainerStyle={{ paddingRight: 8 }} |
| 1225 | > | 1253 | > |
| 1226 | {reasoning ? ( | 1254 | {reasoning ? ( |
| 1227 | props.renderReasoning(reasoning.text) | 1255 | props.renderMarkdown(reasoning.text) |
| 1228 | ) : call ? ( | 1256 | ) : call ? ( |
| 1229 | [ | 1257 | [ |
| 1230 | call.command, | 1258 | call.command, |
apps/mobile/src/lib/threadActivity.test.ts+32-2| ... | @@ -85,7 +85,7 @@ it("shows only the structured path in expanded mobile read details", () => { | ... | @@ -85,7 +85,7 @@ it("shows only the structured path in expanded mobile read details", () => { |
| 85 | expect(withoutPath?.canExpand).toBe(false); | 85 | expect(withoutPath?.canExpand).toBe(false); |
| 86 | }); | 86 | }); |
| 87 | 87 | ||
| 88 | it("expands a T3 Code notice to the prompt the agent got", () => { | 88 | it("keeps system notices prominent for inline Markdown details", () => { |
| 89 | const item = { | 89 | const item = { |
| 90 | ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), | 90 | ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), |
| 91 | type: "notification", | 91 | type: "notification", |
| ... | @@ -99,10 +99,40 @@ it("expands a T3 Code notice to the prompt the agent got", () => { | ... | @@ -99,10 +99,40 @@ it("expands a T3 Code notice to the prompt the agent got", () => { |
| 99 | ); | 99 | ); |
| 100 | 100 | ||
| 101 | expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); | 101 | expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); |
| 102 | expect(activity?.canExpand).toBe(true); | 102 | expect(activity?.prominent).toBe(true); |
| 103 | expect(activity?.canExpand).toBe(false); | ||
| 103 | expect(activity?.getFullDetail()).toBe("Continue where you left off."); | 104 | expect(activity?.getFullDetail()).toBe("Continue where you left off."); |
| 104 | }); | 105 | }); |
| 105 | 106 | ||
| 107 | it("keeps a runless CI failure and its Mergebot link visible outside completed work", () => { | ||
| 108 | const item: OrchestrationV2TurnItem = { | ||
| 109 | ...base("ci-failure", "2026-06-20T00:00:03.000Z", 2), | ||
| 110 | runId: null, | ||
| 111 | type: "notification", | ||
| 112 | source: { kind: "system" }, | ||
| 113 | outcome: "failed", | ||
| 114 | summary: "CI failed for PR #42", | ||
| 115 | detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)", | ||
| 116 | }; | ||
| 117 | const feed = buildThreadFeed([projected(command(), 0), projected(item, 1)]); | ||
| 118 | const presented = deriveThreadFeedPresentation( | ||
| 119 | feed, | ||
| 120 | { | ||
| 121 | runId, | ||
| 122 | status: "completed", | ||
| 123 | startedAt: "2026-06-20T00:00:01.000Z", | ||
| 124 | completedAt: "2026-06-20T00:00:02.000Z", | ||
| 125 | }, | ||
| 126 | new Set(), | ||
| 127 | ); | ||
| 128 | const activity = presented | ||
| 129 | .flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])) | ||
| 130 | .find((entry) => entry.projectedItem.item.id === item.id); | ||
| 131 | expect(activity?.prominent).toBe(true); | ||
| 132 | expect(activity?.status).toBe("failure"); | ||
| 133 | expect(activity?.getFullDetail()).toBe(item.detail); | ||
| 134 | }); | ||
| 135 | |||
| 106 | it("labels file searches with the adapter title and its search target", () => { | 136 | it("labels file searches with the adapter title and its search target", () => { |
| 107 | const item: OrchestrationV2TurnItem = { | 137 | const item: OrchestrationV2TurnItem = { |
| 108 | ...base("file-search", "2026-06-20T00:00:03.000Z", 2), | 138 | ...base("file-search", "2026-06-20T00:00:03.000Z", 2), |
apps/mobile/src/lib/threadActivity.ts+2| ... | @@ -460,6 +460,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean { | ... | @@ -460,6 +460,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean { |
| 460 | item.type === "fork" || | 460 | item.type === "fork" || |
| 461 | item.type === "thread_created" || | 461 | item.type === "thread_created" || |
| 462 | item.type === "system_notice" || | 462 | item.type === "system_notice" || |
| 463 | (item.type === "notification" && item.source.kind === "system") || | ||
| 463 | // An answerable card: it must stand alone and never fold away with the run. | 464 | // An answerable card: it must stand alone and never fold away with the run. |
| 464 | item.type === "secret_request" | 465 | item.type === "secret_request" |
| 465 | ); | 466 | ); |
| ... | @@ -841,6 +842,7 @@ function toFeedActivity( | ... | @@ -841,6 +842,7 @@ function toFeedActivity( |
| 841 | detail, | 842 | detail, |
| 842 | canExpand: | 843 | canExpand: |
| 843 | !(item.type === "error" && item.status === "failed") && | 844 | !(item.type === "error" && item.status === "failed") && |
| 845 | !(item.type === "notification" && item.source.kind === "system") && | ||
| 844 | (readPaths | 846 | (readPaths |
| 845 | ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) | 847 | ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) |
| 846 | : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), | 848 | : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), |
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 46 | ...CLIENT_GUARDED_RPC_SCOPES, | 46 | ...CLIENT_GUARDED_RPC_SCOPES, |
| 47 | [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope, | 47 | [WS_METHODS.uiSettingsGet]: AuthOrchestrationReadScope, |
| 48 | [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope, | 48 | [WS_METHODS.uiSettingsSubscribe]: AuthOrchestrationReadScope, |
| 49 | [WS_METHODS.managedWorkspacesStatus]: AuthFilesystemReadScope, | ||
| 49 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, | 50 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, |
| 50 | [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, | 51 | [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, |
| 51 | [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, | 52 | [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, |
apps/server/src/mcp/toolkits/thread/tools.ts+1-1| ... | @@ -30,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; | ... | @@ -30,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 30 | 30 | ||
| 31 | const ThreadOrganizeTool = Tool.make("t3_thread_organize", { | 31 | const ThreadOrganizeTool = Tool.make("t3_thread_organize", { |
| 32 | description: | 32 | description: |
| 33 | "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", | 33 | "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Settling closes this chat's terminals and browser tabs, stops their running commands and media, and preserves conversation history. Unsettling cannot restore running processes or page state; tell the user before settling work with open resources. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.", |
| 34 | parameters: Schema.Struct({ | 34 | parameters: Schema.Struct({ |
| 35 | threadId: Schema.optional(ThreadId), | 35 | threadId: Schema.optional(ThreadId), |
| 36 | action: Schema.Literals([ | 36 | action: Schema.Literals([ |
apps/server/src/observability/RpcInstrumentation.ts+3| ... | @@ -142,6 +142,9 @@ const RPC_AGGREGATES = { | ... | @@ -142,6 +142,9 @@ const RPC_AGGREGATES = { |
| 142 | [WS_METHODS.subscribeProjectClones]: "source-control", | 142 | [WS_METHODS.subscribeProjectClones]: "source-control", |
| 143 | [WS_METHODS.projectsListEntries]: "workspace", | 143 | [WS_METHODS.projectsListEntries]: "workspace", |
| 144 | [WS_METHODS.devServerStatus]: "workspace", | 144 | [WS_METHODS.devServerStatus]: "workspace", |
| 145 | [WS_METHODS.managedWorkspacesStatus]: "workspace", | ||
| 146 | [WS_METHODS.managedWorkspacesCreate]: "workspace", | ||
| 147 | [WS_METHODS.managedWorkspacesArchiveCompleted]: "workspace", | ||
| 145 | [WS_METHODS.devServerStart]: "workspace", | 148 | [WS_METHODS.devServerStart]: "workspace", |
| 146 | [WS_METHODS.projectsWatch]: "workspace", | 149 | [WS_METHODS.projectsWatch]: "workspace", |
| 147 | [WS_METHODS.projectsReadFile]: "workspace", | 150 | [WS_METHODS.projectsReadFile]: "workspace", |
apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts+273| ... | @@ -23,6 +23,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -23,6 +23,7 @@ import * as Effect from "effect/Effect"; |
| 23 | import * as Layer from "effect/Layer"; | 23 | import * as Layer from "effect/Layer"; |
| 24 | import * as SqlClient from "effect/sql/SqlClient"; | 24 | import * as SqlClient from "effect/sql/SqlClient"; |
| 25 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 25 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 26 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; | ||
| 26 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 27 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 27 | import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; | 28 | import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; |
| 28 | import * as EventSink from "./EventSink.ts"; | 29 | import * as EventSink from "./EventSink.ts"; |
| ... | @@ -44,6 +45,7 @@ const adapter = { | ... | @@ -44,6 +45,7 @@ const adapter = { |
| 44 | const layerDatabase = SqlitePersistence.layerMemory; | 45 | const layerDatabase = SqlitePersistence.layerMemory; |
| 45 | const layerTest = Layer.mergeAll( | 46 | const layerTest = Layer.mergeAll( |
| 46 | layerDatabase, | 47 | layerDatabase, |
| 48 | OrchestrationEventStore.layer.pipe(Layer.provide(layerDatabase)), | ||
| 47 | ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), | 49 | ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), |
| 48 | ProviderReplayHarness.layerWithRegistry( | 50 | ProviderReplayHarness.layerWithRegistry( |
| 49 | { name: "control-reads" }, | 51 | { name: "control-reads" }, |
| ... | @@ -52,6 +54,68 @@ const layerTest = Layer.mergeAll( | ... | @@ -52,6 +54,68 @@ const layerTest = Layer.mergeAll( |
| 52 | ), | 54 | ), |
| 53 | ); | 55 | ); |
| 54 | 56 | ||
| 57 | it.effect("cancels only the automatic settlement that observed newly opened resources", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 60 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 61 | for (const action of ["none", "manual-settle", "revive"] as const) { | ||
| 62 | const threadId = ThreadId.make(`thread:auto-settle-resources:${action}`); | ||
| 63 | yield* orchestrator.dispatch({ | ||
| 64 | type: "thread.create", | ||
| 65 | commandId: CommandId.make(`create-auto-settle-resources:${action}`), | ||
| 66 | threadId, | ||
| 67 | projectId: ProjectId.make("project:auto-settle-resources"), | ||
| 68 | title: "Resource settlement race", | ||
| 69 | modelSelection, | ||
| 70 | runtimeMode: "full-access", | ||
| 71 | interactionMode: "default", | ||
| 72 | branch: null, | ||
| 73 | worktreePath: null, | ||
| 74 | createdBy: "user", | ||
| 75 | creationSource: "web", | ||
| 76 | }); | ||
| 77 | const before = yield* projections.getThread(threadId); | ||
| 78 | const automatic = yield* orchestrator.dispatch({ | ||
| 79 | type: "thread.auto-settle", | ||
| 80 | commandId: CommandId.make(`server:auto-settle:${threadId}:1`), | ||
| 81 | threadId, | ||
| 82 | snapshotAt: before.updatedAt, | ||
| 83 | }); | ||
| 84 | const settledAt = (yield* projections.getThread(threadId)).settledAt; | ||
| 85 | if (action !== "none") { | ||
| 86 | yield* orchestrator.dispatch( | ||
| 87 | action === "manual-settle" | ||
| 88 | ? { | ||
| 89 | type: "thread.settle", | ||
| 90 | commandId: CommandId.make(`manual-settle-resources:${action}`), | ||
| 91 | threadId, | ||
| 92 | } | ||
| 93 | : { | ||
| 94 | type: "thread.unsettle", | ||
| 95 | commandId: CommandId.make(`revive-resources:${action}`), | ||
| 96 | threadId, | ||
| 97 | reason: "user", | ||
| 98 | }, | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | const result = yield* orchestrator | ||
| 102 | .dispatch({ | ||
| 103 | type: "thread.unsettle", | ||
| 104 | commandId: CommandId.make(`server:auto-settle-resources:${threadId}:1`), | ||
| 105 | threadId, | ||
| 106 | reason: "user", | ||
| 107 | snapshotSequence: automatic.sequence, | ||
| 108 | }) | ||
| 109 | .pipe(Effect.exit); | ||
| 110 | |||
| 111 | assert.equal(result._tag, action === "none" ? "Success" : "Failure"); | ||
| 112 | const after = yield* projections.getThread(threadId); | ||
| 113 | assert.equal(after.settledOverride, action === "manual-settle" ? "settled" : "active"); | ||
| 114 | if (action === "manual-settle") assert.deepStrictEqual(after.settledAt, settledAt); | ||
| 115 | } | ||
| 116 | }).pipe(Effect.provide(layerTest)), | ||
| 117 | ); | ||
| 118 | |||
| 55 | it.effect( | 119 | it.effect( |
| 56 | "dispatches metadata, queue resume and request controls without hydrating unrelated history", | 120 | "dispatches metadata, queue resume and request controls without hydrating unrelated history", |
| 57 | () => | 121 | () => |
| ... | @@ -960,3 +1024,212 @@ it.effect("wakes the parent once when its delegated task asks a question", () => | ... | @@ -960,3 +1024,212 @@ it.effect("wakes the parent once when its delegated task asks a question", () => |
| 960 | }); | 1024 | }); |
| 961 | }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), | 1025 | }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), |
| 962 | ); | 1026 | ); |
| 1027 | |||
| 1028 | it.effect("guarded archive preserves a resumed thread and rejects an earlier settlement", () => | ||
| 1029 | Effect.gen(function* () { | ||
| 1030 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 1031 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 1032 | const threadId = ThreadId.make("thread:archive-race"); | ||
| 1033 | yield* orchestrator.dispatch({ | ||
| 1034 | type: "thread.create", | ||
| 1035 | commandId: CommandId.make("create-archive-race"), | ||
| 1036 | threadId, | ||
| 1037 | projectId: ProjectId.make("project:archive-race"), | ||
| 1038 | title: "Work", | ||
| 1039 | modelSelection, | ||
| 1040 | runtimeMode: "full-access", | ||
| 1041 | interactionMode: "default", | ||
| 1042 | branch: null, | ||
| 1043 | worktreePath: null, | ||
| 1044 | createdBy: "user", | ||
| 1045 | creationSource: "web", | ||
| 1046 | }); | ||
| 1047 | yield* orchestrator.dispatch({ | ||
| 1048 | type: "thread.settle", | ||
| 1049 | commandId: CommandId.make("settle-archive-race"), | ||
| 1050 | threadId, | ||
| 1051 | }); | ||
| 1052 | const { snapshotSequence } = yield* orchestrator.getShellSnapshot(); | ||
| 1053 | const originalSettledAt = (yield* projections.getThread(threadId)).settledAt; | ||
| 1054 | yield* orchestrator.dispatch({ | ||
| 1055 | type: "thread.settle", | ||
| 1056 | commandId: CommandId.make("duplicate-settle-archive-race"), | ||
| 1057 | threadId, | ||
| 1058 | }); | ||
| 1059 | yield* orchestrator.dispatch({ | ||
| 1060 | type: "thread.unsettle", | ||
| 1061 | commandId: CommandId.make("resume-archive-race"), | ||
| 1062 | threadId, | ||
| 1063 | reason: "user", | ||
| 1064 | }); | ||
| 1065 | const resumed = yield* orchestrator | ||
| 1066 | .dispatch({ | ||
| 1067 | type: "thread.archive", | ||
| 1068 | commandId: CommandId.make("archive-resumed"), | ||
| 1069 | threadId, | ||
| 1070 | snapshotSequence, | ||
| 1071 | }) | ||
| 1072 | .pipe(Effect.flip); | ||
| 1073 | assert.include(resumed, { | ||
| 1074 | _tag: "OrchestratorDispatchError", | ||
| 1075 | reason: "archive-condition-changed", | ||
| 1076 | }); | ||
| 1077 | assert.isNull((yield* projections.getThread(threadId)).archivedAt); | ||
| 1078 | |||
| 1079 | yield* orchestrator.dispatch({ | ||
| 1080 | type: "thread.settle", | ||
| 1081 | commandId: CommandId.make("resettle-archive-race"), | ||
| 1082 | threadId, | ||
| 1083 | }); | ||
| 1084 | assert.deepEqual((yield* projections.getThread(threadId)).settledAt, originalSettledAt); | ||
| 1085 | const stale = yield* orchestrator | ||
| 1086 | .dispatch({ | ||
| 1087 | type: "thread.archive", | ||
| 1088 | commandId: CommandId.make("archive-previous-settlement"), | ||
| 1089 | threadId, | ||
| 1090 | snapshotSequence, | ||
| 1091 | }) | ||
| 1092 | .pipe(Effect.flip); | ||
| 1093 | assert.include(stale, { | ||
| 1094 | _tag: "OrchestratorDispatchError", | ||
| 1095 | reason: "archive-condition-changed", | ||
| 1096 | }); | ||
| 1097 | assert.isNull((yield* projections.getThread(threadId)).archivedAt); | ||
| 1098 | |||
| 1099 | const beforeRename = yield* orchestrator.getShellSnapshot(); | ||
| 1100 | yield* orchestrator.dispatch({ | ||
| 1101 | type: "thread.metadata.update", | ||
| 1102 | commandId: CommandId.make("rename-archive-race"), | ||
| 1103 | threadId, | ||
| 1104 | title: "Changed", | ||
| 1105 | }); | ||
| 1106 | const renamed = yield* orchestrator | ||
| 1107 | .dispatch({ | ||
| 1108 | type: "thread.archive", | ||
| 1109 | commandId: CommandId.make("archive-before-rename"), | ||
| 1110 | threadId, | ||
| 1111 | snapshotSequence: beforeRename.snapshotSequence, | ||
| 1112 | }) | ||
| 1113 | .pipe(Effect.flip); | ||
| 1114 | assert.include(renamed, { | ||
| 1115 | _tag: "OrchestratorDispatchError", | ||
| 1116 | reason: "archive-condition-changed", | ||
| 1117 | }); | ||
| 1118 | const current = yield* orchestrator.getShellSnapshot(); | ||
| 1119 | yield* orchestrator.dispatch({ | ||
| 1120 | type: "thread.create", | ||
| 1121 | commandId: CommandId.make("create-unrelated-archive-race"), | ||
| 1122 | threadId: ThreadId.make("thread:unrelated-archive-race"), | ||
| 1123 | projectId: ProjectId.make("project:archive-race"), | ||
| 1124 | title: "Unrelated", | ||
| 1125 | modelSelection, | ||
| 1126 | runtimeMode: "full-access", | ||
| 1127 | interactionMode: "default", | ||
| 1128 | branch: null, | ||
| 1129 | worktreePath: null, | ||
| 1130 | createdBy: "user", | ||
| 1131 | creationSource: "web", | ||
| 1132 | }); | ||
| 1133 | yield* orchestrator.dispatch({ | ||
| 1134 | type: "thread.archive", | ||
| 1135 | commandId: CommandId.make("archive-current-settlement"), | ||
| 1136 | threadId, | ||
| 1137 | snapshotSequence: current.snapshotSequence, | ||
| 1138 | }); | ||
| 1139 | assert.isNotNull((yield* projections.getThread(threadId)).archivedAt); | ||
| 1140 | }).pipe(Effect.provide(layerTest)), | ||
| 1141 | ); | ||
| 1142 | |||
| 1143 | it.effect("guarded archive retains threads with active, queued, or pending request work", () => | ||
| 1144 | Effect.gen(function* () { | ||
| 1145 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 1146 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 1147 | const now = yield* DateTime.now; | ||
| 1148 | for (const status of [ | ||
| 1149 | "queued", | ||
| 1150 | "preparing", | ||
| 1151 | "starting", | ||
| 1152 | "running", | ||
| 1153 | "waiting", | ||
| 1154 | "request", | ||
| 1155 | ] as const) { | ||
| 1156 | const threadId = ThreadId.make(`thread:archive-blocked:${status}`); | ||
| 1157 | yield* orchestrator.dispatch({ | ||
| 1158 | type: "thread.create", | ||
| 1159 | commandId: CommandId.make(`create-archive-blocked:${status}`), | ||
| 1160 | threadId, | ||
| 1161 | projectId: ProjectId.make("project:archive-blocked"), | ||
| 1162 | title: "Work", | ||
| 1163 | modelSelection, | ||
| 1164 | runtimeMode: "full-access", | ||
| 1165 | interactionMode: "default", | ||
| 1166 | branch: null, | ||
| 1167 | worktreePath: null, | ||
| 1168 | createdBy: "user", | ||
| 1169 | creationSource: "web", | ||
| 1170 | }); | ||
| 1171 | yield* orchestrator.dispatch({ | ||
| 1172 | type: "thread.settle", | ||
| 1173 | commandId: CommandId.make(`settle-archive-blocked:${status}`), | ||
| 1174 | threadId, | ||
| 1175 | }); | ||
| 1176 | if (status === "request") { | ||
| 1177 | yield* projections.apply({ | ||
| 1178 | id: EventId.make("archive-blocked:request"), | ||
| 1179 | type: "runtime-request.updated", | ||
| 1180 | threadId, | ||
| 1181 | occurredAt: now, | ||
| 1182 | payload: { | ||
| 1183 | id: RuntimeRequestId.make("archive-blocked:request"), | ||
| 1184 | nodeId: NodeId.make("archive-blocked:request"), | ||
| 1185 | providerTurnId: null, | ||
| 1186 | nativeRequestRef: null, | ||
| 1187 | kind: "user_input", | ||
| 1188 | status: "pending", | ||
| 1189 | responseCapability: { type: "message" }, | ||
| 1190 | createdAt: now, | ||
| 1191 | resolvedAt: null, | ||
| 1192 | }, | ||
| 1193 | }); | ||
| 1194 | } else { | ||
| 1195 | yield* projections.apply({ | ||
| 1196 | id: EventId.make(`archive-blocked:run:${status}`), | ||
| 1197 | type: "run.created", | ||
| 1198 | threadId, | ||
| 1199 | occurredAt: now, | ||
| 1200 | payload: { | ||
| 1201 | id: RunId.make(`archive-blocked:run:${status}`), | ||
| 1202 | threadId, | ||
| 1203 | ordinal: 1, | ||
| 1204 | providerInstanceId: instanceId, | ||
| 1205 | modelSelection, | ||
| 1206 | providerThreadId: null, | ||
| 1207 | userMessageId: MessageId.make(`archive-blocked:message:${status}`), | ||
| 1208 | rootNodeId: null, | ||
| 1209 | activeAttemptId: null, | ||
| 1210 | status, | ||
| 1211 | requestedAt: now, | ||
| 1212 | startedAt: null, | ||
| 1213 | completedAt: null, | ||
| 1214 | checkpointId: null, | ||
| 1215 | contextHandoffId: null, | ||
| 1216 | }, | ||
| 1217 | }); | ||
| 1218 | } | ||
| 1219 | const snapshot = yield* orchestrator.getShellSnapshot(); | ||
| 1220 | const exit = yield* orchestrator | ||
| 1221 | .dispatch({ | ||
| 1222 | type: "thread.archive", | ||
| 1223 | commandId: CommandId.make(`archive-blocked:${status}`), | ||
| 1224 | threadId, | ||
| 1225 | snapshotSequence: snapshot.snapshotSequence, | ||
| 1226 | }) | ||
| 1227 | .pipe(Effect.flip); | ||
| 1228 | assert.include(exit, { | ||
| 1229 | _tag: "OrchestratorDispatchError", | ||
| 1230 | reason: "archive-condition-changed", | ||
| 1231 | }); | ||
| 1232 | assert.isNull((yield* projections.getThread(threadId)).archivedAt); | ||
| 1233 | } | ||
| 1234 | }).pipe(Effect.provide(layerTest)), | ||
| 1235 | ); |
apps/server/src/orchestration-v2/Orchestrator.ts+122-107| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { isManagedWorkspaceArchiveCandidate } from "../workspace/managedWorkspaceArchive.ts"; | ||
| 1 | import type { | 2 | import type { |
| 2 | OrchestrationV2SearchThreadInput, | 3 | OrchestrationV2SearchThreadInput, |
| 3 | OrchestrationV2SearchThreadResult, | 4 | OrchestrationV2SearchThreadResult, |
| ... | @@ -142,6 +143,7 @@ export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDi | ... | @@ -142,6 +143,7 @@ export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDi |
| 142 | { | 143 | { |
| 143 | commandId: CommandId, | 144 | commandId: CommandId, |
| 144 | commandType: Schema.String, | 145 | commandType: Schema.String, |
| 146 | reason: Schema.optional(Schema.Literal("archive-condition-changed")), | ||
| 145 | cause: Schema.optional(Schema.Defect()), | 147 | cause: Schema.optional(Schema.Defect()), |
| 146 | }, | 148 | }, |
| 147 | ) { | 149 | ) { |
| ... | @@ -452,6 +454,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { | ... | @@ -452,6 +454,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { |
| 452 | case "thread.delete": | 454 | case "thread.delete": |
| 453 | case "thread.settle": | 455 | case "thread.settle": |
| 454 | case "thread.auto-settle": | 456 | case "thread.auto-settle": |
| 457 | case "thread.ci-failed": | ||
| 455 | case "thread.unsettle": | 458 | case "thread.unsettle": |
| 456 | case "thread.snooze": | 459 | case "thread.snooze": |
| 457 | case "thread.unsnooze": | 460 | case "thread.unsnooze": |
| ... | @@ -2470,6 +2473,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2470,6 +2473,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2470 | return yield* new OrchestratorDispatchError({ | 2473 | return yield* new OrchestratorDispatchError({ |
| 2471 | commandId: command.commandId, | 2474 | commandId: command.commandId, |
| 2472 | commandType: command.type, | 2475 | commandType: command.type, |
| 2476 | ...(command.type === "thread.archive" && command.snapshotSequence !== undefined | ||
| 2477 | ? { reason: "archive-condition-changed" as const } | ||
| 2478 | : {}), | ||
| 2473 | cause: `Thread ${command.threadId} is deleted.`, | 2479 | cause: `Thread ${command.threadId} is deleted.`, |
| 2474 | }); | 2480 | }); |
| 2475 | } | 2481 | } |
| ... | @@ -2553,6 +2559,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2553,6 +2559,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2553 | cause: `Thread ${command.threadId} is no longer empty.`, | 2559 | cause: `Thread ${command.threadId} is no longer empty.`, |
| 2554 | }); | 2560 | }); |
| 2555 | } | 2561 | } |
| 2562 | if (command.type === "thread.unsettle" && command.snapshotSequence !== undefined) { | ||
| 2563 | const sequence = yield* eventSink | ||
| 2564 | .latestSequence({ threadId: command.threadId }) | ||
| 2565 | .pipe(mapDispatchError(command)); | ||
| 2566 | if (thread.settledOverride !== "settled" || sequence > command.snapshotSequence) { | ||
| 2567 | return yield* new OrchestratorDispatchError({ | ||
| 2568 | commandId: command.commandId, | ||
| 2569 | commandType: command.type, | ||
| 2570 | cause: `Thread ${command.threadId} changed before automatic settlement could be cancelled.`, | ||
| 2571 | }); | ||
| 2572 | } | ||
| 2573 | } | ||
| 2574 | if (command.type === "thread.archive" && command.snapshotSequence !== undefined) { | ||
| 2575 | const shell = yield* projectionStore | ||
| 2576 | .getThreadShell(command.threadId) | ||
| 2577 | .pipe(mapDispatchError(command)); | ||
| 2578 | const records = yield* projectionStore | ||
| 2579 | .getThreadRecords(command.threadId, ["runs", "runtimeRequests"]) | ||
| 2580 | .pipe(mapDispatchError(command)); | ||
| 2581 | const sequence = yield* eventSink | ||
| 2582 | .latestSequence({ threadId: command.threadId }) | ||
| 2583 | .pipe(mapDispatchError(command)); | ||
| 2584 | if ( | ||
| 2585 | shell === null || | ||
| 2586 | !isManagedWorkspaceArchiveCandidate(shell) || | ||
| 2587 | sequence > command.snapshotSequence || | ||
| 2588 | records.runs.some((run) => | ||
| 2589 | ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), | ||
| 2590 | ) || | ||
| 2591 | records.runtimeRequests.some((request) => request.status === "pending") | ||
| 2592 | ) { | ||
| 2593 | return yield* new OrchestratorDispatchError({ | ||
| 2594 | commandId: command.commandId, | ||
| 2595 | commandType: command.type, | ||
| 2596 | reason: "archive-condition-changed", | ||
| 2597 | cause: `Thread ${command.threadId} changed before automatic archival.`, | ||
| 2598 | }); | ||
| 2599 | } | ||
| 2600 | } | ||
| 2556 | if (command.type === "thread.archive" && thread.archivedAt !== null) { | 2601 | if (command.type === "thread.archive" && thread.archivedAt !== null) { |
| 2557 | return yield* new OrchestratorDispatchError({ | 2602 | return yield* new OrchestratorDispatchError({ |
| 2558 | commandId: command.commandId, | 2603 | commandId: command.commandId, |
| ... | @@ -10863,6 +10908,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -10863,6 +10908,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10863 | case "thread.visit": | 10908 | case "thread.visit": |
| 10864 | yield* dispatchThreadVisit(command, events); | 10909 | yield* dispatchThreadVisit(command, events); |
| 10865 | break; | 10910 | break; |
| 10911 | case "thread.ci-failed": { | ||
| 10912 | const thread = yield* projectionStore | ||
| 10913 | .getThreadShell(command.threadId) | ||
| 10914 | .pipe(mapDispatchError(command)); | ||
| 10915 | const sequence = yield* eventSink | ||
| 10916 | .latestSequence({ threadId: command.threadId }) | ||
| 10917 | .pipe(mapDispatchError(command)); | ||
| 10918 | const records = yield* projectionStore | ||
| 10919 | .getThreadRecords(command.threadId, ["runs", "runtimeRequests"]) | ||
| 10920 | .pipe(mapDispatchError(command)); | ||
| 10921 | if ( | ||
| 10922 | thread === null || | ||
| 10923 | !isManagedWorkspaceArchiveCandidate(thread) || | ||
| 10924 | sequence > command.snapshotSequence || | ||
| 10925 | records.runs.some((run) => | ||
| 10926 | ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), | ||
| 10927 | ) || | ||
| 10928 | records.runtimeRequests.some((request) => request.status === "pending") | ||
| 10929 | ) { | ||
| 10930 | return yield* new OrchestratorDispatchError({ | ||
| 10931 | commandId: command.commandId, | ||
| 10932 | commandType: command.type, | ||
| 10933 | cause: "The thread changed before the CI failure was recorded.", | ||
| 10934 | }); | ||
| 10935 | } | ||
| 10936 | if ( | ||
| 10937 | yield* projectionStore | ||
| 10938 | .getTurnItem({ threadId: command.threadId, itemId: command.itemId }) | ||
| 10939 | .pipe(mapDispatchError(command)) | ||
| 10940 | ) | ||
| 10941 | break; | ||
| 10942 | const now = yield* DateTime.now; | ||
| 10943 | yield* dispatchThreadMutation( | ||
| 10944 | { | ||
| 10945 | type: "thread.unsettle", | ||
| 10946 | commandId: command.commandId, | ||
| 10947 | threadId: command.threadId, | ||
| 10948 | reason: "user", | ||
| 10949 | }, | ||
| 10950 | events, | ||
| 10951 | effects, | ||
| 10952 | ); | ||
| 10953 | const ordinal = yield* projectionStore | ||
| 10954 | .getNextTurnItemOrdinal(command.threadId) | ||
| 10955 | .pipe(mapDispatchError(command)); | ||
| 10956 | yield* emit( | ||
| 10957 | events, | ||
| 10958 | command, | ||
| 10959 | )({ | ||
| 10960 | type: "turn-item.updated", | ||
| 10961 | threadId: command.threadId, | ||
| 10962 | occurredAt: now, | ||
| 10963 | payload: { | ||
| 10964 | type: "notification", | ||
| 10965 | source: { kind: "system" }, | ||
| 10966 | outcome: "failed", | ||
| 10967 | summary: `CI failed for PR #${command.pullRequestNumber}`, | ||
| 10968 | detail: `[Open PR #${command.pullRequestNumber} in Mergebot](<${command.url}>)`, | ||
| 10969 | id: command.itemId, | ||
| 10970 | threadId: command.threadId, | ||
| 10971 | runId: null, | ||
| 10972 | nodeId: null, | ||
| 10973 | providerThreadId: null, | ||
| 10974 | providerTurnId: null, | ||
| 10975 | nativeItemRef: null, | ||
| 10976 | parentItemId: null, | ||
| 10977 | ordinal, | ||
| 10978 | status: "completed", | ||
| 10979 | title: null, | ||
| 10980 | startedAt: now, | ||
| 10981 | completedAt: now, | ||
| 10982 | updatedAt: now, | ||
| 10983 | }, | ||
| 10984 | }); | ||
| 10985 | break; | ||
| 10986 | } | ||
| 10866 | case "thread.auto-settle": { | 10987 | case "thread.auto-settle": { |
| 10867 | // Automatic settlement (#8600): the sweep evaluated a shell snapshot, | 10988 | // Automatic settlement (#8600): the sweep evaluated a shell snapshot, |
| 10868 | // so re-check against the live thread before settling. Any change | 10989 | // so re-check against the live thread before settling. Any change |
| ... | @@ -11212,6 +11333,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -11212,6 +11333,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 11212 | planned.events.length > 0 || | 11333 | planned.events.length > 0 || |
| 11213 | command.type === "thread.background-work.settle" || | 11334 | command.type === "thread.background-work.settle" || |
| 11214 | command.type === "thread.stop" || | 11335 | command.type === "thread.stop" || |
| 11336 | command.type === "thread.ci-failed" || | ||
| 11215 | command.type === "queued-message.promote-to-steer" || | 11337 | command.type === "queued-message.promote-to-steer" || |
| 11216 | command.type === "subagent.stop" | 11338 | command.type === "subagent.stop" |
| 11217 | ? Effect.succeed(planned) | 11339 | ? Effect.succeed(planned) |
| ... | @@ -11724,110 +11846,3 @@ export const layer: Layer.Layer< | ... | @@ -11724,110 +11846,3 @@ export const layer: Layer.Layer< |
| 11724 | > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( | 11846 | > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( |
| 11725 | Layer.provide(ThreadCommandExecutor.layer), | 11847 | Layer.provide(ThreadCommandExecutor.layer), |
| 11726 | ); | 11848 | ); |
| 11727 | |||
| 11728 | const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed( | ||
| 11729 | OrchestratorV2, | ||
| 11730 | OrchestratorV2.of({ | ||
| 11731 | resumeQueuedRuns: Effect.fail( | ||
| 11732 | new OrchestratorDispatchError({ | ||
| 11733 | commandId: CommandId.make("command:system:resume-queued-runs"), | ||
| 11734 | commandType: "message.dispatch", | ||
| 11735 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11736 | }), | ||
| 11737 | ), | ||
| 11738 | recoverDelegatedTasks: Effect.void, | ||
| 11739 | recoverDelegatedTask: () => Effect.void, | ||
| 11740 | delegatedTaskResultPending: () => Effect.succeed(false), | ||
| 11741 | restartContinuationPending: () => Effect.succeed(false), | ||
| 11742 | awaitRestartContinuation: () => Effect.void, | ||
| 11743 | dispatch: (command) => | ||
| 11744 | Effect.fail( | ||
| 11745 | new OrchestratorDispatchError({ | ||
| 11746 | commandId: command.commandId, | ||
| 11747 | commandType: command.type, | ||
| 11748 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11749 | }), | ||
| 11750 | ), | ||
| 11751 | searchThreadStream: (input) => | ||
| 11752 | Stream.fail(new OrchestratorProjectionError({ threadId: input.threadId })), | ||
| 11753 | searchThread: (input) => | ||
| 11754 | Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })), | ||
| 11755 | getThreadHistoryPage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | ||
| 11756 | getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | ||
| 11757 | getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | ||
| 11758 | getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), | ||
| 11759 | getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | ||
| 11760 | getThreadProjection: (threadId) => | ||
| 11761 | Effect.fail( | ||
| 11762 | new OrchestratorProjectionError({ | ||
| 11763 | threadId, | ||
| 11764 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11765 | }), | ||
| 11766 | ), | ||
| 11767 | getCheckpointContext: (threadId) => | ||
| 11768 | Effect.fail( | ||
| 11769 | new OrchestratorProjectionError({ | ||
| 11770 | threadId, | ||
| 11771 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11772 | }), | ||
| 11773 | ), | ||
| 11774 | getThreadSnapshot: (threadId) => | ||
| 11775 | Effect.fail( | ||
| 11776 | new OrchestratorProjectionError({ | ||
| 11777 | threadId, | ||
| 11778 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11779 | }), | ||
| 11780 | ), | ||
| 11781 | getThreadSnapshotWindow: (threadId) => | ||
| 11782 | Effect.fail( | ||
| 11783 | new OrchestratorProjectionError({ | ||
| 11784 | threadId, | ||
| 11785 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11786 | }), | ||
| 11787 | ), | ||
| 11788 | getShellSnapshot: () => | ||
| 11789 | Effect.fail( | ||
| 11790 | new OrchestratorProjectionError({ | ||
| 11791 | threadId: ThreadId.make("thread:shell"), | ||
| 11792 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11793 | }), | ||
| 11794 | ), | ||
| 11795 | readShellSnapshot: () => | ||
| 11796 | Effect.fail( | ||
| 11797 | new OrchestratorProjectionError({ | ||
| 11798 | threadId: ThreadId.make("thread:shell"), | ||
| 11799 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11800 | }), | ||
| 11801 | ), | ||
| 11802 | getThreadShell: (threadId) => | ||
| 11803 | Effect.fail( | ||
| 11804 | new OrchestratorProjectionError({ | ||
| 11805 | threadId, | ||
| 11806 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11807 | }), | ||
| 11808 | ), | ||
| 11809 | getThreadEventSequence: (threadId) => | ||
| 11810 | Effect.fail( | ||
| 11811 | new OrchestratorProjectionError({ | ||
| 11812 | threadId, | ||
| 11813 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11814 | }), | ||
| 11815 | ), | ||
| 11816 | streamStoredEvents: Stream.fail( | ||
| 11817 | new OrchestratorDomainEventStreamError({ | ||
| 11818 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11819 | }), | ||
| 11820 | ), | ||
| 11821 | streamStoredEventsFrom: () => | ||
| 11822 | Stream.fail( | ||
| 11823 | new OrchestratorDomainEventStreamError({ | ||
| 11824 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11825 | }), | ||
| 11826 | ), | ||
| 11827 | streamDomainEvents: Stream.fail( | ||
| 11828 | new OrchestratorDomainEventStreamError({ | ||
| 11829 | cause: "Orchestration V2 live runtime is not configured.", | ||
| 11830 | }), | ||
| 11831 | ), | ||
| 11832 | } satisfies OrchestratorV2Shape), | ||
| 11833 | ); |
apps/server/src/orchestration-v2/ProjectionStore.ts+17-1| ... | @@ -370,6 +370,7 @@ export interface ShellSnapshotOptions { | ... | @@ -370,6 +370,7 @@ export interface ShellSnapshotOptions { |
| 370 | * their run, item or session rows are read. | 370 | * their run, item or session rows are read. |
| 371 | */ | 371 | */ |
| 372 | readonly unsettledOnly?: boolean; | 372 | readonly unsettledOnly?: boolean; |
| 373 | readonly settledOnly?: boolean; | ||
| 373 | } | 374 | } |
| 374 | 375 | ||
| 375 | export interface ProjectionStoreV2Shape { | 376 | export interface ProjectionStoreV2Shape { |
| ... | @@ -5308,7 +5309,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5308,7 +5309,12 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5308 | 5309 | ||
| 5309 | const selectShellThreadRows = ( | 5310 | const selectShellThreadRows = ( |
| 5310 | threadId?: ThreadId, | 5311 | threadId?: ThreadId, |
| 5311 | { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, | 5312 | { |
| 5313 | location, | ||
| 5314 | projectId, | ||
| 5315 | unsettledOnly = false, | ||
| 5316 | settledOnly = false, | ||
| 5317 | }: ShellSnapshotOptions = {}, | ||
| 5312 | ) => | 5318 | ) => |
| 5313 | sql<ShellThreadRow>` | 5319 | sql<ShellThreadRow>` |
| 5314 | SELECT | 5320 | SELECT |
| ... | @@ -5494,6 +5500,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5494,6 +5500,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5494 | unsettledOnly | 5500 | unsettledOnly |
| 5495 | ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` | 5501 | ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` |
| 5496 | : sql`` | 5502 | : sql`` |
| 5503 | }${ | ||
| 5504 | settledOnly | ||
| 5505 | ? sql` AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL` | ||
| 5506 | : sql`` | ||
| 5497 | } | 5507 | } |
| 5498 | ORDER BY t.updated_at ASC, t.thread_id ASC | 5508 | ORDER BY t.updated_at ASC, t.thread_id ASC |
| 5499 | `; | 5509 | `; |
| ... | @@ -6196,6 +6206,12 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( | ... | @@ -6196,6 +6206,12 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 6196 | ) { | 6206 | ) { |
| 6197 | return false; | 6207 | return false; |
| 6198 | } | 6208 | } |
| 6209 | if ( | ||
| 6210 | options?.settledOnly && | ||
| 6211 | (projection.thread.settledOverride !== "settled" || | ||
| 6212 | projection.thread.settledAt === null) | ||
| 6213 | ) | ||
| 6214 | return false; | ||
| 6199 | if (options?.location === "active") return projection.thread.archivedAt === null; | 6215 | if (options?.location === "active") return projection.thread.archivedAt === null; |
| 6200 | if (options?.location === "archive") return projection.thread.archivedAt !== null; | 6216 | if (options?.location === "archive") return projection.thread.archivedAt !== null; |
| 6201 | return true; | 6217 | return true; |
apps/server/src/orchestration-v2/ThreadAutoArchive.integration.test.ts created+279| ... | @@ -0,0 +1,279 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | CommandId, | ||
| 4 | MessageId, | ||
| 5 | ProjectId, | ||
| 6 | ProviderDriverKind, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | TurnItemId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 15 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 16 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | ||
| 17 | import * as Orchestrator from "./Orchestrator.ts"; | ||
| 18 | import * as ProjectionStore from "./ProjectionStore.ts"; | ||
| 19 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | ||
| 20 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; | ||
| 21 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | ||
| 22 | |||
| 23 | const instanceId = ProviderInstanceId.make("codex"); | ||
| 24 | const modelSelection = { instanceId, model: "gpt-5.1-codex" }; | ||
| 25 | const adapter = { | ||
| 26 | instanceId, | ||
| 27 | driver: ProviderDriverKind.make("codex"), | ||
| 28 | getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), | ||
| 29 | planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), | ||
| 30 | openSession: () => Effect.die("A CI failure must not start a provider"), | ||
| 31 | } as ProviderAdapter.ProviderAdapterV2["Service"]; | ||
| 32 | const databaseLayer = SqlitePersistence.layerMemory; | ||
| 33 | const testLayer = Layer.mergeAll( | ||
| 34 | databaseLayer, | ||
| 35 | ProjectionStore.layer.pipe(Layer.provide(databaseLayer)), | ||
| 36 | ProviderReplayHarness.layerWithRegistry( | ||
| 37 | { name: "auto-archive" }, | ||
| 38 | ProviderAdapterRegistry.layerFromAdapters([adapter]), | ||
| 39 | { databaseLayer, runEffectWorker: false }, | ||
| 40 | ), | ||
| 41 | ); | ||
| 42 | |||
| 43 | it.effect( | ||
| 44 | "CI failure wakes atomically with a durable local notification, deduplicates across settlement, and never requests provider work", | ||
| 45 | () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const engine = yield* Orchestrator.OrchestratorV2; | ||
| 48 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 49 | const sql = yield* SqlClient.SqlClient; | ||
| 50 | const threadId = ThreadId.make("ci-notification"); | ||
| 51 | yield* engine.dispatch({ | ||
| 52 | type: "thread.create", | ||
| 53 | commandId: CommandId.make("create-ci"), | ||
| 54 | threadId, | ||
| 55 | projectId: ProjectId.make("project-ci"), | ||
| 56 | title: "CI notification", | ||
| 57 | modelSelection, | ||
| 58 | runtimeMode: "full-access", | ||
| 59 | interactionMode: "default", | ||
| 60 | branch: null, | ||
| 61 | worktreePath: null, | ||
| 62 | createdBy: "user", | ||
| 63 | creationSource: "web", | ||
| 64 | }); | ||
| 65 | yield* engine.dispatch({ | ||
| 66 | type: "thread.settle", | ||
| 67 | commandId: CommandId.make("settle-ci"), | ||
| 68 | threadId, | ||
| 69 | }); | ||
| 70 | assert.deepStrictEqual( | ||
| 71 | (yield* projections.getShellSnapshot({ | ||
| 72 | location: "active", | ||
| 73 | settledOnly: true, | ||
| 74 | })).threads.map((thread) => thread.id), | ||
| 75 | [threadId], | ||
| 76 | ); | ||
| 77 | const itemId = TurnItemId.make("ci-head-notice"); | ||
| 78 | const dispatchFailure = (commandId: string, snapshotSequence: number) => | ||
| 79 | engine.dispatch({ | ||
| 80 | type: "thread.ci-failed", | ||
| 81 | commandId: CommandId.make(commandId), | ||
| 82 | threadId, | ||
| 83 | itemId, | ||
| 84 | snapshotSequence, | ||
| 85 | pullRequestNumber: 42, | ||
| 86 | url: "https://mergebot.example.test/owner/repository/pull/42", | ||
| 87 | }); | ||
| 88 | yield* dispatchFailure("wake-ci", (yield* projections.getShellSnapshot()).snapshotSequence); | ||
| 89 | const thread = yield* projections.getThread(threadId); | ||
| 90 | assert.equal(thread.settledOverride, "active"); | ||
| 91 | assert.equal(thread.settledAt, null); | ||
| 92 | assert.equal( | ||
| 93 | (yield* projections.getShellSnapshot({ location: "active", settledOnly: true })).threads | ||
| 94 | .length, | ||
| 95 | 0, | ||
| 96 | ); | ||
| 97 | const item = yield* projections.getTurnItem({ threadId, itemId }); | ||
| 98 | assert.ok(item?.type === "notification"); | ||
| 99 | assert.deepStrictEqual(item.source, { kind: "system" }); | ||
| 100 | assert.equal(item.summary, "CI failed for PR #42"); | ||
| 101 | assert.equal( | ||
| 102 | item.detail, | ||
| 103 | "[Open PR #42 in Mergebot](<https://mergebot.example.test/owner/repository/pull/42>)", | ||
| 104 | ); | ||
| 105 | assert.equal(item.runId, null); | ||
| 106 | assert.equal( | ||
| 107 | (yield* projections.getThreadRecords(threadId, ["runs", "messages"])).runs.length, | ||
| 108 | 0, | ||
| 109 | ); | ||
| 110 | assert.equal( | ||
| 111 | (yield* projections.getThreadRecords(threadId, ["messages"])).messages.length, | ||
| 112 | 0, | ||
| 113 | ); | ||
| 114 | const effects = yield* sql<{ | ||
| 115 | type: string; | ||
| 116 | }>`SELECT effect_type AS type FROM orchestration_v2_effect_outbox`; | ||
| 117 | assert.ok( | ||
| 118 | effects.every( | ||
| 119 | (effect) => | ||
| 120 | ![ | ||
| 121 | "provider-runtime.continue", | ||
| 122 | "provider-turn.start", | ||
| 123 | "provider-turn.steer", | ||
| 124 | "provider-turn.restart", | ||
| 125 | ].includes(effect.type), | ||
| 126 | ), | ||
| 127 | ); | ||
| 128 | yield* engine.dispatch({ | ||
| 129 | type: "thread.settle", | ||
| 130 | commandId: CommandId.make("resettle-ci"), | ||
| 131 | threadId, | ||
| 132 | }); | ||
| 133 | yield* dispatchFailure( | ||
| 134 | "duplicate-ci", | ||
| 135 | (yield* projections.getShellSnapshot()).snapshotSequence, | ||
| 136 | ); | ||
| 137 | assert.equal((yield* projections.getThread(threadId)).settledOverride, "settled"); | ||
| 138 | assert.equal( | ||
| 139 | (yield* projections.getThreadRecords(threadId, ["turnItems"])).turnItems.length, | ||
| 140 | 1, | ||
| 141 | ); | ||
| 142 | }).pipe(Effect.provide(testLayer)), | ||
| 143 | ); | ||
| 144 | |||
| 145 | it.effect( | ||
| 146 | "a concurrent activation or edit invalidates pending automatic archival and CI wakeup", | ||
| 147 | () => | ||
| 148 | Effect.gen(function* () { | ||
| 149 | const engine = yield* Orchestrator.OrchestratorV2; | ||
| 150 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 151 | const threadId = ThreadId.make("archive-race"); | ||
| 152 | yield* engine.dispatch({ | ||
| 153 | type: "thread.create", | ||
| 154 | commandId: CommandId.make("create-race"), | ||
| 155 | threadId, | ||
| 156 | projectId: ProjectId.make("project-ci"), | ||
| 157 | title: "Archive race", | ||
| 158 | modelSelection, | ||
| 159 | runtimeMode: "full-access", | ||
| 160 | interactionMode: "default", | ||
| 161 | branch: null, | ||
| 162 | worktreePath: null, | ||
| 163 | createdBy: "user", | ||
| 164 | creationSource: "web", | ||
| 165 | }); | ||
| 166 | yield* engine.dispatch({ | ||
| 167 | type: "thread.settle", | ||
| 168 | commandId: CommandId.make("settle-race"), | ||
| 169 | threadId, | ||
| 170 | }); | ||
| 171 | const before = (yield* projections.getShellSnapshot()).snapshotSequence; | ||
| 172 | yield* engine.dispatch({ | ||
| 173 | type: "thread.unsettle", | ||
| 174 | commandId: CommandId.make("activate-race"), | ||
| 175 | threadId, | ||
| 176 | reason: "user", | ||
| 177 | }); | ||
| 178 | yield* engine.dispatch({ | ||
| 179 | type: "thread.settle", | ||
| 180 | commandId: CommandId.make("resettle-race"), | ||
| 181 | threadId, | ||
| 182 | }); | ||
| 183 | for (const command of [ | ||
| 184 | { | ||
| 185 | type: "thread.archive" as const, | ||
| 186 | commandId: CommandId.make("stale-archive"), | ||
| 187 | threadId, | ||
| 188 | snapshotSequence: before, | ||
| 189 | }, | ||
| 190 | { | ||
| 191 | type: "thread.ci-failed" as const, | ||
| 192 | commandId: CommandId.make("stale-ci"), | ||
| 193 | threadId, | ||
| 194 | snapshotSequence: before, | ||
| 195 | itemId: TurnItemId.make("stale-notice"), | ||
| 196 | pullRequestNumber: 42, | ||
| 197 | url: "https://mergebot.example.test/owner/repository/pull/42", | ||
| 198 | }, | ||
| 199 | ]) | ||
| 200 | assert.ok(Exit.isFailure(yield* Effect.exit(engine.dispatch(command)))); | ||
| 201 | assert.equal((yield* projections.getThread(threadId)).archivedAt, null); | ||
| 202 | assert.equal( | ||
| 203 | (yield* projections.getThreadRecords(threadId, ["turnItems"])).turnItems.length, | ||
| 204 | 0, | ||
| 205 | ); | ||
| 206 | yield* engine.dispatch({ | ||
| 207 | type: "thread.archive", | ||
| 208 | commandId: CommandId.make("fresh-archive"), | ||
| 209 | threadId, | ||
| 210 | snapshotSequence: (yield* projections.getShellSnapshot()).snapshotSequence, | ||
| 211 | }); | ||
| 212 | assert.notEqual((yield* projections.getThread(threadId)).archivedAt, null); | ||
| 213 | }).pipe(Effect.provide(testLayer)), | ||
| 214 | ); | ||
| 215 | |||
| 216 | it.effect( | ||
| 217 | "a new user turn prevents a pending archive or CI observation from changing the thread", | ||
| 218 | () => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const engine = yield* Orchestrator.OrchestratorV2; | ||
| 221 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 222 | const threadId = ThreadId.make("new-run-race"); | ||
| 223 | yield* engine.dispatch({ | ||
| 224 | type: "thread.create", | ||
| 225 | commandId: CommandId.make("create-new-run"), | ||
| 226 | threadId, | ||
| 227 | projectId: ProjectId.make("project-ci"), | ||
| 228 | title: "New run race", | ||
| 229 | modelSelection, | ||
| 230 | runtimeMode: "full-access", | ||
| 231 | interactionMode: "default", | ||
| 232 | branch: null, | ||
| 233 | worktreePath: null, | ||
| 234 | createdBy: "user", | ||
| 235 | creationSource: "web", | ||
| 236 | }); | ||
| 237 | yield* engine.dispatch({ | ||
| 238 | type: "thread.settle", | ||
| 239 | commandId: CommandId.make("settle-new-run"), | ||
| 240 | threadId, | ||
| 241 | }); | ||
| 242 | const snapshotSequence = (yield* projections.getShellSnapshot()).snapshotSequence; | ||
| 243 | yield* engine.dispatch({ | ||
| 244 | type: "message.dispatch", | ||
| 245 | commandId: CommandId.make("new-user-turn"), | ||
| 246 | threadId, | ||
| 247 | messageId: MessageId.make("new-message"), | ||
| 248 | text: "Continue this work", | ||
| 249 | attachments: [], | ||
| 250 | dispatchMode: { type: "queue_after_active" }, | ||
| 251 | createdBy: "user", | ||
| 252 | creationSource: "web", | ||
| 253 | }); | ||
| 254 | for (const command of [ | ||
| 255 | { | ||
| 256 | type: "thread.archive" as const, | ||
| 257 | commandId: CommandId.make("archive-after-new-run"), | ||
| 258 | threadId, | ||
| 259 | snapshotSequence, | ||
| 260 | }, | ||
| 261 | { | ||
| 262 | type: "thread.ci-failed" as const, | ||
| 263 | commandId: CommandId.make("ci-after-new-run"), | ||
| 264 | threadId, | ||
| 265 | snapshotSequence, | ||
| 266 | itemId: TurnItemId.make("new-run-notice"), | ||
| 267 | pullRequestNumber: 42, | ||
| 268 | url: "https://mergebot.example.test/owner/repository/pull/42", | ||
| 269 | }, | ||
| 270 | ]) | ||
| 271 | assert.ok(Exit.isFailure(yield* Effect.exit(engine.dispatch(command)))); | ||
| 272 | assert.equal((yield* projections.getThread(threadId)).archivedAt, null); | ||
| 273 | assert.equal( | ||
| 274 | yield* projections.getTurnItem({ threadId, itemId: TurnItemId.make("new-run-notice") }), | ||
| 275 | null, | ||
| 276 | ); | ||
| 277 | assert.equal((yield* projections.getThreadRecords(threadId, ["runs"])).runs.length, 1); | ||
| 278 | }).pipe(Effect.provide(testLayer)), | ||
| 279 | ); | ||
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+1285-54| ... | @@ -3,18 +3,26 @@ import { assert, describe, expect, it } from "@effect/vitest"; | ... | @@ -3,18 +3,26 @@ import { assert, describe, expect, it } from "@effect/vitest"; |
| 3 | import * as DateTime from "effect/DateTime"; | 3 | import * as DateTime from "effect/DateTime"; |
| 4 | import { | 4 | import { |
| 5 | DEFAULT_SERVER_SETTINGS, | 5 | DEFAULT_SERVER_SETTINGS, |
| 6 | CommandId, | ||
| 6 | ProjectId, | 7 | ProjectId, |
| 7 | EventId, | 8 | EventId, |
| 9 | RunId, | ||
| 10 | TurnItemId, | ||
| 11 | ManagedWorkspaceError, | ||
| 12 | DevServerError, | ||
| 8 | ProviderInstanceId, | 13 | ProviderInstanceId, |
| 9 | ProviderSessionId, | 14 | ProviderSessionId, |
| 10 | ThreadId, | 15 | ThreadId, |
| 11 | type OrchestrationProjectShell, | 16 | type OrchestrationProjectShell, |
| 12 | type OrchestrationV2AppThread, | 17 | type OrchestrationV2AppThread, |
| 13 | type OrchestrationV2Command, | ||
| 14 | type OrchestrationV2DomainEvent, | 18 | type OrchestrationV2DomainEvent, |
| 19 | type OrchestrationV2StoredEvent, | ||
| 20 | type TerminalSummary, | ||
| 15 | type OrchestrationV2ShellSnapshot, | 21 | type OrchestrationV2ShellSnapshot, |
| 16 | type OrchestrationV2ThreadShell, | 22 | type OrchestrationV2ThreadShell, |
| 17 | type PullRequestSummary, | 23 | type PullRequestSummary, |
| 24 | type MergebotPullRequestDetails, | ||
| 25 | type OrchestrationV2ServerCommand, | ||
| 18 | type ServerSettings as ContractServerSettings, | 26 | type ServerSettings as ContractServerSettings, |
| 19 | type ServerSettingsPatch, | 27 | type ServerSettingsPatch, |
| 20 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| ... | @@ -22,6 +30,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ... | @@ -22,6 +30,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 22 | import * as Crypto from "effect/Crypto"; | 30 | import * as Crypto from "effect/Crypto"; |
| 23 | import * as Deferred from "effect/Deferred"; | 31 | import * as Deferred from "effect/Deferred"; |
| 24 | import * as Effect from "effect/Effect"; | 32 | import * as Effect from "effect/Effect"; |
| 33 | import * as Fiber from "effect/Fiber"; | ||
| 25 | import * as FileSystem from "effect/FileSystem"; | 34 | import * as FileSystem from "effect/FileSystem"; |
| 26 | import * as Layer from "effect/Layer"; | 35 | import * as Layer from "effect/Layer"; |
| 27 | import * as PubSub from "effect/PubSub"; | 36 | import * as PubSub from "effect/PubSub"; |
| ... | @@ -36,10 +45,15 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ... | @@ -36,10 +45,15 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 36 | import * as ServerActivation from "../serverActivation.ts"; | 45 | import * as ServerActivation from "../serverActivation.ts"; |
| 37 | import * as ServerSettings from "../serverSettings.ts"; | 46 | import * as ServerSettings from "../serverSettings.ts"; |
| 38 | import * as TerminalManager from "../terminal/Manager.ts"; | 47 | import * as TerminalManager from "../terminal/Manager.ts"; |
| 48 | import * as PreviewManager from "../preview/Manager.ts"; | ||
| 39 | import * as ProjectStore from "./ProjectStore.ts"; | 49 | import * as ProjectStore from "./ProjectStore.ts"; |
| 40 | import * as Orchestrator from "./Orchestrator.ts"; | 50 | import * as Orchestrator from "./Orchestrator.ts"; |
| 41 | import * as ProjectionStore from "./ProjectionStore.ts"; | 51 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 42 | import * as ThreadSettlementService from "./ThreadSettlementService.ts"; | 52 | import * as ThreadSettlementService from "./ThreadSettlementService.ts"; |
| 53 | import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts"; | ||
| 54 | import * as DevServer from "../workspace/DevServer.ts"; | ||
| 55 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 56 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; | ||
| 43 | 57 | ||
| 44 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); | 58 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); |
| 45 | const DAY_MS = 24 * 60 * 60 * 1_000; | 59 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| ... | @@ -152,7 +166,7 @@ describe("isAutoSettlementCandidate", () => { | ... | @@ -152,7 +166,7 @@ describe("isAutoSettlementCandidate", () => { |
| 152 | ).toBe(false); | 166 | ).toBe(false); |
| 153 | }); | 167 | }); |
| 154 | 168 | ||
| 155 | it("settles a thread whose only background work is a command left running", () => { | 169 | it("keeps a thread with a running background command active", () => { |
| 156 | expect( | 170 | expect( |
| 157 | ThreadSettlementService.isAutoSettlementCandidate( | 171 | ThreadSettlementService.isAutoSettlementCandidate( |
| 158 | shell({ | 172 | shell({ |
| ... | @@ -162,7 +176,7 @@ describe("isAutoSettlementCandidate", () => { | ... | @@ -162,7 +176,7 @@ describe("isAutoSettlementCandidate", () => { |
| 162 | }), | 176 | }), |
| 163 | NOW_MS, | 177 | NOW_MS, |
| 164 | ), | 178 | ), |
| 165 | ).toBe(true); | 179 | ).toBe(false); |
| 166 | }); | 180 | }); |
| 167 | 181 | ||
| 168 | it("keeps snoozed threads parked until they wake early on error or completion", () => { | 182 | it("keeps snoozed threads parked until they wake early on error or completion", () => { |
| ... | @@ -251,6 +265,34 @@ describe("threadHasQueuedTurnStart", () => { | ... | @@ -251,6 +265,34 @@ describe("threadHasQueuedTurnStart", () => { |
| 251 | }); | 265 | }); |
| 252 | 266 | ||
| 253 | describe("resolveAutoSettlementAt", () => { | 267 | describe("resolveAutoSettlementAt", () => { |
| 268 | it("keeps canonical work PR links while ignoring their cached lifecycle", () => { | ||
| 269 | const input = { | ||
| 270 | thread: shell({ | ||
| 271 | latestUserMessageAt: at(-4 * DAY_MS), | ||
| 272 | pullRequests: [link(1)], | ||
| 273 | }), | ||
| 274 | pullRequest: null, | ||
| 275 | usePullRequestSnapshots: false, | ||
| 276 | nowMs: NOW_MS, | ||
| 277 | autoSettleAfterDays: 3, | ||
| 278 | autoSettleOnMerge: true, | ||
| 279 | autoSettleKeepPullRequests: true, | ||
| 280 | }; | ||
| 281 | expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); | ||
| 282 | expect( | ||
| 283 | ThreadSettlementService.resolveAutoSettlementAt({ | ||
| 284 | ...input, | ||
| 285 | autoSettleKeepPullRequests: false, | ||
| 286 | }), | ||
| 287 | ).toEqual(at(-4 * DAY_MS)); | ||
| 288 | expect( | ||
| 289 | ThreadSettlementService.resolveAutoSettlementAt({ | ||
| 290 | ...input, | ||
| 291 | pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, | ||
| 292 | }), | ||
| 293 | ).toEqual(at(-4 * DAY_MS)); | ||
| 294 | }); | ||
| 295 | |||
| 254 | it("keeps threads with a PR from inactivity settlement only when asked", () => { | 296 | it("keeps threads with a PR from inactivity settlement only when asked", () => { |
| 255 | const input = { | 297 | const input = { |
| 256 | thread: shell({ | 298 | thread: shell({ |
| ... | @@ -416,6 +458,24 @@ const PROJECT_ID = ProjectId.make("settlement-project"); | ... | @@ -416,6 +458,24 @@ const PROJECT_ID = ProjectId.make("settlement-project"); |
| 416 | const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); | 458 | const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); |
| 417 | 459 | ||
| 418 | describe("autoSettlementSettingsKey", () => { | 460 | describe("autoSettlementSettingsKey", () => { |
| 461 | it("tracks PR retention changes at environment and project scope", () => { | ||
| 462 | const base = ThreadSettlementService.autoSettlementSettingsKey(DEFAULT_SERVER_SETTINGS); | ||
| 463 | expect( | ||
| 464 | ThreadSettlementService.autoSettlementSettingsKey({ | ||
| 465 | ...DEFAULT_SERVER_SETTINGS, | ||
| 466 | sidebarAutoSettleKeepPullRequests: true, | ||
| 467 | }), | ||
| 468 | ).not.toBe(base); | ||
| 469 | expect( | ||
| 470 | ThreadSettlementService.autoSettlementSettingsKey({ | ||
| 471 | ...DEFAULT_SERVER_SETTINGS, | ||
| 472 | projectSettingsOverrides: { | ||
| 473 | [PROJECT_ID]: { sidebarAutoSettleKeepPullRequests: true }, | ||
| 474 | }, | ||
| 475 | }), | ||
| 476 | ).not.toBe(base); | ||
| 477 | }); | ||
| 478 | |||
| 419 | it("distinguishes a project that inherits the threshold from one that disables it", () => { | 479 | it("distinguishes a project that inherits the threshold from one that disables it", () => { |
| 420 | const inherits = ThreadSettlementService.autoSettlementSettingsKey({ | 480 | const inherits = ThreadSettlementService.autoSettlementSettingsKey({ |
| 421 | ...DEFAULT_SERVER_SETTINGS, | 481 | ...DEFAULT_SERVER_SETTINGS, |
| ... | @@ -446,7 +506,12 @@ describe("autoSettlementSettingsKey", () => { | ... | @@ -446,7 +506,12 @@ describe("autoSettlementSettingsKey", () => { |
| 446 | }); | 506 | }); |
| 447 | }); | 507 | }); |
| 448 | 508 | ||
| 449 | type AutoSettleCommand = Extract<OrchestrationV2Command, { readonly type: "thread.auto-settle" }>; | 509 | type SettlementCommand = Extract< |
| 510 | OrchestrationV2ServerCommand, | ||
| 511 | { | ||
| 512 | readonly type: "thread.auto-settle" | "thread.archive" | "thread.ci-failed" | "thread.unsettle"; | ||
| 513 | } | ||
| 514 | >; | ||
| 450 | 515 | ||
| 451 | const testCrypto = Crypto.make({ | 516 | const testCrypto = Crypto.make({ |
| 452 | randomBytes: (size) => new Uint8Array(size).fill(1), | 517 | randomBytes: (size) => new Uint8Array(size).fill(1), |
| ... | @@ -468,16 +533,19 @@ function makeProject( | ... | @@ -468,16 +533,19 @@ function makeProject( |
| 468 | }; | 533 | }; |
| 469 | } | 534 | } |
| 470 | 535 | ||
| 471 | function makeThread(id: string, overrides: Partial<SettlementShell> = {}): SettlementShell { | 536 | function makeThread(id: string, overrides: Partial<SettlementShell> = {}) { |
| 472 | return shell({ | 537 | return { |
| 473 | id: ThreadId.make(id), | 538 | ...shell({ |
| 474 | projectId: PROJECT_ID, | 539 | id: ThreadId.make(id), |
| 475 | title: id, | 540 | projectId: PROJECT_ID, |
| 476 | createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"), | 541 | title: id, |
| 477 | updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), | 542 | createdAt: DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"), |
| 478 | latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), | 543 | updatedAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), |
| 479 | ...overrides, | 544 | latestUserMessageAt: DateTime.makeUnsafe("2026-08-20T00:00:00.000Z"), |
| 480 | }); | 545 | ...overrides, |
| 546 | }), | ||
| 547 | lastVisitedAt: overrides.lastVisitedAt ?? null, | ||
| 548 | }; | ||
| 481 | } | 549 | } |
| 482 | 550 | ||
| 483 | type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & { | 551 | type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & { |
| ... | @@ -533,19 +601,109 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") { | ... | @@ -533,19 +601,109 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") { |
| 533 | } satisfies GitManager.GitBranchPullRequest; | 601 | } satisfies GitManager.GitBranchPullRequest; |
| 534 | } | 602 | } |
| 535 | 603 | ||
| 604 | function terminal(threadId: ThreadId): TerminalSummary { | ||
| 605 | return { | ||
| 606 | threadId, | ||
| 607 | terminalId: "default", | ||
| 608 | cwd: "/workspace/project", | ||
| 609 | worktreePath: null, | ||
| 610 | status: "running", | ||
| 611 | pid: 123, | ||
| 612 | exitCode: null, | ||
| 613 | exitSignal: null, | ||
| 614 | hasRunningSubprocess: true, | ||
| 615 | label: "command", | ||
| 616 | updatedAt: NOW, | ||
| 617 | }; | ||
| 618 | } | ||
| 619 | |||
| 620 | function link(number: number) { | ||
| 621 | return { | ||
| 622 | host: "github.com", | ||
| 623 | repository: "owner/repository", | ||
| 624 | number, | ||
| 625 | url: `https://github.com/owner/repository/pull/${number}`, | ||
| 626 | source: "manual" as const, | ||
| 627 | linkedAt: NOW, | ||
| 628 | snapshot: null, | ||
| 629 | stack: null, | ||
| 630 | }; | ||
| 631 | } | ||
| 632 | |||
| 633 | function mergebotDetail( | ||
| 634 | number: number, | ||
| 635 | overrides: Partial<MergebotPullRequestDetails> = {}, | ||
| 636 | ): MergebotPullRequestDetails { | ||
| 637 | return { | ||
| 638 | number, | ||
| 639 | headSha: "current-head", | ||
| 640 | lifecycle: "open", | ||
| 641 | queueStatus: "not_queued", | ||
| 642 | position: null, | ||
| 643 | mergeEtaAt: null, | ||
| 644 | url: `https://mergebot.example.test/owner/repository/pull/${number}`, | ||
| 645 | title: "Pull request", | ||
| 646 | createdAt: NOW, | ||
| 647 | author: null, | ||
| 648 | additions: 1, | ||
| 649 | deletions: 0, | ||
| 650 | changedFilesCount: 1, | ||
| 651 | labels: [], | ||
| 652 | comments: [], | ||
| 653 | observedAt: Date.parse(NOW), | ||
| 654 | updatedAt: NOW, | ||
| 655 | mergedAt: null, | ||
| 656 | closedAt: null, | ||
| 657 | body: null, | ||
| 658 | draft: false, | ||
| 659 | baseBranch: "main", | ||
| 660 | headBranch: "feature", | ||
| 661 | ciStatus: "passed", | ||
| 662 | hasConflicts: false, | ||
| 663 | checks: [], | ||
| 664 | ...overrides, | ||
| 665 | }; | ||
| 666 | } | ||
| 667 | |||
| 668 | function failedCheck( | ||
| 669 | overrides: Partial<MergebotPullRequestDetails["checks"][number]> = {}, | ||
| 670 | ): MergebotPullRequestDetails["checks"][number] { | ||
| 671 | return { | ||
| 672 | id: "check-1", | ||
| 673 | name: "build", | ||
| 674 | workflowName: null, | ||
| 675 | status: "completed", | ||
| 676 | conclusion: "failure", | ||
| 677 | url: null, | ||
| 678 | startedAt: null, | ||
| 679 | completedAt: NOW, | ||
| 680 | ...overrides, | ||
| 681 | }; | ||
| 682 | } | ||
| 683 | |||
| 536 | interface HarnessOptions { | 684 | interface HarnessOptions { |
| 537 | readonly snapshot: SettlementSnapshot; | 685 | readonly snapshot: SettlementSnapshot; |
| 538 | readonly settings?: ContractServerSettings; | 686 | readonly settings?: ContractServerSettings; |
| 539 | readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; | 687 | readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; |
| 540 | readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; | 688 | readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; |
| 541 | readonly existingWorktreePaths?: ReadonlyArray<string>; | 689 | readonly existingWorktreePaths?: ReadonlyArray<string>; |
| 542 | readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>; | 690 | readonly managedThreadIds?: ReadonlyArray<ThreadId>; |
| 691 | readonly managedLookup?: ManagedWorkspaces.ManagedWorkspaces["Service"]["isManagedThread"]; | ||
| 692 | readonly pullRequestDetail?: PullRequestService.PullRequestService["Service"]["detail"]; | ||
| 693 | readonly mergebotDetail?: MergebotQueue.MergebotQueue["Service"]["readPullRequest"]; | ||
| 694 | readonly mergebotFind?: MergebotQueue.MergebotQueue["Service"]["findPullRequest"]; | ||
| 695 | readonly previousCiNotice?: boolean; | ||
| 696 | readonly previousItemIds?: ReadonlySet<TurnItemId>; | ||
| 697 | readonly settlementTimes?: ReadonlyMap<ThreadId, DateTime.Utc | null>; | ||
| 698 | readonly onDispatch?: (command: SettlementCommand) => Effect.Effect<void>; | ||
| 543 | /** Runs before each settle script start; a failure fails that start. */ | 699 | /** Runs before each settle script start; a failure fails that start. */ |
| 544 | readonly onScriptRun?: ( | 700 | readonly onScriptRun?: ( |
| 545 | input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput, | 701 | input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput, |
| 546 | ) => Effect.Effect<void>; | 702 | ) => Effect.Effect<void>; |
| 547 | /** Runs inside each `closeIdle` call. */ | 703 | /** Runs inside each `close` call. */ |
| 548 | readonly onCloseIdle?: () => Effect.Effect<void>; | 704 | readonly onCloseTerminals?: () => Effect.Effect<void>; |
| 705 | readonly terminals?: ReadonlyArray<TerminalSummary>; | ||
| 706 | readonly onStopUnused?: (threadId: ThreadId) => Effect.Effect<void, DevServerError>; | ||
| 549 | /** Threads `getThread` returns when a `thread.settled` event is handled. */ | 707 | /** Threads `getThread` returns when a `thread.settled` event is handled. */ |
| 550 | readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>; | 708 | readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>; |
| 551 | } | 709 | } |
| ... | @@ -559,7 +717,13 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -559,7 +717,13 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 559 | const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); | 717 | const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); |
| 560 | const settingsChanges = yield* PubSub.unbounded<ContractServerSettings>(); | 718 | const settingsChanges = yield* PubSub.unbounded<ContractServerSettings>(); |
| 561 | const mergedPullRequests = yield* PubSub.unbounded<PullRequestService.PullRequestMergeEvent>(); | 719 | const mergedPullRequests = yield* PubSub.unbounded<PullRequestService.PullRequestMergeEvent>(); |
| 562 | const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]); | 720 | const mergebotChanges = yield* PubSub.unbounded<{ |
| 721 | readonly repository: string; | ||
| 722 | readonly number?: number; | ||
| 723 | }>(); | ||
| 724 | const mergebotSubscribed = yield* Deferred.make<void>(); | ||
| 725 | const providerDetailReads = yield* Ref.make(0); | ||
| 726 | const commands = yield* Ref.make<ReadonlyArray<SettlementCommand>>([]); | ||
| 563 | const branchCalls = yield* Ref.make< | 727 | const branchCalls = yield* Ref.make< |
| 564 | ReadonlyArray<{ readonly cwd: string; readonly branch: string }> | 728 | ReadonlyArray<{ readonly cwd: string; readonly branch: string }> |
| 565 | >([]); | 729 | >([]); |
| ... | @@ -572,10 +736,17 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -572,10 +736,17 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 572 | >([]); | 736 | >([]); |
| 573 | const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]); | 737 | const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]); |
| 574 | const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]); | 738 | const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]); |
| 575 | const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>(); | 739 | const domainEvents = yield* PubSub.unbounded<OrchestrationV2StoredEvent>(); |
| 576 | const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>(); | 740 | const previews = yield* PreviewManager.make.pipe( |
| 741 | Effect.provideService(Crypto.Crypto, testCrypto), | ||
| 742 | ); | ||
| 743 | const closedTerminals = yield* Queue.unbounded<{ readonly threadId: string }>(); | ||
| 744 | const archiveReads = yield* Queue.unbounded<void>(); | ||
| 745 | const fullArchiveReadCount = yield* Ref.make(0); | ||
| 746 | const singleArchiveReads = yield* Ref.make<ReadonlyArray<ThreadId>>([]); | ||
| 577 | const scriptRuns = | 747 | const scriptRuns = |
| 578 | yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>(); | 748 | yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>(); |
| 749 | const devServerStops = yield* Ref.make<ReadonlyArray<ThreadId>>([]); | ||
| 579 | 750 | ||
| 580 | const updateSettings = (patch: ServerSettingsPatch) => | 751 | const updateSettings = (patch: ServerSettingsPatch) => |
| 581 | Effect.gen(function* () { | 752 | Effect.gen(function* () { |
| ... | @@ -611,7 +782,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -611,7 +782,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 611 | }); | 782 | }); |
| 612 | 783 | ||
| 613 | const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => { | 784 | const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => { |
| 614 | if (command.type !== "thread.auto-settle") { | 785 | if ( |
| 786 | command.type !== "thread.auto-settle" && | ||
| 787 | command.type !== "thread.archive" && | ||
| 788 | command.type !== "thread.ci-failed" && | ||
| 789 | command.type !== "thread.unsettle" | ||
| 790 | ) { | ||
| 615 | return Effect.die(new Error(`Unexpected command: ${command.type}`)); | 791 | return Effect.die(new Error(`Unexpected command: ${command.type}`)); |
| 616 | } | 792 | } |
| 617 | return Ref.update(commands, (recorded) => [...recorded, command]).pipe( | 793 | return Ref.update(commands, (recorded) => [...recorded, command]).pipe( |
| ... | @@ -634,10 +810,74 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -634,10 +810,74 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 634 | }); | 810 | }); |
| 635 | 811 | ||
| 636 | const layerDependencies = Layer.mergeAll( | 812 | const layerDependencies = Layer.mergeAll( |
| 813 | Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({ | ||
| 814 | latestAgentSequence: () => Effect.succeed(1), | ||
| 815 | getThreadSettlementTime: (threadId) => | ||
| 816 | Ref.get(snapshots).pipe( | ||
| 817 | Effect.map( | ||
| 818 | (snapshot) => | ||
| 819 | options.settlementTimes?.get(threadId) ?? | ||
| 820 | snapshot.threads.find((thread) => thread.id === threadId)?.settledAt ?? | ||
| 821 | null, | ||
| 822 | ), | ||
| 823 | ), | ||
| 824 | }), | ||
| 825 | Layer.mock(ManagedWorkspaces.ManagedWorkspaces)({ | ||
| 826 | isManagedThread: | ||
| 827 | options.managedLookup ?? | ||
| 828 | ((threadId) => Effect.succeed(options.managedThreadIds?.includes(threadId) ?? false)), | ||
| 829 | }), | ||
| 637 | Layer.mock(ProjectStore.ProjectStoreV2)({ | 830 | Layer.mock(ProjectStore.ProjectStoreV2)({ |
| 638 | listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), | 831 | listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), |
| 639 | }), | 832 | }), |
| 640 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | 833 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ |
| 834 | getThreadShell: (threadId) => | ||
| 835 | Ref.update(singleArchiveReads, (reads) => [...reads, threadId]).pipe( | ||
| 836 | Effect.andThen(Queue.offer(archiveReads, undefined)), | ||
| 837 | Effect.andThen(Ref.get(snapshots)), | ||
| 838 | Effect.map( | ||
| 839 | (snapshot) => | ||
| 840 | [...snapshot.threads, ...snapshot.archivedThreads].find( | ||
| 841 | (thread) => thread.id === threadId, | ||
| 842 | ) ?? null, | ||
| 843 | ), | ||
| 844 | ), | ||
| 845 | getShellSnapshot: (input) => | ||
| 846 | Ref.update(fullArchiveReadCount, (count) => count + 1).pipe( | ||
| 847 | Effect.andThen(Queue.offer(archiveReads, undefined)), | ||
| 848 | Effect.andThen(Ref.get(snapshots)), | ||
| 849 | Effect.map((snapshot) => ({ | ||
| 850 | ...snapshot, | ||
| 851 | threads: snapshot.threads.filter( | ||
| 852 | (thread) => !input?.settledOnly || thread.settledOverride === "settled", | ||
| 853 | ), | ||
| 854 | })), | ||
| 855 | ), | ||
| 856 | getTurnItem: (input) => | ||
| 857 | Effect.succeed( | ||
| 858 | options.previousCiNotice || options.previousItemIds?.has(input.itemId) | ||
| 859 | ? { | ||
| 860 | ...input, | ||
| 861 | id: input.itemId, | ||
| 862 | type: "notification", | ||
| 863 | source: { kind: "system" }, | ||
| 864 | outcome: "failed", | ||
| 865 | summary: "CI failed", | ||
| 866 | runId: null, | ||
| 867 | nodeId: null, | ||
| 868 | providerThreadId: null, | ||
| 869 | providerTurnId: null, | ||
| 870 | nativeItemRef: null, | ||
| 871 | parentItemId: null, | ||
| 872 | ordinal: 0, | ||
| 873 | status: "completed", | ||
| 874 | title: null, | ||
| 875 | startedAt: DateTime.makeUnsafe(NOW), | ||
| 876 | completedAt: DateTime.makeUnsafe(NOW), | ||
| 877 | updatedAt: DateTime.makeUnsafe(NOW), | ||
| 878 | } | ||
| 879 | : null, | ||
| 880 | ), | ||
| 641 | getSettlementCandidates: (threadId) => | 881 | getSettlementCandidates: (threadId) => |
| 642 | Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe( | 882 | Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe( |
| 643 | Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)), | 883 | Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)), |
| ... | @@ -657,12 +897,25 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -657,12 +897,25 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 657 | }, | 897 | }, |
| 658 | }), | 898 | }), |
| 659 | Layer.mock(Orchestrator.OrchestratorV2)({ | 899 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 660 | streamDomainEvents: Stream.fromPubSub(domainEvents), | 900 | streamStoredEventsFrom: () => Stream.fromPubSub(domainEvents), |
| 661 | dispatch, | 901 | dispatch, |
| 662 | }), | 902 | }), |
| 663 | Layer.mock(TerminalManager.TerminalManager)({ | 903 | Layer.mock(TerminalManager.TerminalManager)({ |
| 664 | closeIdle: (input) => | 904 | close: (input) => |
| 665 | Queue.offer(closedIdle, input).pipe(Effect.andThen(options.onCloseIdle?.() ?? Effect.void)), | 905 | Queue.offer(closedTerminals, input).pipe( |
| 906 | Effect.andThen(options.onCloseTerminals?.() ?? Effect.void), | ||
| 907 | ), | ||
| 908 | list: ({ threadId }) => | ||
| 909 | Effect.succeed( | ||
| 910 | options.terminals?.filter((terminal) => terminal.threadId === threadId) ?? [], | ||
| 911 | ), | ||
| 912 | }), | ||
| 913 | Layer.succeed(PreviewManager.PreviewManager, previews), | ||
| 914 | Layer.mock(DevServer.DevServer)({ | ||
| 915 | stopUnused: (threadId) => | ||
| 916 | Ref.update(devServerStops, (stops) => [...stops, threadId]).pipe( | ||
| 917 | Effect.andThen(options.onStopUnused?.(threadId) ?? Effect.void), | ||
| 918 | ), | ||
| 666 | }), | 919 | }), |
| 667 | Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ | 920 | Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ |
| 668 | runForThread: (input) => | 921 | runForThread: (input) => |
| ... | @@ -676,11 +929,34 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -676,11 +929,34 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 676 | invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), | 929 | invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), |
| 677 | }), | 930 | }), |
| 678 | Layer.mock(PullRequestService.PullRequestService)({ | 931 | Layer.mock(PullRequestService.PullRequestService)({ |
| 932 | invalidate: () => Effect.void, | ||
| 933 | detail: (input, ...rest) => | ||
| 934 | Ref.update(providerDetailReads, (count) => count + 1).pipe( | ||
| 935 | Effect.andThen( | ||
| 936 | options.pullRequestDetail?.(input, ...rest) ?? Effect.die("Unexpected detail read"), | ||
| 937 | ), | ||
| 938 | ), | ||
| 679 | summary: pullRequestSummary, | 939 | summary: pullRequestSummary, |
| 680 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( | 940 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( |
| 681 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | 941 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 682 | ), | 942 | ), |
| 683 | }), | 943 | }), |
| 944 | Layer.mock(MergebotQueue.MergebotQueue)({ | ||
| 945 | configuration: { | ||
| 946 | origin: "https://mergebot.example.test", | ||
| 947 | repositories: ["owner/repository"], | ||
| 948 | }, | ||
| 949 | read: () => Effect.succeed(null), | ||
| 950 | readPullRequest: options.mergebotDetail ?? (() => Effect.succeed(null)), | ||
| 951 | findPullRequest: options.mergebotFind ?? (() => Effect.succeed(null)), | ||
| 952 | streamChanges: Stream.unwrap( | ||
| 953 | Effect.gen(function* () { | ||
| 954 | const subscription = yield* PubSub.subscribe(mergebotChanges); | ||
| 955 | yield* Deferred.succeed(mergebotSubscribed, undefined); | ||
| 956 | return Stream.fromSubscription(subscription); | ||
| 957 | }), | ||
| 958 | ), | ||
| 959 | }), | ||
| 684 | Layer.succeed(ServerSettings.ServerSettingsService, serverSettings), | 960 | Layer.succeed(ServerSettings.ServerSettingsService, serverSettings), |
| 685 | Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), | 961 | Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), |
| 686 | Layer.succeed(Crypto.Crypto, testCrypto), | 962 | Layer.succeed(Crypto.Crypto, testCrypto), |
| ... | @@ -699,10 +975,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: | ... | @@ -699,10 +975,23 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 699 | branchCalls, | 975 | branchCalls, |
| 700 | summaryCalls, | 976 | summaryCalls, |
| 701 | summaryRecovery, | 977 | summaryRecovery, |
| 978 | providerDetailReads, | ||
| 979 | mergebotSubscribed, | ||
| 980 | publishMergebotChange: (number?: number) => | ||
| 981 | PubSub.publish(mergebotChanges, { | ||
| 982 | repository: "owner/repository", | ||
| 983 | ...(number === undefined ? {} : { number }), | ||
| 984 | }), | ||
| 702 | invalidatedCwds, | 985 | invalidatedCwds, |
| 703 | closedIdle, | 986 | closedTerminals, |
| 987 | archiveReads, | ||
| 988 | fullArchiveReadCount, | ||
| 989 | singleArchiveReads, | ||
| 704 | scriptRuns, | 990 | scriptRuns, |
| 705 | publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event), | 991 | devServerStops, |
| 992 | previews, | ||
| 993 | publishEvent: (event: OrchestrationV2DomainEvent, commandId: CommandId | null = null) => | ||
| 994 | PubSub.publish(domainEvents, { sequence: 2, commandId, event }), | ||
| 706 | updateSettings, | 995 | updateSettings, |
| 707 | publishMerge: PubSub.publish(mergedPullRequests, { | 996 | publishMerge: PubSub.publish(mergedPullRequests, { |
| 708 | projectId: PROJECT_ID, | 997 | projectId: PROJECT_ID, |
| ... | @@ -726,6 +1015,231 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( | ... | @@ -726,6 +1015,231 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( |
| 726 | }); | 1015 | }); |
| 727 | 1016 | ||
| 728 | describe("ThreadSettlementServiceV2 worker", () => { | 1017 | describe("ThreadSettlementServiceV2 worker", () => { |
| 1018 | it.effect.each([true, false])( | ||
| 1019 | "honors inactivity PR retention %s for canonical work links without cached snapshots", | ||
| 1020 | (keepPullRequests) => | ||
| 1021 | Effect.scoped( | ||
| 1022 | Effect.gen(function* () { | ||
| 1023 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1024 | const thread = makeThread("inactive-canonical-work-pr", { pullRequests: [link(1)] }); | ||
| 1025 | const fixture = yield* makeHarness({ | ||
| 1026 | snapshot: makeSnapshot([thread]), | ||
| 1027 | settings: { | ||
| 1028 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1029 | sidebarAutoSettleAfterDays: 3, | ||
| 1030 | sidebarAutoSettleKeepPullRequests: keepPullRequests, | ||
| 1031 | }, | ||
| 1032 | }); | ||
| 1033 | |||
| 1034 | yield* Effect.gen(function* () { | ||
| 1035 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1036 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1037 | expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual( | ||
| 1038 | keepPullRequests ? [] : [thread.id], | ||
| 1039 | ); | ||
| 1040 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1041 | }), | ||
| 1042 | ), | ||
| 1043 | ); | ||
| 1044 | |||
| 1045 | it.effect("uses only Mergebot for configured linked and branch pull requests", () => | ||
| 1046 | Effect.scoped( | ||
| 1047 | Effect.gen(function* () { | ||
| 1048 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1049 | const workProject = { | ||
| 1050 | ...makeProject(), | ||
| 1051 | repositoryIdentity: { | ||
| 1052 | canonicalKey: "github.com/owner/repository", | ||
| 1053 | locator: { | ||
| 1054 | source: "git-remote" as const, | ||
| 1055 | remoteName: "origin", | ||
| 1056 | remoteUrl: "https://github.com/owner/repository", | ||
| 1057 | }, | ||
| 1058 | }, | ||
| 1059 | }; | ||
| 1060 | const legacy = makeThread("legacy-work-pr", { | ||
| 1061 | linkedPullRequest: { | ||
| 1062 | projectId: PROJECT_ID, | ||
| 1063 | repository: "owner/repository", | ||
| 1064 | number: 1, | ||
| 1065 | url: link(1).url, | ||
| 1066 | }, | ||
| 1067 | }); | ||
| 1068 | const linked = makeThread("linked-work-pr", { | ||
| 1069 | pullRequests: [ | ||
| 1070 | { | ||
| 1071 | ...link(1), | ||
| 1072 | snapshot: { | ||
| 1073 | state: "open", | ||
| 1074 | title: "Old snapshot", | ||
| 1075 | headBranch: "feature", | ||
| 1076 | baseBranch: "main", | ||
| 1077 | isDraft: false, | ||
| 1078 | updatedAt: NOW, | ||
| 1079 | syncedAt: NOW, | ||
| 1080 | }, | ||
| 1081 | }, | ||
| 1082 | ], | ||
| 1083 | }); | ||
| 1084 | const branch = makeThread("branch-work-pr", { branch: "feature" }); | ||
| 1085 | const reused = makeThread("reused-work-branch", { | ||
| 1086 | linkedPullRequest: legacy.linkedPullRequest, | ||
| 1087 | branch: "reused-feature", | ||
| 1088 | }); | ||
| 1089 | const fixture = yield* makeHarness({ | ||
| 1090 | settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null }, | ||
| 1091 | snapshot: makeSnapshot([legacy, linked, branch, reused], [workProject]), | ||
| 1092 | mergebotDetail: ({ number }) => | ||
| 1093 | Effect.succeed(mergebotDetail(number, { lifecycle: "merged", mergedAt: NOW })), | ||
| 1094 | mergebotFind: ({ headBranch }) => | ||
| 1095 | Effect.succeed( | ||
| 1096 | mergebotDetail(1, { | ||
| 1097 | lifecycle: headBranch === "reused-feature" ? "open" : "merged", | ||
| 1098 | mergedAt: NOW, | ||
| 1099 | }), | ||
| 1100 | ), | ||
| 1101 | }); | ||
| 1102 | yield* Effect.gen(function* () { | ||
| 1103 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1104 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1105 | expect( | ||
| 1106 | (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(), | ||
| 1107 | ).toEqual([legacy.id, linked.id, branch.id].toSorted()); | ||
| 1108 | expect(yield* Ref.get(fixture.branchCalls)).toEqual([]); | ||
| 1109 | expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]); | ||
| 1110 | expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); | ||
| 1111 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1112 | }), | ||
| 1113 | ), | ||
| 1114 | ); | ||
| 1115 | |||
| 1116 | it.effect( | ||
| 1117 | "ignores cached work PR snapshots and unknown merge times while inactivity stays independent", | ||
| 1118 | () => | ||
| 1119 | Effect.scoped( | ||
| 1120 | Effect.gen(function* () { | ||
| 1121 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1122 | const thread = makeThread("unknown-work-pr", { | ||
| 1123 | pullRequests: [ | ||
| 1124 | { | ||
| 1125 | ...link(1), | ||
| 1126 | snapshot: { | ||
| 1127 | state: "merged", | ||
| 1128 | title: "Cached merge", | ||
| 1129 | headBranch: "feature", | ||
| 1130 | baseBranch: "main", | ||
| 1131 | isDraft: false, | ||
| 1132 | updatedAt: NOW, | ||
| 1133 | syncedAt: NOW, | ||
| 1134 | mergedAt: NOW, | ||
| 1135 | }, | ||
| 1136 | }, | ||
| 1137 | ], | ||
| 1138 | }); | ||
| 1139 | for (const state of ["disconnected", "unknown-time", "inactive"] as const) { | ||
| 1140 | const fixture = yield* makeHarness({ | ||
| 1141 | settings: { | ||
| 1142 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1143 | sidebarAutoSettleAfterDays: state === "inactive" ? 1 : null, | ||
| 1144 | }, | ||
| 1145 | snapshot: makeSnapshot([thread]), | ||
| 1146 | mergebotDetail: () => | ||
| 1147 | Effect.succeed( | ||
| 1148 | state === "unknown-time" | ||
| 1149 | ? mergebotDetail(1, { lifecycle: "merged", updatedAt: NOW, mergedAt: null }) | ||
| 1150 | : null, | ||
| 1151 | ), | ||
| 1152 | }); | ||
| 1153 | yield* Effect.gen(function* () { | ||
| 1154 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1155 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1156 | expect((yield* Ref.get(fixture.commands)).length).toBe(state === "inactive" ? 1 : 0); | ||
| 1157 | expect(yield* Ref.get(fixture.branchCalls)).toEqual([]); | ||
| 1158 | expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]); | ||
| 1159 | expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); | ||
| 1160 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1161 | } | ||
| 1162 | }), | ||
| 1163 | ), | ||
| 1164 | ); | ||
| 1165 | |||
| 1166 | it.effect("rechecks open resources after waiting for a merged pull request", () => | ||
| 1167 | Effect.scoped( | ||
| 1168 | Effect.gen(function* () { | ||
| 1169 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1170 | const lookupStarted = yield* Deferred.make<void>(); | ||
| 1171 | const lookupResult = yield* Deferred.make<GitManager.GitBranchPullRequest>(); | ||
| 1172 | const thread = makeThread("open-during-lookup", { branch: "feature" }); | ||
| 1173 | const fixture = yield* makeHarness({ | ||
| 1174 | snapshot: makeSnapshot([thread]), | ||
| 1175 | settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null }, | ||
| 1176 | branchPullRequest: () => | ||
| 1177 | Deferred.succeed(lookupStarted, undefined).pipe( | ||
| 1178 | Effect.andThen(Deferred.await(lookupResult)), | ||
| 1179 | ), | ||
| 1180 | }); | ||
| 1181 | |||
| 1182 | yield* Effect.gen(function* () { | ||
| 1183 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1184 | const startup = yield* startHarness( | ||
| 1185 | service, | ||
| 1186 | fixture.activation, | ||
| 1187 | fixture.snapshotReads, | ||
| 1188 | ).pipe(Effect.forkScoped); | ||
| 1189 | yield* Deferred.await(lookupStarted); | ||
| 1190 | const tab = yield* fixture.previews.open({ threadId: thread.id }); | ||
| 1191 | yield* Deferred.succeed(lookupResult, makeBranchPullRequest("merged")); | ||
| 1192 | yield* Fiber.join(startup); | ||
| 1193 | expect(yield* Ref.get(fixture.commands)).toEqual([]); | ||
| 1194 | expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]); | ||
| 1195 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1196 | }), | ||
| 1197 | ), | ||
| 1198 | ); | ||
| 1199 | |||
| 1200 | it.effect("keeps unattended chats with terminals, browser tabs, or commands active", () => | ||
| 1201 | Effect.scoped( | ||
| 1202 | Effect.gen(function* () { | ||
| 1203 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1204 | const runningTerminal = makeThread("running-terminal"); | ||
| 1205 | const idleTerminal = makeThread("idle-terminal"); | ||
| 1206 | const exitedTerminal = makeThread("exited-terminal"); | ||
| 1207 | const browser = makeThread("browser-tab"); | ||
| 1208 | const background = makeThread("background-command", { | ||
| 1209 | pendingBackgroundTasks: [{ taskId: "command", kind: "command" }], | ||
| 1210 | }); | ||
| 1211 | const quiet = makeThread("quiet-chat"); | ||
| 1212 | const fixture = yield* makeHarness({ | ||
| 1213 | snapshot: makeSnapshot([ | ||
| 1214 | runningTerminal, | ||
| 1215 | idleTerminal, | ||
| 1216 | exitedTerminal, | ||
| 1217 | browser, | ||
| 1218 | background, | ||
| 1219 | quiet, | ||
| 1220 | ]), | ||
| 1221 | settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 1 }, | ||
| 1222 | terminals: [ | ||
| 1223 | terminal(runningTerminal.id), | ||
| 1224 | { ...terminal(idleTerminal.id), hasRunningSubprocess: false }, | ||
| 1225 | { ...terminal(exitedTerminal.id), status: "exited", pid: null }, | ||
| 1226 | ], | ||
| 1227 | }); | ||
| 1228 | yield* fixture.previews.open({ threadId: browser.id }); | ||
| 1229 | |||
| 1230 | yield* Effect.gen(function* () { | ||
| 1231 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1232 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1233 | expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ | ||
| 1234 | quiet.id, | ||
| 1235 | ]); | ||
| 1236 | expect((yield* fixture.previews.list({ threadId: browser.id })).sessions).toHaveLength(1); | ||
| 1237 | expect(yield* Queue.size(fixture.closedTerminals)).toBe(0); | ||
| 1238 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1239 | }), | ||
| 1240 | ), | ||
| 1241 | ); | ||
| 1242 | |||
| 729 | it.effect("settles a merged pull request stored only in the thread links", () => | 1243 | it.effect("settles a merged pull request stored only in the thread links", () => |
| 730 | Effect.scoped( | 1244 | Effect.scoped( |
| 731 | Effect.gen(function* () { | 1245 | Effect.gen(function* () { |
| ... | @@ -769,6 +1283,54 @@ describe("ThreadSettlementServiceV2 worker", () => { | ... | @@ -769,6 +1283,54 @@ describe("ThreadSettlementServiceV2 worker", () => { |
| 769 | ), | 1283 | ), |
| 770 | ); | 1284 | ); |
| 771 | 1285 | ||
| 1286 | it.effect("reevaluates a merged link immediately after its compact update event", () => | ||
| 1287 | Effect.scoped( | ||
| 1288 | Effect.gen(function* () { | ||
| 1289 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1290 | const mergedLink = { | ||
| 1291 | ...link(42), | ||
| 1292 | host: "example.test", | ||
| 1293 | url: "https://example.test/owner/repository/pull/42", | ||
| 1294 | snapshot: { | ||
| 1295 | state: "merged" as const, | ||
| 1296 | title: "Pull request", | ||
| 1297 | headBranch: "feature", | ||
| 1298 | baseBranch: "main", | ||
| 1299 | isDraft: false, | ||
| 1300 | updatedAt: NOW, | ||
| 1301 | syncedAt: NOW, | ||
| 1302 | mergedAt: NOW, | ||
| 1303 | }, | ||
| 1304 | }; | ||
| 1305 | const thread = makeThread("compact-merged-link", { pullRequests: [mergedLink] }); | ||
| 1306 | const settled = yield* Deferred.make<void>(); | ||
| 1307 | const fixture = yield* makeHarness({ | ||
| 1308 | snapshot: makeSnapshot([]), | ||
| 1309 | settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null }, | ||
| 1310 | onDispatch: () => Deferred.succeed(settled, undefined), | ||
| 1311 | }); | ||
| 1312 | |||
| 1313 | yield* Effect.gen(function* () { | ||
| 1314 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1315 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1316 | yield* Ref.set(fixture.snapshots, makeSnapshot([thread])); | ||
| 1317 | yield* fixture.publishEvent({ | ||
| 1318 | id: EventId.make("compact-merged-link"), | ||
| 1319 | type: "thread.pull-request-link-updated", | ||
| 1320 | threadId: thread.id, | ||
| 1321 | occurredAt: DateTime.makeUnsafe(NOW), | ||
| 1322 | payload: mergedLink, | ||
| 1323 | }); | ||
| 1324 | yield* Deferred.await(settled); | ||
| 1325 | yield* service.drain; | ||
| 1326 | expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ | ||
| 1327 | thread.id, | ||
| 1328 | ]); | ||
| 1329 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1330 | }), | ||
| 1331 | ), | ||
| 1332 | ); | ||
| 1333 | |||
| 772 | it.effect("settles only the project opted in while environment settlement is disabled", () => | 1334 | it.effect("settles only the project opted in while environment settlement is disabled", () => |
| 773 | Effect.scoped( | 1335 | Effect.scoped( |
| 774 | Effect.gen(function* () { | 1336 | Effect.gen(function* () { |
| ... | @@ -814,6 +1376,8 @@ describe("ThreadSettlementServiceV2 worker", () => { | ... | @@ -814,6 +1376,8 @@ describe("ThreadSettlementServiceV2 worker", () => { |
| 814 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | 1376 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); |
| 815 | const commands = yield* Ref.get(fixture.commands); | 1377 | const commands = yield* Ref.get(fixture.commands); |
| 816 | expect(commands).toHaveLength(1); | 1378 | expect(commands).toHaveLength(1); |
| 1379 | assert.equal(commands[0]?.type, "thread.auto-settle"); | ||
| 1380 | if (commands[0]?.type !== "thread.auto-settle") return; | ||
| 817 | expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt); | 1381 | expect(commands[0]?.settledAt).toEqual(thread.latestRunCompletedAt); |
| 818 | expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt); | 1382 | expect(commands[0]?.snapshotAt).toEqual(thread.updatedAt); |
| 819 | }).pipe(Effect.provide(fixture.layer)); | 1383 | }).pipe(Effect.provide(fixture.layer)); |
| ... | @@ -1077,6 +1641,185 @@ describe("ThreadSettlementServiceV2 worker", () => { | ... | @@ -1077,6 +1641,185 @@ describe("ThreadSettlementServiceV2 worker", () => { |
| 1077 | ); | 1641 | ); |
| 1078 | }); | 1642 | }); |
| 1079 | 1643 | ||
| 1644 | describe("ThreadSettlementServiceV2 development servers", () => { | ||
| 1645 | const settings = { | ||
| 1646 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1647 | sidebarAutoSettleOnMerge: false, | ||
| 1648 | sidebarAutoSettleAfterDays: null, | ||
| 1649 | }; | ||
| 1650 | const settled = (id: string, overrides: Partial<SettlementShell> = {}) => | ||
| 1651 | makeThread(id, { | ||
| 1652 | settledOverride: "settled", | ||
| 1653 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 1654 | ...overrides, | ||
| 1655 | }); | ||
| 1656 | const start = Effect.fn("startDevServerCleanupHarness")(function* ( | ||
| 1657 | fixture: Effect.Success<ReturnType<typeof makeHarness>>, | ||
| 1658 | ) { | ||
| 1659 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1660 | yield* service.start(); | ||
| 1661 | yield* Deferred.succeed(fixture.activation, undefined); | ||
| 1662 | yield* Queue.take(fixture.archiveReads); | ||
| 1663 | yield* service.drain; | ||
| 1664 | return service; | ||
| 1665 | }); | ||
| 1666 | |||
| 1667 | it.effect( | ||
| 1668 | "reconciles settled and archived servers on startup with auto-settlement disabled", | ||
| 1669 | () => | ||
| 1670 | Effect.scoped( | ||
| 1671 | Effect.gen(function* () { | ||
| 1672 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1673 | const parked = settled("parked"); | ||
| 1674 | const archived = makeThread("archived", { archivedAt: DateTime.makeUnsafe(NOW) }); | ||
| 1675 | const fixture = yield* makeHarness({ | ||
| 1676 | settings, | ||
| 1677 | snapshot: { | ||
| 1678 | ...makeSnapshot([parked, makeThread("active")]), | ||
| 1679 | archivedThreads: [archived], | ||
| 1680 | }, | ||
| 1681 | managedThreadIds: [parked.id], | ||
| 1682 | }); | ||
| 1683 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 1684 | assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [ | ||
| 1685 | archived.id, | ||
| 1686 | parked.id, | ||
| 1687 | ]); | ||
| 1688 | assert.equal(yield* Queue.size(fixture.scriptRuns), 0); | ||
| 1689 | }), | ||
| 1690 | ), | ||
| 1691 | ); | ||
| 1692 | |||
| 1693 | it.effect( | ||
| 1694 | "settling retains a managed workspace while stopping its separate development server", | ||
| 1695 | () => | ||
| 1696 | Effect.scoped( | ||
| 1697 | Effect.gen(function* () { | ||
| 1698 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1699 | const thread = settled("managed", { worktreePath: null }); | ||
| 1700 | const fixture = yield* makeHarness({ | ||
| 1701 | settings, | ||
| 1702 | snapshot: makeSnapshot([]), | ||
| 1703 | currentThreads: [thread], | ||
| 1704 | managedThreadIds: [thread.id], | ||
| 1705 | }); | ||
| 1706 | yield* Effect.gen(function* () { | ||
| 1707 | const service = yield* start(fixture); | ||
| 1708 | yield* Ref.set(fixture.snapshots, makeSnapshot([thread])); | ||
| 1709 | yield* fixture.publishEvent({ | ||
| 1710 | type: "thread.settled", | ||
| 1711 | id: EventId.make("settled:managed"), | ||
| 1712 | threadId: thread.id, | ||
| 1713 | occurredAt: DateTime.makeUnsafe(NOW), | ||
| 1714 | payload: thread, | ||
| 1715 | }); | ||
| 1716 | yield* Queue.take(fixture.archiveReads); | ||
| 1717 | yield* service.drain; | ||
| 1718 | assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]); | ||
| 1719 | assert.equal(yield* Queue.size(fixture.scriptRuns), 0); | ||
| 1720 | assert.equal(yield* Queue.size(fixture.closedTerminals), 1); | ||
| 1721 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1722 | }), | ||
| 1723 | ), | ||
| 1724 | ); | ||
| 1725 | |||
| 1726 | it.effect("retries a failed shutdown on the next minute without blocking healthy threads", () => | ||
| 1727 | Effect.scoped( | ||
| 1728 | Effect.gen(function* () { | ||
| 1729 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1730 | const failed = settled("failed-stop"); | ||
| 1731 | const healthy = settled("healthy-stop"); | ||
| 1732 | const attempts = yield* Ref.make(0); | ||
| 1733 | const fixture = yield* makeHarness({ | ||
| 1734 | settings, | ||
| 1735 | snapshot: makeSnapshot([failed, healthy]), | ||
| 1736 | onStopUnused: (threadId) => | ||
| 1737 | threadId !== failed.id | ||
| 1738 | ? Effect.void | ||
| 1739 | : Ref.updateAndGet(attempts, (count) => count + 1).pipe( | ||
| 1740 | Effect.flatMap((count) => | ||
| 1741 | count === 1 | ||
| 1742 | ? Effect.fail(new DevServerError({ reason: "status" })) | ||
| 1743 | : Effect.void, | ||
| 1744 | ), | ||
| 1745 | ), | ||
| 1746 | }); | ||
| 1747 | yield* Effect.gen(function* () { | ||
| 1748 | const service = yield* start(fixture); | ||
| 1749 | assert.deepStrictEqual((yield* Ref.get(fixture.devServerStops)).toSorted(), [ | ||
| 1750 | failed.id, | ||
| 1751 | healthy.id, | ||
| 1752 | ]); | ||
| 1753 | yield* TestClock.adjust("1 minute"); | ||
| 1754 | yield* Queue.take(fixture.archiveReads); | ||
| 1755 | yield* service.drain; | ||
| 1756 | assert.equal(yield* Ref.get(attempts), 2); | ||
| 1757 | assert.equal((yield* Ref.get(fixture.devServerStops)).length, 4); | ||
| 1758 | assert.equal(yield* Queue.size(fixture.scriptRuns), 0); | ||
| 1759 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1760 | }), | ||
| 1761 | ), | ||
| 1762 | ); | ||
| 1763 | |||
| 1764 | it.effect("committed archival stops an unsettled thread's server", () => | ||
| 1765 | Effect.scoped( | ||
| 1766 | Effect.gen(function* () { | ||
| 1767 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1768 | const thread = makeThread("archiving", { archivedAt: DateTime.makeUnsafe(NOW) }); | ||
| 1769 | const fixture = yield* makeHarness({ settings, snapshot: makeSnapshot([]) }); | ||
| 1770 | yield* Effect.gen(function* () { | ||
| 1771 | const service = yield* start(fixture); | ||
| 1772 | yield* Ref.set(fixture.snapshots, { | ||
| 1773 | ...makeSnapshot([]), | ||
| 1774 | archivedThreads: [thread], | ||
| 1775 | }); | ||
| 1776 | yield* fixture.publishEvent({ | ||
| 1777 | type: "thread.archived", | ||
| 1778 | id: EventId.make("archived:archiving"), | ||
| 1779 | threadId: thread.id, | ||
| 1780 | occurredAt: DateTime.makeUnsafe(NOW), | ||
| 1781 | payload: thread, | ||
| 1782 | }); | ||
| 1783 | yield* Queue.take(fixture.archiveReads); | ||
| 1784 | yield* service.drain; | ||
| 1785 | assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), [thread.id]); | ||
| 1786 | assert.equal(yield* Queue.size(fixture.scriptRuns), 0); | ||
| 1787 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1788 | }), | ||
| 1789 | ), | ||
| 1790 | ); | ||
| 1791 | |||
| 1792 | it.effect("a settlement event already overtaken by revival does not stop its server", () => | ||
| 1793 | Effect.scoped( | ||
| 1794 | Effect.gen(function* () { | ||
| 1795 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 1796 | const old = settled("revived"); | ||
| 1797 | const active = makeThread("revived", { settledOverride: "active", status: "running" }); | ||
| 1798 | const fixture = yield* makeHarness({ | ||
| 1799 | settings, | ||
| 1800 | snapshot: makeSnapshot([]), | ||
| 1801 | currentThreads: [active], | ||
| 1802 | }); | ||
| 1803 | yield* Effect.gen(function* () { | ||
| 1804 | const service = yield* start(fixture); | ||
| 1805 | yield* Ref.set(fixture.snapshots, makeSnapshot([active])); | ||
| 1806 | yield* fixture.publishEvent({ | ||
| 1807 | type: "thread.settled", | ||
| 1808 | id: EventId.make("settled:revived"), | ||
| 1809 | threadId: old.id, | ||
| 1810 | occurredAt: DateTime.makeUnsafe(NOW), | ||
| 1811 | payload: old, | ||
| 1812 | }); | ||
| 1813 | yield* Queue.take(fixture.archiveReads); | ||
| 1814 | yield* service.drain; | ||
| 1815 | assert.deepStrictEqual(yield* Ref.get(fixture.devServerStops), []); | ||
| 1816 | assert.equal(yield* Queue.size(fixture.closedTerminals), 0); | ||
| 1817 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1818 | }), | ||
| 1819 | ), | ||
| 1820 | ); | ||
| 1821 | }); | ||
| 1822 | |||
| 1080 | describe("ThreadSettlementServiceV2 terminals", () => { | 1823 | describe("ThreadSettlementServiceV2 terminals", () => { |
| 1081 | const appThread = ( | 1824 | const appThread = ( |
| 1082 | id: string, | 1825 | id: string, |
| ... | @@ -1116,7 +1859,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { | ... | @@ -1116,7 +1859,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { |
| 1116 | payload: thread, | 1859 | payload: thread, |
| 1117 | }); | 1860 | }); |
| 1118 | 1861 | ||
| 1119 | it.effect("closes the thread's idle terminals when it settles", () => | 1862 | it.effect("closes all of the thread's terminals and browser tabs when it settles", () => |
| 1120 | Effect.scoped( | 1863 | Effect.scoped( |
| 1121 | Effect.gen(function* () { | 1864 | Effect.gen(function* () { |
| 1122 | const thread = appThread("settled-thread", "settled"); | 1865 | const thread = appThread("settled-thread", "settled"); |
| ... | @@ -1124,12 +1867,88 @@ describe("ThreadSettlementServiceV2 terminals", () => { | ... | @@ -1124,12 +1867,88 @@ describe("ThreadSettlementServiceV2 terminals", () => { |
| 1124 | snapshot: makeSnapshot([]), | 1867 | snapshot: makeSnapshot([]), |
| 1125 | currentThreads: [thread], | 1868 | currentThreads: [thread], |
| 1126 | }); | 1869 | }); |
| 1870 | yield* fixture.previews.open({ threadId: thread.id }); | ||
| 1871 | yield* fixture.previews.open({ threadId: thread.id, runtime: "server" }); | ||
| 1872 | const peerId = ThreadId.make("other-chat"); | ||
| 1873 | const peerTab = yield* fixture.previews.open({ threadId: peerId }); | ||
| 1874 | const previewEvents = yield* fixture.previews.subscribeEvents; | ||
| 1127 | 1875 | ||
| 1128 | yield* Effect.gen(function* () { | 1876 | yield* Effect.gen(function* () { |
| 1129 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | 1877 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; |
| 1130 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | 1878 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); |
| 1131 | yield* fixture.publishEvent(settledEvent(thread)); | 1879 | yield* fixture.publishEvent(settledEvent(thread)); |
| 1132 | assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: thread.id }); | 1880 | assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), { |
| 1881 | threadId: thread.id, | ||
| 1882 | }); | ||
| 1883 | expect((yield* PubSub.take(previewEvents)).type).toBe("closed"); | ||
| 1884 | expect((yield* PubSub.take(previewEvents)).type).toBe("closed"); | ||
| 1885 | expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([]); | ||
| 1886 | expect((yield* fixture.previews.list({ threadId: peerId })).sessions).toEqual([peerTab]); | ||
| 1887 | const active = appThread("settled-thread", "active"); | ||
| 1888 | yield* fixture.publishEvent({ | ||
| 1889 | type: "thread.unsettled", | ||
| 1890 | id: EventId.make("event:unsettled:managed"), | ||
| 1891 | threadId: active.id, | ||
| 1892 | occurredAt: DateTime.makeUnsafe(NOW), | ||
| 1893 | payload: active, | ||
| 1894 | }); | ||
| 1895 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1896 | }), | ||
| 1897 | ), | ||
| 1898 | ); | ||
| 1899 | |||
| 1900 | it.effect("preserves resources opened during an automatic settlement", () => | ||
| 1901 | Effect.scoped( | ||
| 1902 | Effect.gen(function* () { | ||
| 1903 | const thread = appThread("automatic-thread", "settled", "/worktrees/automatic"); | ||
| 1904 | const marker = appThread("marker-thread", "settled", "/worktrees/marker"); | ||
| 1905 | const fixture = yield* makeHarness({ | ||
| 1906 | snapshot: makeSnapshot([]), | ||
| 1907 | currentThreads: [thread, marker], | ||
| 1908 | terminals: [terminal(thread.id)], | ||
| 1909 | existingWorktreePaths: ["/worktrees/automatic", "/worktrees/marker"], | ||
| 1910 | }); | ||
| 1911 | const tab = yield* fixture.previews.open({ threadId: thread.id }); | ||
| 1912 | |||
| 1913 | yield* Effect.gen(function* () { | ||
| 1914 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1915 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1916 | yield* fixture.publishEvent( | ||
| 1917 | settledEvent(thread), | ||
| 1918 | CommandId.make("server:auto-settle:automatic-thread:1"), | ||
| 1919 | ); | ||
| 1920 | yield* fixture.publishEvent(settledEvent(marker)); | ||
| 1921 | expect((yield* Queue.take(fixture.scriptRuns)).threadId).toBe(marker.id); | ||
| 1922 | expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id }); | ||
| 1923 | expect(yield* Queue.size(fixture.closedTerminals)).toBe(0); | ||
| 1924 | expect((yield* fixture.previews.list({ threadId: thread.id })).sessions).toEqual([tab]); | ||
| 1925 | expect(yield* Ref.get(fixture.commands)).toMatchObject([ | ||
| 1926 | { type: "thread.unsettle", threadId: thread.id, snapshotSequence: 2 }, | ||
| 1927 | ]); | ||
| 1928 | }).pipe(Effect.provide(fixture.layer)); | ||
| 1929 | }), | ||
| 1930 | ), | ||
| 1931 | ); | ||
| 1932 | |||
| 1933 | it.effect("ignores a stale settle event after the chat was revived and settled again", () => | ||
| 1934 | Effect.scoped( | ||
| 1935 | Effect.gen(function* () { | ||
| 1936 | const previous = appThread("resettled-thread", "settled"); | ||
| 1937 | const current = { ...previous, settledAt: DateTime.makeUnsafe(Date.parse(NOW) + 1) }; | ||
| 1938 | const marker = appThread("marker-thread", "settled"); | ||
| 1939 | const fixture = yield* makeHarness({ | ||
| 1940 | snapshot: makeSnapshot([]), | ||
| 1941 | currentThreads: [current, marker], | ||
| 1942 | }); | ||
| 1943 | const tab = yield* fixture.previews.open({ threadId: current.id }); | ||
| 1944 | |||
| 1945 | yield* Effect.gen(function* () { | ||
| 1946 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 1947 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | ||
| 1948 | yield* fixture.publishEvent(settledEvent(previous)); | ||
| 1949 | yield* fixture.publishEvent(settledEvent(marker)); | ||
| 1950 | expect(yield* Queue.take(fixture.closedTerminals)).toEqual({ threadId: marker.id }); | ||
| 1951 | expect((yield* fixture.previews.list({ threadId: current.id })).sessions).toEqual([tab]); | ||
| 1133 | }).pipe(Effect.provide(fixture.layer)); | 1952 | }).pipe(Effect.provide(fixture.layer)); |
| 1134 | }), | 1953 | }), |
| 1135 | ), | 1954 | ), |
| ... | @@ -1153,7 +1972,9 @@ describe("ThreadSettlementServiceV2 terminals", () => { | ... | @@ -1153,7 +1972,9 @@ describe("ThreadSettlementServiceV2 terminals", () => { |
| 1153 | // Events run in order, so the first close belongs to the later | 1972 | // Events run in order, so the first close belongs to the later |
| 1154 | // event only if the re-engaged thread was skipped. | 1973 | // event only if the re-engaged thread was skipped. |
| 1155 | yield* fixture.publishEvent(settledEvent(marker)); | 1974 | yield* fixture.publishEvent(settledEvent(marker)); |
| 1156 | assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: marker.id }); | 1975 | assert.deepStrictEqual(yield* Queue.take(fixture.closedTerminals), { |
| 1976 | threadId: marker.id, | ||
| 1977 | }); | ||
| 1157 | }).pipe(Effect.provide(fixture.layer)); | 1978 | }).pipe(Effect.provide(fixture.layer)); |
| 1158 | }), | 1979 | }), |
| 1159 | ), | 1980 | ), |
| ... | @@ -1188,32 +2009,37 @@ describe("ThreadSettlementServiceV2 terminals", () => { | ... | @@ -1188,32 +2009,37 @@ describe("ThreadSettlementServiceV2 terminals", () => { |
| 1188 | ), | 2009 | ), |
| 1189 | ); | 2010 | ); |
| 1190 | 2011 | ||
| 1191 | it.effect("skips the settle script for a thread re-engaged while its shells close", () => | 2012 | it.effect( |
| 1192 | Effect.scoped( | 2013 | "keeps browser tabs and skips the settle script when revived while terminals close", |
| 1193 | Effect.gen(function* () { | 2014 | () => |
| 1194 | const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging"); | 2015 | Effect.scoped( |
| 1195 | const marker = appThread("marker-thread", "settled", "/worktrees/marker"); | 2016 | Effect.gen(function* () { |
| 1196 | const currentThreads = [settled, marker]; | 2017 | const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging"); |
| 1197 | const fixture = yield* makeHarness({ | 2018 | const marker = appThread("marker-thread", "settled", "/worktrees/marker"); |
| 1198 | snapshot: makeSnapshot([]), | 2019 | const currentThreads = [settled, marker]; |
| 1199 | currentThreads, | 2020 | const fixture = yield* makeHarness({ |
| 1200 | existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"], | 2021 | snapshot: makeSnapshot([]), |
| 1201 | // The user sends a message while the first close checks processes. | 2022 | currentThreads, |
| 1202 | onCloseIdle: () => | 2023 | existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"], |
| 1203 | Effect.sync(() => { | 2024 | onCloseTerminals: () => |
| 1204 | currentThreads[0] = { ...settled, settledOverride: "active" }; | 2025 | Effect.sync(() => { |
| 1205 | }), | 2026 | currentThreads[0] = { ...settled, settledOverride: "active" }; |
| 1206 | }); | 2027 | }), |
| 2028 | }); | ||
| 2029 | const tab = yield* fixture.previews.open({ threadId: settled.id }); | ||
| 1207 | 2030 | ||
| 1208 | yield* Effect.gen(function* () { | 2031 | yield* Effect.gen(function* () { |
| 1209 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | 2032 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; |
| 1210 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); | 2033 | yield* startHarness(service, fixture.activation, fixture.snapshotReads); |
| 1211 | yield* fixture.publishEvent(settledEvent(settled)); | 2034 | yield* fixture.publishEvent(settledEvent(settled)); |
| 1212 | yield* fixture.publishEvent(settledEvent(marker)); | 2035 | yield* fixture.publishEvent(settledEvent(marker)); |
| 1213 | assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id); | 2036 | assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id); |
| 1214 | }).pipe(Effect.provide(fixture.layer)); | 2037 | expect((yield* fixture.previews.list({ threadId: settled.id })).sessions).toEqual([ |
| 1215 | }), | 2038 | tab, |
| 1216 | ), | 2039 | ]); |
| 2040 | }).pipe(Effect.provide(fixture.layer)); | ||
| 2041 | }), | ||
| 2042 | ), | ||
| 1217 | ); | 2043 | ); |
| 1218 | 2044 | ||
| 1219 | it.effect("runs the settle script once when a settlement is re-emitted", () => | 2045 | it.effect("runs the settle script once when a settlement is re-emitted", () => |
| ... | @@ -1327,3 +2153,408 @@ describe("isSnoozed", () => { | ... | @@ -1327,3 +2153,408 @@ describe("isSnoozed", () => { |
| 1327 | ).toBe(false); | 2153 | ).toBe(false); |
| 1328 | }); | 2154 | }); |
| 1329 | }); | 2155 | }); |
| 2156 | |||
| 2157 | describe("settled thread archival", () => { | ||
| 2158 | const settings = { | ||
| 2159 | ...DEFAULT_SERVER_SETTINGS, | ||
| 2160 | sidebarAutoSettleOnMerge: false, | ||
| 2161 | sidebarAutoSettleAfterDays: null, | ||
| 2162 | }; | ||
| 2163 | |||
| 2164 | const start = Effect.fn("startArchiveHarness")(function* ( | ||
| 2165 | fixture: Effect.Success<ReturnType<typeof makeHarness>>, | ||
| 2166 | ) { | ||
| 2167 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 2168 | yield* service.start(); | ||
| 2169 | yield* Deferred.succeed(fixture.activation, undefined); | ||
| 2170 | yield* Queue.take(fixture.archiveReads); | ||
| 2171 | yield* service.drain; | ||
| 2172 | }); | ||
| 2173 | |||
| 2174 | it.effect( | ||
| 2175 | "archives from a live Mergebot merge update without a minute timer or provider lookup", | ||
| 2176 | () => | ||
| 2177 | Effect.scoped( | ||
| 2178 | Effect.gen(function* () { | ||
| 2179 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2180 | const thread = makeThread("live-merge", { | ||
| 2181 | settledOverride: "settled", | ||
| 2182 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2183 | pullRequests: [link(1)], | ||
| 2184 | }); | ||
| 2185 | const detail = yield* Ref.make<MergebotPullRequestDetails | null>(null); | ||
| 2186 | const archived = yield* Deferred.make<void>(); | ||
| 2187 | const fixture = yield* makeHarness({ | ||
| 2188 | settings, | ||
| 2189 | snapshot: makeSnapshot([thread]), | ||
| 2190 | mergebotDetail: () => Ref.get(detail), | ||
| 2191 | onDispatch: (command) => | ||
| 2192 | command.type === "thread.archive" | ||
| 2193 | ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid) | ||
| 2194 | : Effect.void, | ||
| 2195 | }); | ||
| 2196 | yield* Effect.gen(function* () { | ||
| 2197 | const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; | ||
| 2198 | yield* start(fixture); | ||
| 2199 | yield* Deferred.await(fixture.mergebotSubscribed); | ||
| 2200 | expect(yield* Ref.get(fixture.commands)).toEqual([]); | ||
| 2201 | yield* Ref.set(detail, mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })); | ||
| 2202 | yield* fixture.publishMergebotChange(1); | ||
| 2203 | yield* TestClock.adjust("100 millis"); | ||
| 2204 | yield* Deferred.await(archived); | ||
| 2205 | yield* service.drain; | ||
| 2206 | expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([ | ||
| 2207 | "thread.archive", | ||
| 2208 | ]); | ||
| 2209 | expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); | ||
| 2210 | }).pipe(Effect.provide(fixture.layer)); | ||
| 2211 | }), | ||
| 2212 | ), | ||
| 2213 | ); | ||
| 2214 | |||
| 2215 | it.effect("Saturday archival stays available while Mergebot is disconnected", () => | ||
| 2216 | Effect.scoped( | ||
| 2217 | Effect.gen(function* () { | ||
| 2218 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2219 | const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(Date.parse(NOW)); | ||
| 2220 | const fixture = yield* makeHarness({ | ||
| 2221 | settings, | ||
| 2222 | snapshot: makeSnapshot([ | ||
| 2223 | makeThread("offline-saturday", { | ||
| 2224 | settledOverride: "settled", | ||
| 2225 | settledAt: DateTime.makeUnsafe(boundary), | ||
| 2226 | pullRequests: [link(1)], | ||
| 2227 | }), | ||
| 2228 | ]), | ||
| 2229 | mergebotDetail: () => Effect.succeed(null), | ||
| 2230 | }); | ||
| 2231 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2232 | expect((yield* Ref.get(fixture.commands)).map((command) => command.type)).toEqual([ | ||
| 2233 | "thread.archive", | ||
| 2234 | ]); | ||
| 2235 | expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); | ||
| 2236 | }), | ||
| 2237 | ), | ||
| 2238 | ); | ||
| 2239 | |||
| 2240 | it("uses Saturday at nine in the server timezone, including a DST week", () => { | ||
| 2241 | const local = DateTime.zoneMakeLocal(); | ||
| 2242 | const saturday = DateTime.makeZonedUnsafe( | ||
| 2243 | { year: 2026, month: 11, day: 7, hour: 9 }, | ||
| 2244 | { timeZone: local, adjustForTimeZone: true }, | ||
| 2245 | ); | ||
| 2246 | const friday = DateTime.subtract(saturday, { days: 1 }); | ||
| 2247 | expect( | ||
| 2248 | ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(saturday)), | ||
| 2249 | ).toBe(DateTime.toEpochMillis(saturday)); | ||
| 2250 | expect( | ||
| 2251 | ThreadSettlementService.lastSaturdayArchiveBoundary(DateTime.toEpochMillis(friday)), | ||
| 2252 | ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 }))); | ||
| 2253 | expect( | ||
| 2254 | ThreadSettlementService.lastSaturdayArchiveBoundary( | ||
| 2255 | DateTime.toEpochMillis(DateTime.subtract(saturday, { milliseconds: 1 })), | ||
| 2256 | ), | ||
| 2257 | ).toBe(DateTime.toEpochMillis(DateTime.subtract(saturday, { weeks: 1 }))); | ||
| 2258 | }); | ||
| 2259 | |||
| 2260 | it.effect( | ||
| 2261 | "catches up after restart, excluding fresh settlements and busy threads with auto-settlement disabled", | ||
| 2262 | () => | ||
| 2263 | Effect.scoped( | ||
| 2264 | Effect.gen(function* () { | ||
| 2265 | const nowMs = Date.parse(NOW); | ||
| 2266 | yield* TestClock.setTime(nowMs); | ||
| 2267 | const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs); | ||
| 2268 | const fixture = yield* makeHarness({ | ||
| 2269 | settings, | ||
| 2270 | snapshot: makeSnapshot([ | ||
| 2271 | makeThread("old", { | ||
| 2272 | settledOverride: "settled", | ||
| 2273 | settledAt: DateTime.makeUnsafe(boundary), | ||
| 2274 | }), | ||
| 2275 | makeThread("fresh", { | ||
| 2276 | settledOverride: "settled", | ||
| 2277 | settledAt: DateTime.makeUnsafe(boundary + 1), | ||
| 2278 | }), | ||
| 2279 | makeThread("busy", { | ||
| 2280 | settledOverride: "settled", | ||
| 2281 | settledAt: DateTime.makeUnsafe(boundary), | ||
| 2282 | activeRunId: RunId.make("run"), | ||
| 2283 | status: "running", | ||
| 2284 | }), | ||
| 2285 | makeThread("queued", { | ||
| 2286 | settledOverride: "settled", | ||
| 2287 | settledAt: DateTime.makeUnsafe(boundary), | ||
| 2288 | latestUserMessageAt: DateTime.makeUnsafe(nowMs), | ||
| 2289 | }), | ||
| 2290 | makeThread("active", { settledOverride: "active" }), | ||
| 2291 | makeThread("backdated", { | ||
| 2292 | settledOverride: "settled", | ||
| 2293 | settledAt: DateTime.makeUnsafe(boundary - DAY_MS), | ||
| 2294 | }), | ||
| 2295 | ]), | ||
| 2296 | settlementTimes: new Map([[ThreadId.make("backdated"), DateTime.makeUnsafe(nowMs)]]), | ||
| 2297 | }); | ||
| 2298 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2299 | assert.deepStrictEqual( | ||
| 2300 | (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]), | ||
| 2301 | [["thread.archive", ThreadId.make("old")]], | ||
| 2302 | ); | ||
| 2303 | }), | ||
| 2304 | ), | ||
| 2305 | ); | ||
| 2306 | |||
| 2307 | it.effect( | ||
| 2308 | "archives a settled managed thread only when every visible PR merged, reading shared PRs once", | ||
| 2309 | () => | ||
| 2310 | Effect.scoped( | ||
| 2311 | Effect.gen(function* () { | ||
| 2312 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2313 | const reads = yield* Ref.make<ReadonlyArray<number>>([]); | ||
| 2314 | const threads = ["merged-a", "merged-b", "stack-open"].map((id) => | ||
| 2315 | makeThread(id, { | ||
| 2316 | settledOverride: "settled", | ||
| 2317 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2318 | pullRequests: id === "stack-open" ? [link(1), link(2)] : [link(1)], | ||
| 2319 | }), | ||
| 2320 | ); | ||
| 2321 | const fixture = yield* makeHarness({ | ||
| 2322 | settings, | ||
| 2323 | snapshot: makeSnapshot(threads), | ||
| 2324 | managedThreadIds: threads.map((thread) => thread.id), | ||
| 2325 | mergebotDetail: (reference) => | ||
| 2326 | Ref.update(reads, (values) => [...values, reference.number]).pipe( | ||
| 2327 | Effect.as( | ||
| 2328 | mergebotDetail(reference.number, { | ||
| 2329 | lifecycle: reference.number === 1 ? "merged" : "open", | ||
| 2330 | }), | ||
| 2331 | ), | ||
| 2332 | ), | ||
| 2333 | }); | ||
| 2334 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2335 | assert.deepStrictEqual( | ||
| 2336 | (yield* Ref.get(fixture.commands)).map((command) => command.threadId).toSorted(), | ||
| 2337 | [ThreadId.make("merged-a"), ThreadId.make("merged-b")], | ||
| 2338 | ); | ||
| 2339 | assert.deepStrictEqual((yield* Ref.get(reads)).toSorted(), [1, 2]); | ||
| 2340 | }), | ||
| 2341 | ), | ||
| 2342 | ); | ||
| 2343 | |||
| 2344 | it.effect("a managed lookup failure leaves healthy archival and auto-settlement running", () => | ||
| 2345 | Effect.scoped( | ||
| 2346 | Effect.gen(function* () { | ||
| 2347 | const nowMs = Date.parse(NOW); | ||
| 2348 | yield* TestClock.setTime(nowMs); | ||
| 2349 | const boundary = ThreadSettlementService.lastSaturdayArchiveBoundary(nowMs); | ||
| 2350 | const failed = makeThread("failed-lookup", { | ||
| 2351 | settledOverride: "settled", | ||
| 2352 | settledAt: DateTime.makeUnsafe(boundary), | ||
| 2353 | pullRequests: [{ ...link(1), repository: "unconfigured/repository" }], | ||
| 2354 | }); | ||
| 2355 | const fixture = yield* makeHarness({ | ||
| 2356 | settings: { ...settings, sidebarAutoSettleAfterDays: 1 }, | ||
| 2357 | snapshot: makeSnapshot([ | ||
| 2358 | failed, | ||
| 2359 | makeThread("plain-archive", { | ||
| 2360 | settledOverride: "settled", | ||
| 2361 | settledAt: DateTime.makeUnsafe(boundary), | ||
| 2362 | }), | ||
| 2363 | makeThread("inactive", { | ||
| 2364 | latestUserMessageAt: DateTime.makeUnsafe(nowMs - 30 * DAY_MS), | ||
| 2365 | }), | ||
| 2366 | ]), | ||
| 2367 | managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })), | ||
| 2368 | }); | ||
| 2369 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2370 | assert.deepStrictEqual( | ||
| 2371 | (yield* Ref.get(fixture.commands)).map((command) => [command.type, command.threadId]), | ||
| 2372 | [ | ||
| 2373 | ["thread.archive", ThreadId.make("plain-archive")], | ||
| 2374 | ["thread.auto-settle", ThreadId.make("inactive")], | ||
| 2375 | ], | ||
| 2376 | ); | ||
| 2377 | }), | ||
| 2378 | ), | ||
| 2379 | ); | ||
| 2380 | |||
| 2381 | it.effect("observes configured work PRs from the repository's main checkout", () => | ||
| 2382 | Effect.scoped( | ||
| 2383 | Effect.gen(function* () { | ||
| 2384 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2385 | const thread = makeThread("root-work-pr", { | ||
| 2386 | settledOverride: "settled", | ||
| 2387 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2388 | worktreePath: null, | ||
| 2389 | pullRequests: [link(1)], | ||
| 2390 | }); | ||
| 2391 | const fixture = yield* makeHarness({ | ||
| 2392 | settings, | ||
| 2393 | snapshot: makeSnapshot([thread]), | ||
| 2394 | managedLookup: () => Effect.fail(new ManagedWorkspaceError({ reason: "workspace" })), | ||
| 2395 | mergebotDetail: () => | ||
| 2396 | Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })), | ||
| 2397 | }); | ||
| 2398 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2399 | assert.deepStrictEqual( | ||
| 2400 | (yield* Ref.get(fixture.commands)).map((command) => command.type), | ||
| 2401 | ["thread.archive"], | ||
| 2402 | ); | ||
| 2403 | }), | ||
| 2404 | ), | ||
| 2405 | ); | ||
| 2406 | |||
| 2407 | it.effect( | ||
| 2408 | "settling after merge archives immediately and skips the managed workspace settle script", | ||
| 2409 | () => | ||
| 2410 | Effect.scoped( | ||
| 2411 | Effect.gen(function* () { | ||
| 2412 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2413 | const thread = { | ||
| 2414 | ...makeThread("settle-after-merge", { | ||
| 2415 | settledOverride: "settled", | ||
| 2416 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2417 | worktreePath: "/workspace/managed", | ||
| 2418 | pullRequests: [link(1)], | ||
| 2419 | }), | ||
| 2420 | lastVisitedAt: null, | ||
| 2421 | }; | ||
| 2422 | const archived = yield* Deferred.make<void>(); | ||
| 2423 | const fixture = yield* makeHarness({ | ||
| 2424 | settings, | ||
| 2425 | snapshot: makeSnapshot([]), | ||
| 2426 | currentThreads: [thread], | ||
| 2427 | managedThreadIds: [thread.id], | ||
| 2428 | existingWorktreePaths: ["/workspace/managed"], | ||
| 2429 | mergebotDetail: () => | ||
| 2430 | Effect.succeed(mergebotDetail(1, { lifecycle: "merged", mergedAt: NOW })), | ||
| 2431 | onDispatch: (command) => | ||
| 2432 | command.type === "thread.archive" | ||
| 2433 | ? Deferred.succeed(archived, undefined).pipe(Effect.asVoid) | ||
| 2434 | : Effect.void, | ||
| 2435 | }); | ||
| 2436 | yield* Effect.gen(function* () { | ||
| 2437 | yield* start(fixture); | ||
| 2438 | yield* Ref.set(fixture.snapshots, makeSnapshot([thread])); | ||
| 2439 | yield* fixture.publishEvent({ | ||
| 2440 | type: "thread.settled", | ||
| 2441 | id: EventId.make("settled-after-merge"), | ||
| 2442 | threadId: thread.id, | ||
| 2443 | occurredAt: DateTime.makeUnsafe(NOW), | ||
| 2444 | payload: thread, | ||
| 2445 | }); | ||
| 2446 | yield* Deferred.await(archived); | ||
| 2447 | assert.equal(yield* Ref.get(fixture.fullArchiveReadCount), 1); | ||
| 2448 | assert.deepStrictEqual(yield* Ref.get(fixture.singleArchiveReads), [thread.id]); | ||
| 2449 | assert.equal(yield* Queue.size(fixture.scriptRuns), 0); | ||
| 2450 | assert.deepStrictEqual( | ||
| 2451 | (yield* Ref.get(fixture.commands)).map((command) => command.type), | ||
| 2452 | ["thread.archive"], | ||
| 2453 | ); | ||
| 2454 | }).pipe(Effect.provide(fixture.layer)); | ||
| 2455 | }), | ||
| 2456 | ), | ||
| 2457 | ); | ||
| 2458 | |||
| 2459 | it.effect( | ||
| 2460 | "wakes failed current-head CI through a notification command without dispatching a message or starting a run", | ||
| 2461 | () => | ||
| 2462 | Effect.scoped( | ||
| 2463 | Effect.gen(function* () { | ||
| 2464 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2465 | const thread = makeThread("ci-failed", { | ||
| 2466 | settledOverride: "settled", | ||
| 2467 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2468 | pullRequests: [link(1)], | ||
| 2469 | }); | ||
| 2470 | const fixture = yield* makeHarness({ | ||
| 2471 | settings, | ||
| 2472 | snapshot: makeSnapshot([thread]), | ||
| 2473 | managedThreadIds: [thread.id], | ||
| 2474 | mergebotDetail: () => | ||
| 2475 | Effect.succeed( | ||
| 2476 | mergebotDetail(1, { | ||
| 2477 | checks: [failedCheck()], | ||
| 2478 | }), | ||
| 2479 | ), | ||
| 2480 | }); | ||
| 2481 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2482 | const commands = yield* Ref.get(fixture.commands); | ||
| 2483 | assert.equal(commands.length, 1); | ||
| 2484 | assert.equal(commands[0]?.type, "thread.ci-failed"); | ||
| 2485 | if (commands[0]?.type !== "thread.ci-failed") return; | ||
| 2486 | assert.equal(commands[0].url, "https://mergebot.example.test/owner/repository/pull/1"); | ||
| 2487 | assert.ok(commands[0].itemId.includes(":current-head:")); | ||
| 2488 | }), | ||
| 2489 | ), | ||
| 2490 | ); | ||
| 2491 | |||
| 2492 | it.effect("records each failed check attempt once and wakes again for a same-head rerun", () => | ||
| 2493 | Effect.scoped( | ||
| 2494 | Effect.gen(function* () { | ||
| 2495 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2496 | const thread = makeThread("rerun-ci", { | ||
| 2497 | settledOverride: "settled", | ||
| 2498 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2499 | pullRequests: [link(1)], | ||
| 2500 | }); | ||
| 2501 | const original = failedCheck({ completedAt: "2026-08-28T11:00:00.000Z" }); | ||
| 2502 | const first = yield* makeHarness({ | ||
| 2503 | settings, | ||
| 2504 | snapshot: makeSnapshot([thread]), | ||
| 2505 | managedThreadIds: [thread.id], | ||
| 2506 | mergebotDetail: () => Effect.succeed(mergebotDetail(1, { checks: [original] })), | ||
| 2507 | }); | ||
| 2508 | yield* start(first).pipe(Effect.provide(first.layer)); | ||
| 2509 | const previous = (yield* Ref.get(first.commands))[0]; | ||
| 2510 | assert.ok(previous?.type === "thread.ci-failed"); | ||
| 2511 | for (const changed of [ | ||
| 2512 | null, | ||
| 2513 | { completedAt: "2026-08-28T11:30:00.000Z" }, | ||
| 2514 | { id: "rerun-check-2" }, | ||
| 2515 | ]) { | ||
| 2516 | const fixture = yield* makeHarness({ | ||
| 2517 | settings, | ||
| 2518 | snapshot: makeSnapshot([thread]), | ||
| 2519 | managedThreadIds: [thread.id], | ||
| 2520 | previousItemIds: new Set([previous.itemId]), | ||
| 2521 | mergebotDetail: () => | ||
| 2522 | Effect.succeed(mergebotDetail(1, { checks: [{ ...original, ...changed }] })), | ||
| 2523 | }); | ||
| 2524 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2525 | const commands = yield* Ref.get(fixture.commands); | ||
| 2526 | assert.equal(commands.length, changed === null ? 0 : 1); | ||
| 2527 | if (commands[0]?.type === "thread.ci-failed") | ||
| 2528 | assert.notEqual(commands[0].itemId, previous.itemId); | ||
| 2529 | } | ||
| 2530 | }), | ||
| 2531 | ), | ||
| 2532 | ); | ||
| 2533 | |||
| 2534 | it.effect("ignores stale snapshots and failures already recorded for the current head", () => | ||
| 2535 | Effect.scoped( | ||
| 2536 | Effect.gen(function* () { | ||
| 2537 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 2538 | const thread = makeThread("known-ci", { | ||
| 2539 | settledOverride: "settled", | ||
| 2540 | settledAt: DateTime.makeUnsafe(NOW), | ||
| 2541 | pullRequests: [link(1)], | ||
| 2542 | }); | ||
| 2543 | for (const previousCiNotice of [false, true]) { | ||
| 2544 | const fixture = yield* makeHarness({ | ||
| 2545 | settings, | ||
| 2546 | snapshot: makeSnapshot([thread]), | ||
| 2547 | previousCiNotice, | ||
| 2548 | mergebotDetail: () => | ||
| 2549 | Effect.succeed( | ||
| 2550 | previousCiNotice ? mergebotDetail(1, { checks: [failedCheck()] }) : null, | ||
| 2551 | ), | ||
| 2552 | }); | ||
| 2553 | yield* start(fixture).pipe(Effect.provide(fixture.layer)); | ||
| 2554 | expect(yield* Ref.get(fixture.commands)).toEqual([]); | ||
| 2555 | expect(yield* Ref.get(fixture.providerDetailReads)).toBe(0); | ||
| 2556 | } | ||
| 2557 | }), | ||
| 2558 | ), | ||
| 2559 | ); | ||
| 2560 | }); |
apps/server/src/orchestration-v2/ThreadSettlementService.ts+439-37| ... | @@ -1,10 +1,19 @@ | ... | @@ -1,10 +1,19 @@ |
| 1 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | ||
| 2 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | 1 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 3 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | 2 | import { |
| 3 | threadPullRequestKeyOf, | ||
| 4 | normalizeThreadPullRequestKey, | ||
| 5 | legacyThreadPullRequestKey, | ||
| 6 | visibleThreadPullRequests, | ||
| 7 | } from "@t3tools/shared/threadPullRequests"; | ||
| 4 | import { | 8 | import { |
| 5 | CommandId, | 9 | CommandId, |
| 10 | TurnItemId, | ||
| 11 | MergebotPullRequestCheck, | ||
| 12 | isMergebotRepository, | ||
| 6 | type ThreadId, | 13 | type ThreadId, |
| 7 | type OrchestrationV2DomainEvent, | 14 | type PullRequestRef, |
| 15 | type OrchestrationV2AppThread, | ||
| 16 | type OrchestrationV2StoredEvent, | ||
| 8 | type OrchestrationV2ThreadShell, | 17 | type OrchestrationV2ThreadShell, |
| 9 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 10 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 19 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| ... | @@ -15,6 +24,8 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -15,6 +24,8 @@ import * as DateTime from "effect/DateTime"; |
| 15 | import * as Effect from "effect/Effect"; | 24 | import * as Effect from "effect/Effect"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | 25 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Layer from "effect/Layer"; | 26 | import * as Layer from "effect/Layer"; |
| 27 | import * as Schema from "effect/Schema"; | ||
| 28 | import * as Hex from "effect/encoding/Hex"; | ||
| 18 | import * as Schedule from "effect/Schedule"; | 29 | import * as Schedule from "effect/Schedule"; |
| 19 | import type * as Scope from "effect/Scope"; | 30 | import type * as Scope from "effect/Scope"; |
| 20 | import * as Stream from "effect/Stream"; | 31 | import * as Stream from "effect/Stream"; |
| ... | @@ -23,11 +34,30 @@ import * as GitManager from "../git/GitManager.ts"; | ... | @@ -23,11 +34,30 @@ import * as GitManager from "../git/GitManager.ts"; |
| 23 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 34 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 24 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | 35 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 25 | import * as ServerSettings from "../serverSettings.ts"; | 36 | import * as ServerSettings from "../serverSettings.ts"; |
| 37 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; | ||
| 26 | import { forkParked } from "../serverActivation.ts"; | 38 | import { forkParked } from "../serverActivation.ts"; |
| 27 | import * as TerminalManager from "../terminal/Manager.ts"; | 39 | import * as TerminalManager from "../terminal/Manager.ts"; |
| 40 | import * as PreviewManager from "../preview/Manager.ts"; | ||
| 28 | import * as ProjectStore from "./ProjectStore.ts"; | 41 | import * as ProjectStore from "./ProjectStore.ts"; |
| 29 | import * as Orchestrator from "./Orchestrator.ts"; | 42 | import * as Orchestrator from "./Orchestrator.ts"; |
| 30 | import * as ProjectionStore from "./ProjectionStore.ts"; | 43 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 44 | import * as ManagedWorkspaces from "../workspace/ManagedWorkspaces.ts"; | ||
| 45 | import * as DevServer from "../workspace/DevServer.ts"; | ||
| 46 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; | ||
| 47 | import { isManagedWorkspaceArchiveCandidate } from "../workspace/managedWorkspaceArchive.ts"; | ||
| 48 | |||
| 49 | const encodeCiFailureIdentity = Schema.encodeSync( | ||
| 50 | Schema.fromJsonString( | ||
| 51 | Schema.Array( | ||
| 52 | MergebotPullRequestCheck.mapFields(({ id, status, conclusion, completedAt }) => ({ | ||
| 53 | id, | ||
| 54 | status, | ||
| 55 | conclusion, | ||
| 56 | completedAt, | ||
| 57 | })), | ||
| 58 | ), | ||
| 59 | ), | ||
| 60 | ); | ||
| 31 | 61 | ||
| 32 | export interface SettlementPullRequest { | 62 | export interface SettlementPullRequest { |
| 33 | readonly state: "open" | "closed" | "merged"; | 63 | readonly state: "open" | "closed" | "merged"; |
| ... | @@ -35,6 +65,20 @@ export interface SettlementPullRequest { | ... | @@ -35,6 +65,20 @@ export interface SettlementPullRequest { |
| 35 | readonly mergedAt?: string | null; | 65 | readonly mergedAt?: string | null; |
| 36 | } | 66 | } |
| 37 | 67 | ||
| 68 | export function lastSaturdayArchiveBoundary(nowMs: number): number { | ||
| 69 | const now = DateTime.makeZonedUnsafe(nowMs, { timeZone: DateTime.zoneMakeLocal() }); | ||
| 70 | const days = (DateTime.toParts(now).weekDay + 1) % 7; | ||
| 71 | const boundary = DateTime.setParts(DateTime.subtract(now, { days }), { | ||
| 72 | hour: 9, | ||
| 73 | minute: 0, | ||
| 74 | second: 0, | ||
| 75 | millisecond: 0, | ||
| 76 | }); | ||
| 77 | return DateTime.toEpochMillis( | ||
| 78 | DateTime.toEpochMillis(boundary) > nowMs ? DateTime.subtract(boundary, { weeks: 1 }) : boundary, | ||
| 79 | ); | ||
| 80 | } | ||
| 81 | |||
| 38 | const DAY_MS = 24 * 60 * 60 * 1_000; | 82 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 39 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; | 83 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; |
| 40 | 84 | ||
| ... | @@ -144,10 +188,8 @@ export function isAutoSettlementCandidate( | ... | @@ -144,10 +188,8 @@ export function isAutoSettlementCandidate( |
| 144 | if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; | 188 | if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; |
| 145 | // Blocked-on-you work must never park behind a settled override. | 189 | // Blocked-on-you work must never park behind a settled override. |
| 146 | if (thread.pendingRuntimeRequest !== null) return false; | 190 | if (thread.pendingRuntimeRequest !== null) return false; |
| 147 | // A live run, or background work that will wake the agent, is not | ||
| 148 | // staleness. A dev server left running is: the agent is done. | ||
| 149 | if (thread.activityRunStatus != null) return false; | 191 | if (thread.activityRunStatus != null) return false; |
| 150 | if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; | 192 | if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false; |
| 151 | if (threadHasQueuedTurnStart(thread, nowMs)) return false; | 193 | if (threadHasQueuedTurnStart(thread, nowMs)) return false; |
| 152 | const snoozedUntilMs = toMillis(thread.snoozedUntil); | 194 | const snoozedUntilMs = toMillis(thread.snoozedUntil); |
| 153 | if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; | 195 | if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; |
| ... | @@ -203,12 +245,17 @@ export function resolveAutoSettlementAt(input: { | ... | @@ -203,12 +245,17 @@ export function resolveAutoSettlementAt(input: { |
| 203 | readonly autoSettleAfterDays: number | null; | 245 | readonly autoSettleAfterDays: number | null; |
| 204 | readonly autoSettleOnMerge: boolean; | 246 | readonly autoSettleOnMerge: boolean; |
| 205 | readonly autoSettleKeepPullRequests?: boolean; | 247 | readonly autoSettleKeepPullRequests?: boolean; |
| 248 | readonly usePullRequestSnapshots?: boolean; | ||
| 206 | }): DateTime.Utc | null { | 249 | }): DateTime.Utc | null { |
| 207 | const { thread } = input; | 250 | const { thread } = input; |
| 208 | let pullRequest = input.pullRequest; | 251 | let pullRequest = input.pullRequest; |
| 209 | const links = visibleThreadPullRequests(thread.pullRequests ?? []); | 252 | const links = visibleThreadPullRequests(thread.pullRequests ?? []); |
| 210 | if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; | 253 | if ( |
| 211 | if (links.length > 0) { | 254 | input.usePullRequestSnapshots !== false && |
| 255 | links.some((link) => link.snapshot === null || link.snapshot.state === "open") | ||
| 256 | ) | ||
| 257 | return null; | ||
| 258 | if (input.usePullRequestSnapshots !== false && links.length > 0) { | ||
| 212 | const terminalAt = (link: (typeof links)[number]) => { | 259 | const terminalAt = (link: (typeof links)[number]) => { |
| 213 | const snapshot = link.snapshot; | 260 | const snapshot = link.snapshot; |
| 214 | const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; | 261 | const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; |
| ... | @@ -238,7 +285,10 @@ export function resolveAutoSettlementAt(input: { | ... | @@ -238,7 +285,10 @@ export function resolveAutoSettlementAt(input: { |
| 238 | return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); | 285 | return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); |
| 239 | } | 286 | } |
| 240 | const hasPullRequest = | 287 | const hasPullRequest = |
| 241 | pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null; | 288 | links.length > 0 || |
| 289 | pullRequest !== null || | ||
| 290 | thread.linkedPullRequest != null || | ||
| 291 | thread.branchPullRequest != null; | ||
| 242 | if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null; | 292 | if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null; |
| 243 | if (input.autoSettleAfterDays === null || activityAtMs === null) return null; | 293 | if (input.autoSettleAfterDays === null || activityAtMs === null) return null; |
| 244 | // A passed wake timer restarts inactivity. An earlier wake can only delay settlement. | 294 | // A passed wake timer restarts inactivity. An earlier wake can only delay settlement. |
| ... | @@ -312,15 +362,215 @@ export const make = Effect.gen(function* () { | ... | @@ -312,15 +362,215 @@ export const make = Effect.gen(function* () { |
| 312 | const crypto = yield* Crypto.Crypto; | 362 | const crypto = yield* Crypto.Crypto; |
| 313 | const fileSystem = yield* FileSystem.FileSystem; | 363 | const fileSystem = yield* FileSystem.FileSystem; |
| 314 | const terminals = yield* TerminalManager.TerminalManager; | 364 | const terminals = yield* TerminalManager.TerminalManager; |
| 365 | const previews = yield* PreviewManager.PreviewManager; | ||
| 315 | const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | 366 | const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 367 | const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 368 | const devServer = yield* DevServer.DevServer; | ||
| 369 | const mergebot = yield* MergebotQueue.MergebotQueue; | ||
| 370 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 316 | // Settling a settled thread re-emits thread.settled with the same settledAt, | 371 | // Settling a settled thread re-emits thread.settled with the same settledAt, |
| 317 | // so this keeps the settle action to one run per settlement. | 372 | // so this keeps the settle action to one run per settlement. |
| 318 | const settleActionRunAt = new Map<ThreadId, number>(); | 373 | const settleActionRunAt = new Map<ThreadId, number>(); |
| 319 | 374 | ||
| 375 | const configuredRepository = (reference: { | ||
| 376 | readonly host?: string | undefined; | ||
| 377 | readonly repository: string; | ||
| 378 | }) => | ||
| 379 | reference.host?.toLowerCase() === "github.com" | ||
| 380 | ? mergebot.configuration?.repositories.find( | ||
| 381 | (repository) => repository.toLowerCase() === reference.repository.toLowerCase(), | ||
| 382 | ) | ||
| 383 | : undefined; | ||
| 384 | |||
| 385 | const hasOpenResources = Effect.fn("ThreadSettlementServiceV2.hasOpenResources")(function* ( | ||
| 386 | threadId: ThreadId, | ||
| 387 | ) { | ||
| 388 | if ((yield* terminals.list({ threadId })).length > 0) return true; | ||
| 389 | return (yield* previews.list({ threadId })).sessions.length > 0; | ||
| 390 | }); | ||
| 391 | |||
| 392 | const stopUnusedDevServer = Effect.fn("ThreadSettlementServiceV2.stopUnusedDevServer")( | ||
| 393 | (threadId: ThreadId) => devServer.stopUnused(threadId), | ||
| 394 | (effect, threadId) => | ||
| 395 | effect.pipe( | ||
| 396 | Effect.catchCause((cause) => | ||
| 397 | Cause.hasInterruptsOnly(cause) | ||
| 398 | ? Effect.failCause(cause) | ||
| 399 | : Effect.logWarning("stopping an unused development server failed", { | ||
| 400 | threadId, | ||
| 401 | cause: Cause.pretty(cause), | ||
| 402 | }), | ||
| 403 | ), | ||
| 404 | ), | ||
| 405 | ); | ||
| 406 | |||
| 407 | const archiveSettledThreads = Effect.fn("ThreadSettlementServiceV2.archiveSettledThreads")( | ||
| 408 | function* (threadId?: ThreadId) { | ||
| 409 | const snapshot = | ||
| 410 | threadId === undefined | ||
| 411 | ? yield* projections.getShellSnapshot() | ||
| 412 | : yield* Effect.gen(function* () { | ||
| 413 | const snapshotSequence = yield* eventStore.latestAgentSequence(threadId); | ||
| 414 | const thread = yield* projections.getThreadShell(threadId); | ||
| 415 | return { | ||
| 416 | snapshotSequence, | ||
| 417 | threads: thread === null ? [] : [thread], | ||
| 418 | archivedThreads: [], | ||
| 419 | }; | ||
| 420 | }); | ||
| 421 | const parked = [...snapshot.threads, ...snapshot.archivedThreads].filter( | ||
| 422 | (thread) => thread.settledOverride === "settled" || thread.archivedAt !== null, | ||
| 423 | ); | ||
| 424 | yield* Effect.forEach(parked, (thread) => stopUnusedDevServer(thread.id), { | ||
| 425 | concurrency: 8, | ||
| 426 | discard: true, | ||
| 427 | }); | ||
| 428 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 429 | const boundary = lastSaturdayArchiveBoundary(nowMs); | ||
| 430 | const candidates = snapshot.threads | ||
| 431 | .filter(isManagedWorkspaceArchiveCandidate) | ||
| 432 | .filter( | ||
| 433 | (thread) => | ||
| 434 | (threadId === undefined || thread.id === threadId) && | ||
| 435 | !threadHasQueuedTurnStart(thread, nowMs), | ||
| 436 | ); | ||
| 437 | const readPullRequest = Effect.fn("ThreadSettlementServiceV2.observePullRequest")(function* ( | ||
| 438 | reference: PullRequestRef, | ||
| 439 | ) { | ||
| 440 | const repository = configuredRepository(reference); | ||
| 441 | if (repository === undefined) { | ||
| 442 | yield* pullRequests.invalidate({ reference }, { notifyReaders: false }); | ||
| 443 | const detail = yield* pullRequests.detail({ ...reference, allowStale: false }); | ||
| 444 | return { ...detail, failureIdentity: null }; | ||
| 445 | } | ||
| 446 | const detail = yield* mergebot.readPullRequest({ repository, number: reference.number }); | ||
| 447 | if (detail === null) return null; | ||
| 448 | const failedChecks = detail.checks.filter((check) => | ||
| 449 | ["failure", "action_required", "timed_out", "startup_failure"].includes( | ||
| 450 | check.conclusion ?? "", | ||
| 451 | ), | ||
| 452 | ); | ||
| 453 | const failed = | ||
| 454 | detail.lifecycle === "open" && | ||
| 455 | (failedChecks.length > 0 || | ||
| 456 | detail.ciStatus === "failed" || | ||
| 457 | detail.queueStatus === "ci_failed"); | ||
| 458 | const identity = encodeCiFailureIdentity( | ||
| 459 | failedChecks.toSorted((left, right) => left.id.localeCompare(right.id)), | ||
| 460 | ); | ||
| 461 | return { | ||
| 462 | ...detail, | ||
| 463 | repository, | ||
| 464 | state: detail.lifecycle, | ||
| 465 | failureIdentity: failed | ||
| 466 | ? Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(identity))) | ||
| 467 | : null, | ||
| 468 | }; | ||
| 469 | }); | ||
| 470 | const reads = new Map<string, ReturnType<typeof readPullRequest>>(); | ||
| 471 | const targets = (yield* Effect.forEach( | ||
| 472 | candidates, | ||
| 473 | (thread) => | ||
| 474 | Effect.gen(function* () { | ||
| 475 | const links = visibleThreadPullRequests(thread.pullRequests ?? []); | ||
| 476 | const workPullRequest = links.some( | ||
| 477 | (link) => | ||
| 478 | normalizeThreadPullRequestKey(link).host === "github.com" && | ||
| 479 | isMergebotRepository(mergebot.configuration ?? undefined, link.repository), | ||
| 480 | ); | ||
| 481 | const work = | ||
| 482 | links.length > 0 && | ||
| 483 | (workPullRequest || (yield* managedWorkspaces.isManagedThread(thread.id))); | ||
| 484 | return { thread, links, work }; | ||
| 485 | }).pipe( | ||
| 486 | Effect.catchTags({ | ||
| 487 | ManagedWorkspaceError: () => | ||
| 488 | Effect.logWarning("automatic thread archival workspace lookup skipped", { | ||
| 489 | threadId: thread.id, | ||
| 490 | }).pipe(Effect.as(null)), | ||
| 491 | }), | ||
| 492 | ), | ||
| 493 | { concurrency: 8 }, | ||
| 494 | )).filter((target) => target !== null); | ||
| 495 | for (const { thread, links, work } of targets) { | ||
| 496 | if (!work) continue; | ||
| 497 | for (const link of links) { | ||
| 498 | const reference = { | ||
| 499 | projectId: thread.projectId, | ||
| 500 | host: link.host, | ||
| 501 | repository: link.repository, | ||
| 502 | number: link.number, | ||
| 503 | }; | ||
| 504 | const key = `${reference.projectId}:${threadPullRequestKeyOf(reference)}`; | ||
| 505 | if (!reads.has(key)) reads.set(key, yield* Effect.cached(readPullRequest(reference))); | ||
| 506 | } | ||
| 507 | } | ||
| 508 | yield* Effect.forEach( | ||
| 509 | targets, | ||
| 510 | ({ thread, links, work }) => | ||
| 511 | Effect.gen(function* () { | ||
| 512 | let merged = false; | ||
| 513 | if (work) { | ||
| 514 | const observed = yield* Effect.forEach( | ||
| 515 | links, | ||
| 516 | (link) => | ||
| 517 | reads.get(`${thread.projectId}:${threadPullRequestKeyOf(link)}`) ?? | ||
| 518 | Effect.die("Pull request observation was not prepared"), | ||
| 519 | ); | ||
| 520 | for (const failed of observed) { | ||
| 521 | if (failed === null || failed.failureIdentity === null) continue; | ||
| 522 | const itemId = TurnItemId.make( | ||
| 523 | `server:ci-failed:${thread.id}:${failed.repository}:${failed.number}:${failed.headSha}:${failed.failureIdentity}`, | ||
| 524 | ); | ||
| 525 | const previous = yield* projections.getTurnItem({ threadId: thread.id, itemId }); | ||
| 526 | if (previous === null) { | ||
| 527 | const uuid = yield* crypto.randomUUIDv4(); | ||
| 528 | yield* orchestrator.dispatch({ | ||
| 529 | type: "thread.ci-failed", | ||
| 530 | commandId: CommandId.make(`server:ci-failed:${thread.id}:${uuid}`), | ||
| 531 | threadId: thread.id, | ||
| 532 | snapshotSequence: snapshot.snapshotSequence, | ||
| 533 | itemId, | ||
| 534 | pullRequestNumber: failed.number, | ||
| 535 | url: failed.url, | ||
| 536 | }); | ||
| 537 | return; | ||
| 538 | } | ||
| 539 | } | ||
| 540 | merged = | ||
| 541 | observed.length > 0 && observed.every((detail) => detail?.state === "merged"); | ||
| 542 | } | ||
| 543 | if (!merged) { | ||
| 544 | const settledAt = yield* eventStore.getThreadSettlementTime(thread.id); | ||
| 545 | if (settledAt === null || DateTime.toEpochMillis(settledAt) > boundary) return; | ||
| 546 | } | ||
| 547 | const uuid = yield* crypto.randomUUIDv4(); | ||
| 548 | yield* orchestrator.dispatch({ | ||
| 549 | type: "thread.archive", | ||
| 550 | commandId: CommandId.make(`server:auto-archive:${thread.id}:${uuid}`), | ||
| 551 | threadId: thread.id, | ||
| 552 | snapshotSequence: snapshot.snapshotSequence, | ||
| 553 | }); | ||
| 554 | }).pipe( | ||
| 555 | Effect.catchCause((cause) => | ||
| 556 | Cause.hasInterruptsOnly(cause) | ||
| 557 | ? Effect.failCause(cause) | ||
| 558 | : Effect.logWarning("automatic thread archival skipped", { | ||
| 559 | threadId: thread.id, | ||
| 560 | cause: Cause.pretty(cause), | ||
| 561 | }), | ||
| 562 | ), | ||
| 563 | ), | ||
| 564 | { concurrency: 8, discard: true }, | ||
| 565 | ); | ||
| 566 | }, | ||
| 567 | ); | ||
| 568 | |||
| 320 | const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( | 569 | const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( |
| 321 | mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, | 570 | mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, |
| 322 | threadId?: ThreadId, | 571 | threadId?: ThreadId, |
| 323 | ) { | 572 | ) { |
| 573 | yield* archiveSettledThreads(threadId); | ||
| 324 | const settings = yield* settingsService.getSettings; | 574 | const settings = yield* settingsService.getSettings; |
| 325 | if (!autoSettlementConfigured(settings)) { | 575 | if (!autoSettlementConfigured(settings)) { |
| 326 | return; | 576 | return; |
| ... | @@ -331,6 +581,23 @@ export const make = Effect.gen(function* () { | ... | @@ -331,6 +581,23 @@ export const make = Effect.gen(function* () { |
| 331 | const projectShells = yield* projectStore.listShells(); | 581 | const projectShells = yield* projectStore.listShells(); |
| 332 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); | 582 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); |
| 333 | const projects = new Map(projectShells.map((project) => [project.id, project])); | 583 | const projects = new Map(projectShells.map((project) => [project.id, project])); |
| 584 | const workRepositoryFor = (thread: (typeof threads)[number]) => { | ||
| 585 | for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) { | ||
| 586 | const repository = configuredRepository(normalizeThreadPullRequestKey(link)); | ||
| 587 | if (repository !== undefined) return repository; | ||
| 588 | } | ||
| 589 | const reference = thread.linkedPullRequest ?? thread.branchPullRequest; | ||
| 590 | if (reference != null) { | ||
| 591 | const repository = configuredRepository(legacyThreadPullRequestKey(reference)); | ||
| 592 | if (repository !== undefined) return repository; | ||
| 593 | } | ||
| 594 | const canonical = projects | ||
| 595 | .get(thread.projectId) | ||
| 596 | ?.repositoryIdentity?.canonicalKey.toLowerCase(); | ||
| 597 | return mergebot.configuration?.repositories.find( | ||
| 598 | (repository) => canonical === `github.com/${repository.toLowerCase()}`, | ||
| 599 | ); | ||
| 600 | }; | ||
| 334 | // A merge event re-sweeps every candidate, not just the threads linked to | 601 | // A merge event re-sweeps every candidate, not just the threads linked to |
| 335 | // the merged pull request: most threads carry no link and settle from | 602 | // the merged pull request: most threads carry no link and settle from |
| 336 | // their branch lookup, which would otherwise wait for the next minute's | 603 | // their branch lookup, which would otherwise wait for the next minute's |
| ... | @@ -339,6 +606,7 @@ export const make = Effect.gen(function* () { | ... | @@ -339,6 +606,7 @@ export const make = Effect.gen(function* () { |
| 339 | 606 | ||
| 340 | const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")( | 607 | const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")( |
| 341 | function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { | 608 | function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { |
| 609 | if (yield* hasOpenResources(thread.id)) return null; | ||
| 342 | const currentSettings = resolveProjectSettings( | 610 | const currentSettings = resolveProjectSettings( |
| 343 | yield* settingsService.getSettings, | 611 | yield* settingsService.getSettings, |
| 344 | thread.projectId, | 612 | thread.projectId, |
| ... | @@ -347,6 +615,7 @@ export const make = Effect.gen(function* () { | ... | @@ -347,6 +615,7 @@ export const make = Effect.gen(function* () { |
| 347 | const settledAt = resolveAutoSettlementAt({ | 615 | const settledAt = resolveAutoSettlementAt({ |
| 348 | thread, | 616 | thread, |
| 349 | pullRequest, | 617 | pullRequest, |
| 618 | usePullRequestSnapshots: workRepositoryFor(thread) === undefined, | ||
| 350 | nowMs: DateTime.toEpochMillis(decisionNow), | 619 | nowMs: DateTime.toEpochMillis(decisionNow), |
| 351 | autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays, | 620 | autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays, |
| 352 | autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, | 621 | autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, |
| ... | @@ -384,7 +653,11 @@ export const make = Effect.gen(function* () { | ... | @@ -384,7 +653,11 @@ export const make = Effect.gen(function* () { |
| 384 | { concurrency: 8 }, | 653 | { concurrency: 8 }, |
| 385 | )) | 654 | )) |
| 386 | .filter((thread) => thread !== null) | 655 | .filter((thread) => thread !== null) |
| 387 | .filter((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).length === 0); | 656 | .filter( |
| 657 | (thread) => | ||
| 658 | visibleThreadPullRequests(thread.pullRequests ?? []).length === 0 || | ||
| 659 | workRepositoryFor(thread) !== undefined, | ||
| 660 | ); | ||
| 388 | // Use the same cwd as the sidebar so both paths share GitManager's PR cache. | 661 | // Use the same cwd as the sidebar so both paths share GitManager's PR cache. |
| 389 | const lookupCwdByThreadId = new Map<string, string>(); | 662 | const lookupCwdByThreadId = new Map<string, string>(); |
| 390 | yield* Effect.forEach( | 663 | yield* Effect.forEach( |
| ... | @@ -412,7 +685,16 @@ export const make = Effect.gen(function* () { | ... | @@ -412,7 +685,16 @@ export const make = Effect.gen(function* () { |
| 412 | // its branch threads now instead of on a later sweep. Threads linked to | 685 | // its branch threads now instead of on a later sweep. Threads linked to |
| 413 | // the merged pull request settle from the event itself below and need no | 686 | // the merged pull request settle from the event itself below and need no |
| 414 | // lookup, so they are absent from this map by construction. | 687 | // lookup, so they are absent from this map by construction. |
| 415 | const cwds = [...new Set(lookupCwdByThreadId.values())]; | 688 | const cwds = [ |
| 689 | ...new Set( | ||
| 690 | lookupCandidates | ||
| 691 | .filter((thread) => workRepositoryFor(thread) === undefined) | ||
| 692 | .flatMap((thread) => { | ||
| 693 | const cwd = lookupCwdByThreadId.get(thread.id); | ||
| 694 | return cwd === undefined ? [] : [cwd]; | ||
| 695 | }), | ||
| 696 | ), | ||
| 697 | ]; | ||
| 416 | yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { | 698 | yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { |
| 417 | concurrency: 8, | 699 | concurrency: 8, |
| 418 | discard: true, | 700 | discard: true, |
| ... | @@ -450,6 +732,7 @@ export const make = Effect.gen(function* () { | ... | @@ -450,6 +732,7 @@ export const make = Effect.gen(function* () { |
| 450 | resolveAutoSettlementAt({ | 732 | resolveAutoSettlementAt({ |
| 451 | thread, | 733 | thread, |
| 452 | pullRequest, | 734 | pullRequest, |
| 735 | usePullRequestSnapshots: workRepositoryFor(thread) === undefined, | ||
| 453 | nowMs: DateTime.toEpochMillis(decisionNow), | 736 | nowMs: DateTime.toEpochMillis(decisionNow), |
| 454 | autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, | 737 | autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, |
| 455 | autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, | 738 | autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, |
| ... | @@ -464,6 +747,91 @@ export const make = Effect.gen(function* () { | ... | @@ -464,6 +747,91 @@ export const make = Effect.gen(function* () { |
| 464 | ) { | 747 | ) { |
| 465 | const thread = group[0]!; | 748 | const thread = group[0]!; |
| 466 | const reference = thread.linkedPullRequest ?? thread.branchPullRequest; | 749 | const reference = thread.linkedPullRequest ?? thread.branchPullRequest; |
| 750 | const workRepository = workRepositoryFor(thread); | ||
| 751 | if (workRepository !== undefined) { | ||
| 752 | const links = visibleThreadPullRequests(thread.pullRequests ?? []); | ||
| 753 | const references = | ||
| 754 | links.length > 0 | ||
| 755 | ? links.map(normalizeThreadPullRequestKey) | ||
| 756 | : reference === null || reference === undefined | ||
| 757 | ? [] | ||
| 758 | : [legacyThreadPullRequestKey(reference)]; | ||
| 759 | const observed = | ||
| 760 | references.length > 0 | ||
| 761 | ? yield* Effect.forEach(references, (link) => | ||
| 762 | Effect.gen(function* () { | ||
| 763 | const repository = configuredRepository(link); | ||
| 764 | if (repository !== undefined) { | ||
| 765 | const detail = yield* mergebot.readPullRequest({ | ||
| 766 | repository, | ||
| 767 | number: link.number, | ||
| 768 | }); | ||
| 769 | return detail === null | ||
| 770 | ? null | ||
| 771 | : { | ||
| 772 | state: detail.lifecycle, | ||
| 773 | mergedAt: detail.mergedAt ?? null, | ||
| 774 | closedAt: detail.closedAt ?? null, | ||
| 775 | }; | ||
| 776 | } | ||
| 777 | const detail = yield* pullRequests.summary( | ||
| 778 | { projectId: thread.projectId, ...link }, | ||
| 779 | { recoverTransientFailure: false }, | ||
| 780 | ); | ||
| 781 | return { | ||
| 782 | state: detail.state, | ||
| 783 | mergedAt: detail.mergedAt ?? null, | ||
| 784 | closedAt: detail.closedAt ?? null, | ||
| 785 | }; | ||
| 786 | }), | ||
| 787 | ) | ||
| 788 | : thread.branch === null | ||
| 789 | ? [] | ||
| 790 | : [ | ||
| 791 | yield* mergebot | ||
| 792 | .findPullRequest({ repository: workRepository, headBranch: thread.branch }) | ||
| 793 | .pipe( | ||
| 794 | Effect.map((detail) => | ||
| 795 | detail === null | ||
| 796 | ? null | ||
| 797 | : { | ||
| 798 | state: detail.lifecycle, | ||
| 799 | mergedAt: detail.mergedAt ?? null, | ||
| 800 | closedAt: detail.closedAt ?? null, | ||
| 801 | }, | ||
| 802 | ), | ||
| 803 | ), | ||
| 804 | ]; | ||
| 805 | const terminal = observed.filter((detail) => detail !== null); | ||
| 806 | if ( | ||
| 807 | terminal.length === 0 || | ||
| 808 | terminal.length !== observed.length || | ||
| 809 | terminal.some( | ||
| 810 | (detail) => | ||
| 811 | detail.state === "open" || | ||
| 812 | !Number.isFinite( | ||
| 813 | Date.parse((detail.state === "merged" ? detail.mergedAt : detail.closedAt) ?? ""), | ||
| 814 | ), | ||
| 815 | ) | ||
| 816 | ) | ||
| 817 | return null; | ||
| 818 | if (references.length > 0 && thread.branch !== null) { | ||
| 819 | const current = yield* mergebot.findPullRequest({ | ||
| 820 | repository: workRepository, | ||
| 821 | headBranch: thread.branch, | ||
| 822 | }); | ||
| 823 | if (current?.lifecycle === "open") return null; | ||
| 824 | } | ||
| 825 | return terminal.reduce((latest, detail) => { | ||
| 826 | const latestAt = Date.parse( | ||
| 827 | (latest.state === "merged" ? latest.mergedAt : latest.closedAt) ?? "", | ||
| 828 | ); | ||
| 829 | const detailAt = Date.parse( | ||
| 830 | (detail.state === "merged" ? detail.mergedAt : detail.closedAt) ?? "", | ||
| 831 | ); | ||
| 832 | return detailAt > latestAt ? detail : latest; | ||
| 833 | }); | ||
| 834 | } | ||
| 467 | if (reference != null) { | 835 | if (reference != null) { |
| 468 | // The event carries the merged state, so only the threads linked to | 836 | // The event carries the merged state, so only the threads linked to |
| 469 | // that exact pull request settle from it. Every other linked thread | 837 | // that exact pull request settle from it. Every other linked thread |
| ... | @@ -565,24 +933,41 @@ export const make = Effect.gen(function* () { | ... | @@ -565,24 +933,41 @@ export const make = Effect.gen(function* () { |
| 565 | runSweep(null, threadId), | 933 | runSweep(null, threadId), |
| 566 | ); | 934 | ); |
| 567 | 935 | ||
| 568 | // Settling closes the thread's shells that sit at an idle prompt, so they stop | ||
| 569 | // holding the worktree. A terminal running a command (a dev server, an | ||
| 570 | // editor) stays for the user to close. Then the project's settle script runs | ||
| 571 | // in the thread's own worktree; a thread in the shared checkout skips it, | ||
| 572 | // because other threads may still be working there. | ||
| 573 | const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( | 936 | const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( |
| 574 | function* (threadId: ThreadId) { | 937 | function* (settlement: OrchestrationV2AppThread, automaticSequence: number | undefined) { |
| 938 | const threadId = settlement.id; | ||
| 939 | const settledAtMs = toMillis(settlement.settledAt); | ||
| 575 | // A thread re-engaged before this event ran keeps its shells. | 940 | // A thread re-engaged before this event ran keeps its shells. |
| 576 | const settled = yield* projections.getThread(threadId); | 941 | const settled = yield* projections.getThread(threadId); |
| 577 | if (settled.settledOverride !== "settled") return; | 942 | if (settled.settledOverride !== "settled" || toMillis(settled.settledAt) !== settledAtMs) |
| 578 | yield* terminals.closeIdle({ threadId }); | 943 | return; |
| 944 | if (automaticSequence !== undefined) { | ||
| 945 | if (yield* hasOpenResources(threadId)) { | ||
| 946 | const uuid = yield* crypto.randomUUIDv4(); | ||
| 947 | yield* orchestrator.dispatch({ | ||
| 948 | type: "thread.unsettle", | ||
| 949 | commandId: CommandId.make(`server:auto-settle-resources:${threadId}:${uuid}`), | ||
| 950 | threadId, | ||
| 951 | reason: "user", | ||
| 952 | snapshotSequence: automaticSequence, | ||
| 953 | }); | ||
| 954 | return; | ||
| 955 | } | ||
| 956 | } else { | ||
| 957 | yield* terminals.close({ threadId }); | ||
| 958 | const current = yield* projections.getThread(threadId); | ||
| 959 | if (current.settledOverride !== "settled" || toMillis(current.settledAt) !== settledAtMs) | ||
| 960 | return; | ||
| 961 | yield* previews.close({ threadId }); | ||
| 962 | } | ||
| 963 | if (yield* managedWorkspaces.isManagedThread(threadId)) return; | ||
| 579 | const worktreePath = settled.worktreePath; | 964 | const worktreePath = settled.worktreePath; |
| 580 | if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return; | 965 | if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return; |
| 581 | // Closing and the worktree check wait on I/O. A thread re-engaged | 966 | // Closing and the worktree check wait on I/O. A thread re-engaged |
| 582 | // meanwhile is working again, so its worktree is no place for cleanup. | 967 | // meanwhile is working again, so its worktree is no place for cleanup. |
| 583 | const thread = yield* projections.getThread(threadId); | 968 | const thread = yield* projections.getThread(threadId); |
| 584 | if (thread.settledOverride !== "settled") return; | 969 | if (thread.settledOverride !== "settled" || toMillis(thread.settledAt) !== settledAtMs) |
| 585 | const settledAtMs = toMillis(thread.settledAt); | 970 | return; |
| 586 | if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return; | 971 | if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return; |
| 587 | const run = yield* projectScripts.runForThread({ | 972 | const run = yield* projectScripts.runForThread({ |
| 588 | threadId, | 973 | threadId, |
| ... | @@ -598,48 +983,60 @@ export const make = Effect.gen(function* () { | ... | @@ -598,48 +983,60 @@ export const make = Effect.gen(function* () { |
| 598 | yield* run.completion.pipe(Effect.forkDetach); | 983 | yield* run.completion.pipe(Effect.forkDetach); |
| 599 | } | 984 | } |
| 600 | }, | 985 | }, |
| 601 | (effect, threadId) => | 986 | (effect, settlement) => |
| 602 | effect.pipe( | 987 | effect.pipe( |
| 603 | Effect.catchCause((cause) => | 988 | Effect.catchCause((cause) => |
| 604 | Cause.hasInterruptsOnly(cause) | 989 | Cause.hasInterruptsOnly(cause) |
| 605 | ? Effect.failCause(cause) | 990 | ? Effect.failCause(cause) |
| 606 | : Effect.logWarning("cleaning up a settled thread failed", { | 991 | : Effect.logWarning("cleaning up a settled thread failed", { |
| 607 | threadId, | 992 | threadId: settlement.id, |
| 608 | cause: Cause.pretty(cause), | 993 | cause: Cause.pretty(cause), |
| 609 | }), | 994 | }), |
| 610 | ), | 995 | ), |
| 611 | ), | 996 | ), |
| 612 | ); | 997 | ); |
| 613 | 998 | ||
| 614 | const processEvent = (event: OrchestrationV2DomainEvent) => { | 999 | const processEvent = Effect.fn("ThreadSettlementServiceV2.processEvent")(function* ( |
| 1000 | stored: OrchestrationV2StoredEvent, | ||
| 1001 | ) { | ||
| 1002 | const event = stored.event; | ||
| 615 | switch (event.type) { | 1003 | switch (event.type) { |
| 616 | case "thread.settled": | 1004 | case "thread.settled": |
| 617 | return cleanUpSettledThread(event.threadId); | 1005 | return yield* cleanUpSettledThread( |
| 1006 | event.payload, | ||
| 1007 | stored.commandId?.startsWith("server:auto-settle:") ? stored.sequence : undefined, | ||
| 1008 | ).pipe(Effect.andThen(worker.enqueue(event.threadId))); | ||
| 1009 | case "thread.archived": | ||
| 618 | case "thread.pull-request-link-updated": | 1010 | case "thread.pull-request-link-updated": |
| 619 | case "thread.pull-request-synced": | 1011 | case "thread.pull-request-synced": |
| 620 | case "provider-session.detached": | 1012 | case "provider-session.detached": |
| 621 | return worker.enqueue(event.threadId); | 1013 | return yield* worker.enqueue(event.threadId); |
| 622 | case "provider-session.updated": | 1014 | case "provider-session.updated": |
| 623 | return event.payload.status !== "starting" && event.payload.status !== "running" | 1015 | if (event.payload.status !== "starting" && event.payload.status !== "running") { |
| 624 | ? worker.enqueue(event.threadId) | 1016 | yield* worker.enqueue(event.threadId); |
| 625 | : Effect.void; | 1017 | } |
| 1018 | return; | ||
| 626 | case "run.updated": | 1019 | case "run.updated": |
| 627 | return ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( | 1020 | if ( |
| 628 | event.payload.status, | 1021 | ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( |
| 629 | ) | 1022 | event.payload.status, |
| 630 | ? worker.enqueue(event.threadId) | 1023 | ) |
| 631 | : Effect.void; | 1024 | ) { |
| 1025 | yield* worker.enqueue(event.threadId); | ||
| 1026 | } | ||
| 1027 | return; | ||
| 632 | default: | 1028 | default: |
| 633 | return Effect.void; | 1029 | return; |
| 634 | } | 1030 | } |
| 635 | }; | 1031 | }); |
| 636 | 1032 | ||
| 637 | const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn( | 1033 | const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn( |
| 638 | "ThreadSettlementServiceV2.start", | 1034 | "ThreadSettlementServiceV2.start", |
| 639 | )(function* () { | 1035 | )(function* () { |
| 640 | const settingsChanges = yield* settingsService.subscribeChanges; | 1036 | const settingsChanges = yield* settingsService.subscribeChanges; |
| 641 | const mergedPullRequests = yield* pullRequests.subscribeMerges; | 1037 | const mergedPullRequests = yield* pullRequests.subscribeMerges; |
| 642 | const events = orchestrator.streamDomainEvents; | 1038 | const afterSequence = yield* eventStore.latestAgentSequence().pipe(Effect.orDie); |
| 1039 | const events = orchestrator.streamStoredEventsFrom({ afterSequence }); | ||
| 643 | const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); | 1040 | const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); |
| 644 | let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); | 1041 | let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); |
| 645 | yield* forkParked( | 1042 | yield* forkParked( |
| ... | @@ -659,6 +1056,11 @@ export const make = Effect.gen(function* () { | ... | @@ -659,6 +1056,11 @@ export const make = Effect.gen(function* () { |
| 659 | }), | 1056 | }), |
| 660 | ); | 1057 | ); |
| 661 | yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event))); | 1058 | yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event))); |
| 1059 | yield* forkParked( | ||
| 1060 | Stream.runForEach(mergebot.streamChanges.pipe(Stream.debounce("100 millis")), () => | ||
| 1061 | worker.enqueue(undefined), | ||
| 1062 | ), | ||
| 1063 | ); | ||
| 662 | yield* forkParked( | 1064 | yield* forkParked( |
| 663 | Stream.runForEach(events, processEvent).pipe( | 1065 | Stream.runForEach(events, processEvent).pipe( |
| 664 | Effect.catchCause((cause) => | 1066 | Effect.catchCause((cause) => |
apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts+48| ... | @@ -330,3 +330,51 @@ it.effect("replays a command's events through the command index, not the sequenc | ... | @@ -330,3 +330,51 @@ it.effect("replays a command's events through the command index, not the sequenc |
| 330 | ), | 330 | ), |
| 331 | ), | 331 | ), |
| 332 | ); | 332 | ); |
| 333 | |||
| 334 | it.effect( | ||
| 335 | "dates a settlement from its first event, keeps duplicate settlements and imports stable, and resets on activation", | ||
| 336 | () => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 339 | const threadId = ThreadId.make("weekly-settlement"); | ||
| 340 | const settled = { settledOverride: "settled", settledAt: "2026-08-01T00:00:00.000Z" }; | ||
| 341 | const first = "2026-09-04T17:00:00.000Z"; | ||
| 342 | const second = "2026-09-06T17:00:00.000Z"; | ||
| 343 | const event = (ordinal: number, eventType: string, at: string, payload: unknown) => ({ | ||
| 344 | ...eventRow(ordinal, "thread", 2, threadId), | ||
| 345 | event_type: eventType, | ||
| 346 | occurred_at: at, | ||
| 347 | payload_json: encodeJson(payload), | ||
| 348 | }); | ||
| 349 | assert.equal(yield* store.getThreadSettlementTime(threadId), null); | ||
| 350 | yield* seedEvents([ | ||
| 351 | event(1001, "thread.created", occurredAt, { settledOverride: null, settledAt: null }), | ||
| 352 | event(1002, "thread.settled", first, settled), | ||
| 353 | event(1003, "thread.settled", second, settled), | ||
| 354 | ]); | ||
| 355 | assert.deepStrictEqual( | ||
| 356 | yield* store.getThreadSettlementTime(threadId), | ||
| 357 | DateTime.makeUnsafe(first), | ||
| 358 | ); | ||
| 359 | yield* seedEvents([ | ||
| 360 | event(1004, "thread.unsettled", second, { settledOverride: "active", settledAt: null }), | ||
| 361 | event(1005, "thread.settled", second, settled), | ||
| 362 | ]); | ||
| 363 | assert.deepStrictEqual( | ||
| 364 | yield* store.getThreadSettlementTime(threadId), | ||
| 365 | DateTime.makeUnsafe(second), | ||
| 366 | ); | ||
| 367 | const imported = ThreadId.make("legacy-settlement"); | ||
| 368 | yield* seedEvents([ | ||
| 369 | { | ||
| 370 | ...eventRow(1006, "thread", 2, imported), | ||
| 371 | event_type: "thread.created", | ||
| 372 | payload_json: encodeJson(settled), | ||
| 373 | }, | ||
| 374 | ]); | ||
| 375 | assert.deepStrictEqual( | ||
| 376 | yield* store.getThreadSettlementTime(imported), | ||
| 377 | DateTime.makeUnsafe(settled.settledAt), | ||
| 378 | ); | ||
| 379 | }).pipe(Effect.provide(layerEventStore)), | ||
| 380 | ); |
apps/server/src/persistence/OrchestrationEventStore.ts+32| ... | @@ -30,6 +30,7 @@ import * as Context from "effect/Context"; | ... | @@ -30,6 +30,7 @@ import * as Context from "effect/Context"; |
| 30 | import * as SqlClient from "effect/sql/SqlClient"; | 30 | import * as SqlClient from "effect/sql/SqlClient"; |
| 31 | import * as SqlSchema from "effect/sql/SqlSchema"; | 31 | import * as SqlSchema from "effect/sql/SqlSchema"; |
| 32 | import * as Effect from "effect/Effect"; | 32 | import * as Effect from "effect/Effect"; |
| 33 | import * as DateTime from "effect/DateTime"; | ||
| 33 | import * as Layer from "effect/Layer"; | 34 | import * as Layer from "effect/Layer"; |
| 34 | import * as PubSub from "effect/PubSub"; | 35 | import * as PubSub from "effect/PubSub"; |
| 35 | import * as Option from "effect/Option"; | 36 | import * as Option from "effect/Option"; |
| ... | @@ -123,6 +124,9 @@ export class OrchestrationEventStore extends Context.Service< | ... | @@ -123,6 +124,9 @@ export class OrchestrationEventStore extends Context.Service< |
| 123 | ) => Effect.Effect<number, OrchestrationEventStoreError>; | 124 | ) => Effect.Effect<number, OrchestrationEventStoreError>; |
| 124 | 125 | ||
| 125 | readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>; | 126 | readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>; |
| 127 | readonly getThreadSettlementTime: ( | ||
| 128 | threadId: ThreadId, | ||
| 129 | ) => Effect.Effect<DateTime.Utc | null, OrchestrationEventStoreError>; | ||
| 126 | 130 | ||
| 127 | /** | 131 | /** |
| 128 | * Read the finite retained application-event range `(afterSequence, throughSequence]`. | 132 | * Read the finite retained application-event range `(afterSequence, throughSequence]`. |
| ... | @@ -703,6 +707,33 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -703,6 +707,33 @@ const makeEventStore = Effect.gen(function* () { |
| 703 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), | 707 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), |
| 704 | ); | 708 | ); |
| 705 | 709 | ||
| 710 | const getThreadSettlementTime: OrchestrationEventStore["Service"]["getThreadSettlementTime"] = ( | ||
| 711 | threadId, | ||
| 712 | ) => | ||
| 713 | sql<{ readonly settled_at: string | null }>` | ||
| 714 | SELECT CASE WHEN event_type = 'thread.settled' THEN occurred_at | ||
| 715 | ELSE json_extract(payload_json, '$.settledAt') END AS settled_at | ||
| 716 | FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence | ||
| 717 | WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId} | ||
| 718 | AND (event_type = 'thread.settled' OR | ||
| 719 | (event_type = 'thread.created' AND json_extract(payload_json, '$.settledOverride') = 'settled')) | ||
| 720 | AND sequence > COALESCE(( | ||
| 721 | SELECT MAX(sequence) FROM orchestration_events INDEXED BY idx_orchestration_events_agent_stream_sequence | ||
| 722 | WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId} | ||
| 723 | AND event_type LIKE 'thread.%' | ||
| 724 | AND json_type(payload_json, '$.settledOverride') IS NOT NULL | ||
| 725 | AND json_extract(payload_json, '$.settledOverride') IS NOT 'settled' | ||
| 726 | ), 0) | ||
| 727 | ORDER BY sequence ASC LIMIT 1 | ||
| 728 | `.pipe( | ||
| 729 | Effect.map((rows) => | ||
| 730 | rows[0]?.settled_at == null ? null : DateTime.makeUnsafe(rows[0].settled_at), | ||
| 731 | ), | ||
| 732 | Effect.mapError( | ||
| 733 | toPersistenceSqlError("OrchestrationEventStore.getThreadSettlementTime:query"), | ||
| 734 | ), | ||
| 735 | ); | ||
| 736 | |||
| 706 | // The OR planner otherwise scans every V2 event instead of seeking the final sequence. | 737 | // The OR planner otherwise scans every V2 event instead of seeking the final sequence. |
| 707 | const latestApplicationSequence = sql<{ readonly sequence: number | null }>` | 738 | const latestApplicationSequence = sql<{ readonly sequence: number | null }>` |
| 708 | SELECT MAX(sequence) AS sequence | 739 | SELECT MAX(sequence) AS sequence |
| ... | @@ -786,6 +817,7 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -786,6 +817,7 @@ const makeEventStore = Effect.gen(function* () { |
| 786 | getReplayStats, | 817 | getReplayStats, |
| 787 | latestAgentSequence, | 818 | latestAgentSequence, |
| 788 | latestApplicationSequence, | 819 | latestApplicationSequence, |
| 820 | getThreadSettlementTime, | ||
| 789 | readApplicationEvents: catchUpApplicationEvents, | 821 | readApplicationEvents: catchUpApplicationEvents, |
| 790 | publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), | 822 | publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), |
| 791 | streamApplicationEvents, | 823 | streamApplicationEvents, |
apps/server/src/server.ts+7| ... | @@ -119,6 +119,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve | ... | @@ -119,6 +119,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve |
| 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; |
| 121 | import * as DevServer from "./workspace/DevServer.ts"; | 121 | import * as DevServer from "./workspace/DevServer.ts"; |
| 122 | import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts"; | ||
| 122 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 123 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 123 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 124 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 124 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 125 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | @@ -646,6 +647,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -646,6 +647,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 646 | Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), | 647 | Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), |
| 647 | ).pipe( | 648 | ).pipe( |
| 648 | // Core Services | 649 | // Core Services |
| 650 | Layer.provideMerge( | ||
| 651 | ManagedWorkspaces.layer.pipe( | ||
| 652 | Layer.provide(ProcessRunner.layer), | ||
| 653 | Layer.provide(layerPullRequestService), | ||
| 654 | ), | ||
| 655 | ), | ||
| 649 | Layer.provideMerge(layerOrchestrationApplication), | 656 | Layer.provideMerge(layerOrchestrationApplication), |
| 650 | Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), | 657 | Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), |
| 651 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), | 658 | Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), |
apps/server/src/terminal/Manager.test.ts+34| ... | @@ -1827,6 +1827,40 @@ it.layer( | ... | @@ -1827,6 +1827,40 @@ it.layer( |
| 1827 | }), | 1827 | }), |
| 1828 | ); | 1828 | ); |
| 1829 | 1829 | ||
| 1830 | it.effect( | ||
| 1831 | "lists and closes one chat's sessions without touching a peer in the same directory", | ||
| 1832 | () => | ||
| 1833 | Effect.gen(function* () { | ||
| 1834 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 1835 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 1836 | yield* manager.open(openInput({ terminalId: "sidecar" })); | ||
| 1837 | yield* manager.open(openInput({ threadId: "peer-chat", terminalId: "default" })); | ||
| 1838 | const outputProcessed = yield* Deferred.make<void>(); | ||
| 1839 | const unsubscribe = yield* manager.subscribe((event) => | ||
| 1840 | event.type === "output" && event.threadId === "thread-1" | ||
| 1841 | ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) | ||
| 1842 | : Effect.void, | ||
| 1843 | ); | ||
| 1844 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1845 | ptyAdapter.processes[0]!.emitData("valuable history\n"); | ||
| 1846 | yield* Deferred.await(outputProcessed); | ||
| 1847 | |||
| 1848 | const listed = yield* manager.list({ threadId: "thread-1" }); | ||
| 1849 | expect(listed.map((session) => session.terminalId).sort()).toEqual(["default", "sidecar"]); | ||
| 1850 | expect(listed.every((session) => session.cwd === listed[0]?.cwd)).toBe(true); | ||
| 1851 | yield* manager.close({ threadId: "thread-1" }); | ||
| 1852 | |||
| 1853 | expect(yield* manager.list({ threadId: "thread-1" })).toEqual([]); | ||
| 1854 | expect( | ||
| 1855 | (yield* manager.list({ threadId: "peer-chat" })).map((session) => session.terminalId), | ||
| 1856 | ).toEqual(["default"]); | ||
| 1857 | expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([true, true, false]); | ||
| 1858 | expect((yield* manager.open(openInput({ terminalId: "default" }))).history).toBe( | ||
| 1859 | "valuable history\n", | ||
| 1860 | ); | ||
| 1861 | }), | ||
| 1862 | ); | ||
| 1863 | |||
| 1830 | it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => | 1864 | it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => |
| 1831 | Effect.gen(function* () { | 1865 | Effect.gen(function* () { |
| 1832 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { | 1866 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { |
apps/server/src/terminal/Manager.ts+7-2| ... | @@ -211,11 +211,14 @@ export class TerminalManager extends Context.Service< | ... | @@ -211,11 +211,14 @@ export class TerminalManager extends Context.Service< |
| 211 | */ | 211 | */ |
| 212 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; | 212 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; |
| 213 | 213 | ||
| 214 | readonly list: (input: { | ||
| 215 | readonly threadId: string; | ||
| 216 | }) => Effect.Effect<ReadonlyArray<TerminalSummary>>; | ||
| 217 | |||
| 214 | /** | 218 | /** |
| 215 | * Close a thread's terminals that wait at an idle shell prompt. A terminal | 219 | * Close a thread's terminals that wait at an idle shell prompt. A terminal |
| 216 | * that runs a command stays open. When `terminalId` is set, only that | 220 | * that runs a command stays open. When `terminalId` is set, only that |
| 217 | * terminal is considered. Used when a thread settles and when a setup | 221 | * terminal is considered. |
| 218 | * script finishes. | ||
| 219 | */ | 222 | */ |
| 220 | readonly closeIdle: (input: { | 223 | readonly closeIdle: (input: { |
| 221 | readonly threadId: string; | 224 | readonly threadId: string; |
| ... | @@ -3214,6 +3217,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -3214,6 +3217,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 3214 | clear, | 3217 | clear, |
| 3215 | restart, | 3218 | restart, |
| 3216 | close, | 3219 | close, |
| 3220 | list: ({ threadId }) => | ||
| 3221 | sessionsForThread(threadId).pipe(Effect.map((sessions) => sessions.map(summary))), | ||
| 3217 | closeIdle, | 3222 | closeIdle, |
| 3218 | subscribe, | 3223 | subscribe, |
| 3219 | subscribeMetadata, | 3224 | subscribeMetadata, |
apps/server/src/workspace/ManagedWorkspaces.archive.test.ts created+340| ... | @@ -0,0 +1,340 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures. | ||
| 2 | import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { | ||
| 6 | ProjectId, | ||
| 7 | ThreadId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | SourceControlProviderKind, | ||
| 10 | type OrchestrationV2ThreadShell, | ||
| 11 | type ThreadPullRequestLink, | ||
| 12 | type PullRequestSummary, | ||
| 13 | PullRequestOperationError, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Layer from "effect/Layer"; | ||
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 21 | import * as NodeFSP from "node:fs/promises"; | ||
| 22 | import * as NodeOS from "node:os"; | ||
| 23 | import * as NodePath from "node:path"; | ||
| 24 | |||
| 25 | import * as ServerConfig from "../config.ts"; | ||
| 26 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 27 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 28 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 29 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ||
| 30 | import * as ManagedWorkspaces from "./ManagedWorkspaces.ts"; | ||
| 31 | |||
| 32 | const now = DateTime.makeUnsafe("2026-10-13T12:00:00.000Z"); | ||
| 33 | const projectId = ProjectId.make("managed-project"); | ||
| 34 | const threadId = ThreadId.make("managed-thread"); | ||
| 35 | let home: string; | ||
| 36 | let workspacePath: string; | ||
| 37 | let threads: OrchestrationV2ThreadShell[]; | ||
| 38 | const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); | ||
| 39 | const dispatch = vi.fn<Orchestrator.OrchestratorV2["Service"]["dispatch"]>(); | ||
| 40 | const summary = vi.fn<PullRequestService.PullRequestService["Service"]["summary"]>(); | ||
| 41 | const invalidate = vi.fn<PullRequestService.PullRequestService["Service"]["invalidate"]>(); | ||
| 42 | const link = (number: number): ThreadPullRequestLink => ({ | ||
| 43 | host: "github.com", | ||
| 44 | repository: "work/app", | ||
| 45 | number, | ||
| 46 | url: `https://github.com/work/app/pull/${number}`, | ||
| 47 | source: "manual", | ||
| 48 | linkedAt: DateTime.formatIso(now), | ||
| 49 | snapshot: null, | ||
| 50 | stack: null, | ||
| 51 | }); | ||
| 52 | const pullRequest = (state: PullRequestSummary["state"], number = 1): PullRequestSummary => ({ | ||
| 53 | provider: SourceControlProviderKind.make("github"), | ||
| 54 | projectId, | ||
| 55 | repository: "work/app", | ||
| 56 | number, | ||
| 57 | title: "Work", | ||
| 58 | url: link(number).url, | ||
| 59 | state, | ||
| 60 | headBranch: "feature", | ||
| 61 | baseBranch: "main", | ||
| 62 | mergedAt: state === "merged" ? DateTime.formatIso(now) : null, | ||
| 63 | updatedAt: DateTime.formatIso(now), | ||
| 64 | }); | ||
| 65 | const thread = (): OrchestrationV2ThreadShell => ({ | ||
| 66 | id: threadId, | ||
| 67 | projectId, | ||
| 68 | title: "Managed workspace", | ||
| 69 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 70 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }, | ||
| 71 | runtimeMode: "full-access", | ||
| 72 | interactionMode: "default", | ||
| 73 | branch: null, | ||
| 74 | worktreePath: null, | ||
| 75 | activeProviderThreadId: null, | ||
| 76 | lineage: { rootThreadId: threadId, parentThreadId: null, relationshipToParent: null }, | ||
| 77 | forkedFrom: null, | ||
| 78 | createdBy: "user", | ||
| 79 | creationSource: "web", | ||
| 80 | activeRunId: null, | ||
| 81 | latestRunId: null, | ||
| 82 | status: "idle", | ||
| 83 | pendingRuntimeRequest: null, | ||
| 84 | latestVisibleMessage: null, | ||
| 85 | latestUserMessageAt: null, | ||
| 86 | hasActionableProposedPlan: false, | ||
| 87 | itemCount: 0, | ||
| 88 | visibleItemCount: 0, | ||
| 89 | createdAt: now, | ||
| 90 | updatedAt: now, | ||
| 91 | archivedAt: null, | ||
| 92 | settledOverride: "settled", | ||
| 93 | settledAt: now, | ||
| 94 | deletedAt: null, | ||
| 95 | pullRequests: [link(1)], | ||
| 96 | }); | ||
| 97 | |||
| 98 | beforeEach(async () => { | ||
| 99 | home = await NodeFSP.realpath( | ||
| 100 | await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-archive-")), | ||
| 101 | ); | ||
| 102 | workspacePath = NodePath.join(home, "workspaces/1"); | ||
| 103 | await NodeFSP.mkdir(workspacePath, { recursive: true }); | ||
| 104 | const records = NodePath.join(home, ".local/state/workspaces/workspaces"); | ||
| 105 | await NodeFSP.mkdir(records, { recursive: true }); | ||
| 106 | await NodeFSP.writeFile(NodePath.join(records, "1.json"), JSON.stringify({ threadId })); | ||
| 107 | await NodeFSP.mkdir(NodePath.join(home, ".config")); | ||
| 108 | await NodeFSP.mkdir(NodePath.join(home, "source/client"), { recursive: true }); | ||
| 109 | await NodeFSP.mkdir(NodePath.join(home, "bin")); | ||
| 110 | await NodeFSP.writeFile( | ||
| 111 | NodePath.join(home, ".config/workspaces.json"), | ||
| 112 | JSON.stringify({ repository: NodePath.join(home, "source") }), | ||
| 113 | ); | ||
| 114 | await NodeFSP.writeFile( | ||
| 115 | NodePath.join(home, "source/client/package.json"), | ||
| 116 | '{"name":"@work/client"}', | ||
| 117 | ); | ||
| 118 | await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); | ||
| 119 | threads = [thread()]; | ||
| 120 | launches.mockReset(); | ||
| 121 | launches.mockImplementation(() => | ||
| 122 | Effect.succeed({ | ||
| 123 | stdout: '{"abandoned":false}', | ||
| 124 | stderr: "", | ||
| 125 | code: ChildProcessSpawner.ExitCode(0), | ||
| 126 | timedOut: false, | ||
| 127 | stdoutTruncated: false, | ||
| 128 | stderrTruncated: false, | ||
| 129 | stdoutInvalidUtf8: false, | ||
| 130 | stderrInvalidUtf8: false, | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | dispatch.mockReset(); | ||
| 134 | summary.mockReset(); | ||
| 135 | invalidate.mockReset(); | ||
| 136 | dispatch.mockImplementation(() => Effect.succeed({ sequence: 1, storedEvents: [] })); | ||
| 137 | summary.mockImplementation((ref) => Effect.succeed(pullRequest("merged", ref.number))); | ||
| 138 | invalidate.mockImplementation(() => Effect.void); | ||
| 139 | }); | ||
| 140 | afterEach(async () => { | ||
| 141 | await NodeFSP.rm(home, { recursive: true, force: true }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | const layer = () => | ||
| 145 | ManagedWorkspaces.layer.pipe( | ||
| 146 | Layer.provide( | ||
| 147 | Layer.mergeAll( | ||
| 148 | NodeServices.layer, | ||
| 149 | Layer.succeed(HostProcess.HomeDirectory, home), | ||
| 150 | Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }), | ||
| 151 | ServerConfig.layerTest(home, NodePath.join(home, "isolated-t3")).pipe( | ||
| 152 | Layer.provide(NodeServices.layer), | ||
| 153 | ), | ||
| 154 | Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }), | ||
| 155 | Layer.mock(Orchestrator.OrchestratorV2)({ | ||
| 156 | getThreadShell: (id) => | ||
| 157 | Effect.succeed(threads.find((thread) => thread.id === id) ?? null), | ||
| 158 | getShellSnapshot: () => | ||
| 159 | Effect.succeed({ schemaVersion: 1, snapshotSequence: 1, threads, archivedThreads: [] }), | ||
| 160 | dispatch, | ||
| 161 | }), | ||
| 162 | Layer.mock(ProjectStore.ProjectStoreV2)({ | ||
| 163 | getShell: () => | ||
| 164 | Effect.succeed( | ||
| 165 | Option.some({ | ||
| 166 | id: projectId, | ||
| 167 | title: "Workspace", | ||
| 168 | workspaceRoot: workspacePath, | ||
| 169 | defaultModelSelection: null, | ||
| 170 | scripts: [], | ||
| 171 | createdAt: DateTime.formatIso(now), | ||
| 172 | updatedAt: DateTime.formatIso(now), | ||
| 173 | }), | ||
| 174 | ), | ||
| 175 | listShells: () => | ||
| 176 | Effect.succeed([ | ||
| 177 | { | ||
| 178 | id: projectId, | ||
| 179 | title: "Workspace", | ||
| 180 | workspaceRoot: workspacePath, | ||
| 181 | repositoryIdentity: null, | ||
| 182 | defaultModelSelection: null, | ||
| 183 | defaultThreadEnvMode: "local", | ||
| 184 | autoPull: false, | ||
| 185 | faviconPath: null, | ||
| 186 | projectIcon: null, | ||
| 187 | scripts: [], | ||
| 188 | createdAt: DateTime.formatIso(now), | ||
| 189 | updatedAt: DateTime.formatIso(now), | ||
| 190 | }, | ||
| 191 | ]), | ||
| 192 | }), | ||
| 193 | Layer.mock(PullRequestService.PullRequestService)({ summary, invalidate }), | ||
| 194 | ), | ||
| 195 | ), | ||
| 196 | ); | ||
| 197 | const use = <A, E>(effect: Effect.Effect<A, E, ManagedWorkspaces.ManagedWorkspaces>) => | ||
| 198 | effect.pipe(Effect.provide(layer())); | ||
| 199 | |||
| 200 | describe("managed workspace archival", () => { | ||
| 201 | it.effect("archives JJ project-root threads only after every linked PR is freshly verified", () => | ||
| 202 | use( | ||
| 203 | Effect.gen(function* () { | ||
| 204 | threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] }; | ||
| 205 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 206 | expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ | ||
| 207 | archivedThreadIds: [threadId], | ||
| 208 | }); | ||
| 209 | expect(invalidate).toHaveBeenCalledTimes(2); | ||
| 210 | expect(summary).toHaveBeenCalledTimes(2); | ||
| 211 | expect(summary.mock.calls[0]).toEqual([ | ||
| 212 | { | ||
| 213 | host: "github.com", | ||
| 214 | repository: "work/app", | ||
| 215 | number: 1, | ||
| 216 | projectId, | ||
| 217 | allowStale: false, | ||
| 218 | }, | ||
| 219 | { recoverTransientFailure: false }, | ||
| 220 | ]); | ||
| 221 | expect(dispatch.mock.calls[0]?.[0]).toMatchObject({ | ||
| 222 | type: "thread.archive", | ||
| 223 | threadId, | ||
| 224 | snapshotSequence: 1, | ||
| 225 | }); | ||
| 226 | }), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | it.effect.each(["open", "closed"] as const)("retains a workspace with any %s PR", (state) => | ||
| 230 | use( | ||
| 231 | Effect.gen(function* () { | ||
| 232 | threads[0] = { ...threads[0]!, pullRequests: [link(1), link(2)] }; | ||
| 233 | summary.mockImplementation((ref) => | ||
| 234 | Effect.succeed(pullRequest(ref.number === 1 ? "merged" : state, ref.number)), | ||
| 235 | ); | ||
| 236 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 237 | expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ | ||
| 238 | archivedThreadIds: [], | ||
| 239 | }); | ||
| 240 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 241 | }), | ||
| 242 | ), | ||
| 243 | ); | ||
| 244 | it.effect.each(["active", "unlinked", "shared active"] as const)( | ||
| 245 | "retains %s work without host reads", | ||
| 246 | (kind) => | ||
| 247 | use( | ||
| 248 | Effect.gen(function* () { | ||
| 249 | if (kind === "active") threads[0] = { ...threads[0]!, settledOverride: "active" }; | ||
| 250 | if (kind === "unlinked") threads[0] = { ...threads[0]!, pullRequests: [] }; | ||
| 251 | if (kind === "shared active") | ||
| 252 | threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: null }); | ||
| 253 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 254 | expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ | ||
| 255 | archivedThreadIds: [], | ||
| 256 | }); | ||
| 257 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 258 | expect(summary).not.toHaveBeenCalled(); | ||
| 259 | }), | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | it.effect("fails closed when a host cannot confirm the merge", () => | ||
| 263 | use( | ||
| 264 | Effect.gen(function* () { | ||
| 265 | summary.mockImplementation( | ||
| 266 | () => new PullRequestOperationError({ operation: "summary", detail: "Host unavailable" }), | ||
| 267 | ); | ||
| 268 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 269 | expect((yield* service.archiveCompleted({ workspacePath }).pipe(Effect.flip)).reason).toBe( | ||
| 270 | "archive", | ||
| 271 | ); | ||
| 272 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 273 | }), | ||
| 274 | ), | ||
| 275 | ); | ||
| 276 | it.effect("retains an unknown merge timestamp", () => | ||
| 277 | use( | ||
| 278 | Effect.gen(function* () { | ||
| 279 | summary.mockImplementation(() => | ||
| 280 | Effect.succeed({ ...pullRequest("merged"), mergedAt: null }), | ||
| 281 | ); | ||
| 282 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 283 | expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ | ||
| 284 | archivedThreadIds: [], | ||
| 285 | }); | ||
| 286 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 287 | }), | ||
| 288 | ), | ||
| 289 | ); | ||
| 290 | it.effect("refuses an arbitrary project checkout", () => | ||
| 291 | use( | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 294 | expect( | ||
| 295 | (yield* service.archiveCompleted({ workspacePath: home }).pipe(Effect.flip)).reason, | ||
| 296 | ).toBe("workspace"); | ||
| 297 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 298 | }), | ||
| 299 | ), | ||
| 300 | ); | ||
| 301 | }); | ||
| 302 | |||
| 303 | it.effect("retains an abandoned settled workspace without a merged PR", () => | ||
| 304 | use( | ||
| 305 | Effect.gen(function* () { | ||
| 306 | threads[0] = { ...threads[0]!, pullRequests: [] }; | ||
| 307 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 308 | expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] }); | ||
| 309 | expect(launches).not.toHaveBeenCalled(); | ||
| 310 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 311 | }), | ||
| 312 | ), | ||
| 313 | ); | ||
| 314 | |||
| 315 | it.effect("recognizes managed project roots and managed thread worktrees", () => | ||
| 316 | use( | ||
| 317 | Effect.gen(function* () { | ||
| 318 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 319 | expect(yield* service.isManagedThread(threadId)).toBe(true); | ||
| 320 | threads[0] = { ...threads[0]!, worktreePath: home }; | ||
| 321 | expect(yield* service.isManagedThread(threadId)).toBe(false); | ||
| 322 | threads[0] = { ...threads[0]!, worktreePath: workspacePath }; | ||
| 323 | expect(yield* service.isManagedThread(threadId)).toBe(true); | ||
| 324 | expect(yield* service.isManagedThread(ThreadId.make("missing-thread"))).toBe(false); | ||
| 325 | expect(launches).not.toHaveBeenCalled(); | ||
| 326 | }), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | |||
| 330 | it.effect("keeps merged workspaces available while another chat references them", () => | ||
| 331 | use( | ||
| 332 | Effect.gen(function* () { | ||
| 333 | threads.push({ ...thread(), id: ThreadId.make("active-peer"), settledOverride: "active" }); | ||
| 334 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 335 | expect(yield* service.archiveCompleted({ workspacePath })).toEqual({ archivedThreadIds: [] }); | ||
| 336 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 337 | expect(summary).not.toHaveBeenCalled(); | ||
| 338 | }), | ||
| 339 | ), | ||
| 340 | ); | ||
apps/server/src/workspace/ManagedWorkspaces.test.ts created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Owns isolated filesystem fixtures. | ||
| 2 | import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { ProjectId } from "@t3tools/contracts"; | ||
| 6 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 10 | import * as NodeFSP from "node:fs/promises"; | ||
| 11 | import * as NodeOS from "node:os"; | ||
| 12 | import * as NodePath from "node:path"; | ||
| 13 | |||
| 14 | import * as ServerConfig from "../config.ts"; | ||
| 15 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 16 | import * as ManagedWorkspaces from "./ManagedWorkspaces.ts"; | ||
| 17 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 18 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 19 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ||
| 20 | |||
| 21 | let home: string; | ||
| 22 | let repository: string; | ||
| 23 | let platform: NodeJS.Platform; | ||
| 24 | let sourceRegistered: boolean; | ||
| 25 | const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); | ||
| 26 | const created = { | ||
| 27 | workspacePath: "/test/workspaces/1", | ||
| 28 | projectId: "new-project", | ||
| 29 | threadId: "new-thread", | ||
| 30 | environmentId: "current-host", | ||
| 31 | }; | ||
| 32 | const output = (stdout = JSON.stringify(created), code = 0) => ({ | ||
| 33 | stdout, | ||
| 34 | stderr: "", | ||
| 35 | code: ChildProcessSpawner.ExitCode(code), | ||
| 36 | timedOut: false, | ||
| 37 | stdoutTruncated: false, | ||
| 38 | stderrTruncated: false, | ||
| 39 | stdoutInvalidUtf8: false, | ||
| 40 | stderrInvalidUtf8: false, | ||
| 41 | }); | ||
| 42 | |||
| 43 | beforeEach(async () => { | ||
| 44 | home = await NodeFSP.realpath( | ||
| 45 | await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-managed-ws-")), | ||
| 46 | ); | ||
| 47 | repository = NodePath.join(home, "source"); | ||
| 48 | platform = "darwin"; | ||
| 49 | sourceRegistered = true; | ||
| 50 | await NodeFSP.mkdir(repository, { recursive: true }); | ||
| 51 | await NodeFSP.mkdir(NodePath.join(home, ".config")); | ||
| 52 | await NodeFSP.mkdir(NodePath.join(home, "bin")); | ||
| 53 | await NodeFSP.writeFile( | ||
| 54 | NodePath.join(home, ".config/workspaces.json"), | ||
| 55 | JSON.stringify({ repository }), | ||
| 56 | ); | ||
| 57 | await NodeFSP.writeFile(NodePath.join(home, "bin/ws"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); | ||
| 58 | launches.mockReset(); | ||
| 59 | launches.mockImplementation(() => Effect.succeed(output())); | ||
| 60 | }); | ||
| 61 | |||
| 62 | afterEach(async () => { | ||
| 63 | await NodeFSP.rm(home, { recursive: true, force: true }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | const layer = () => | ||
| 67 | ManagedWorkspaces.layer.pipe( | ||
| 68 | Layer.provide( | ||
| 69 | Layer.mergeAll( | ||
| 70 | NodeServices.layer, | ||
| 71 | Layer.succeed(HostProcess.HomeDirectory, home), | ||
| 72 | Layer.succeed(HostProcess.Platform, platform), | ||
| 73 | Layer.succeed(HostProcess.Environment, { PATH: NodePath.join(home, "bin") }), | ||
| 74 | ServerConfig.layerTest(repository, NodePath.join(home, "isolated-t3")).pipe( | ||
| 75 | Layer.provide(NodeServices.layer), | ||
| 76 | ), | ||
| 77 | Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }), | ||
| 78 | Layer.mock(Orchestrator.OrchestratorV2)({}), | ||
| 79 | Layer.mock(ProjectStore.ProjectStoreV2)({ | ||
| 80 | listShells: () => | ||
| 81 | Effect.succeed( | ||
| 82 | sourceRegistered | ||
| 83 | ? [ | ||
| 84 | { | ||
| 85 | id: ProjectId.make("source-project"), | ||
| 86 | title: "my work", | ||
| 87 | workspaceRoot: repository, | ||
| 88 | defaultModelSelection: null, | ||
| 89 | scripts: [], | ||
| 90 | createdAt: "2026-10-13T12:00:00.000Z", | ||
| 91 | updatedAt: "2026-10-13T12:00:00.000Z", | ||
| 92 | }, | ||
| 93 | ] | ||
| 94 | : [], | ||
| 95 | ), | ||
| 96 | }), | ||
| 97 | Layer.mock(PullRequestService.PullRequestService)({}), | ||
| 98 | ), | ||
| 99 | ), | ||
| 100 | ); | ||
| 101 | |||
| 102 | const use = <A, E>(effect: Effect.Effect<A, E, ManagedWorkspaces.ManagedWorkspaces>) => | ||
| 103 | effect.pipe(Effect.provide(layer())); | ||
| 104 | |||
| 105 | describe("managed workspace creation", () => { | ||
| 106 | it.effect( | ||
| 107 | "reuses the installed workflow and targets this server without opening a host window", | ||
| 108 | () => | ||
| 109 | use( | ||
| 110 | Effect.gen(function* () { | ||
| 111 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 112 | expect(yield* service.status()).toEqual({ projectId: ProjectId.make("source-project") }); | ||
| 113 | expect(launches).not.toHaveBeenCalled(); | ||
| 114 | expect(yield* service.create()).toEqual(created); | ||
| 115 | expect(launches).toHaveBeenCalledOnce(); | ||
| 116 | expect(launches.mock.calls[0]?.[0]).toMatchObject({ | ||
| 117 | command: NodePath.join(home, "bin/ws"), | ||
| 118 | args: ["new", "--background", "--no-open", "--json"], | ||
| 119 | cwd: repository, | ||
| 120 | env: { T3CODE_HOME: NodePath.join(home, "isolated-t3") }, | ||
| 121 | }); | ||
| 122 | }), | ||
| 123 | ), | ||
| 124 | ); | ||
| 125 | |||
| 126 | it.effect.each([ | ||
| 127 | "missing tool", | ||
| 128 | "missing configuration", | ||
| 129 | "missing repository", | ||
| 130 | "unregistered source", | ||
| 131 | "Windows", | ||
| 132 | ] as const)("does not launch when %s makes creation unavailable", (reason) => { | ||
| 133 | if (reason === "Windows") platform = "win32"; | ||
| 134 | if (reason === "unregistered source") sourceRegistered = false; | ||
| 135 | return use( | ||
| 136 | Effect.gen(function* () { | ||
| 137 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 138 | if (reason === "missing tool") | ||
| 139 | yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, "bin/ws"))); | ||
| 140 | if (reason === "missing configuration") | ||
| 141 | yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, ".config/workspaces.json"))); | ||
| 142 | if (reason === "missing repository") | ||
| 143 | yield* Effect.promise(() => NodeFSP.rm(repository, { recursive: true })); | ||
| 144 | expect(yield* service.status()).toEqual({ projectId: null }); | ||
| 145 | const error = yield* service.create().pipe(Effect.flip); | ||
| 146 | expect(error.reason).toBe("unavailable"); | ||
| 147 | expect(launches).not.toHaveBeenCalled(); | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | }); | ||
| 151 | |||
| 152 | it.effect("rejects unreadable and incomplete CLI replies", () => | ||
| 153 | use( | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 156 | for (const stdout of ["not JSON", JSON.stringify({ workspacePath: "/created" })]) { | ||
| 157 | launches.mockImplementation(() => Effect.succeed(output(stdout))); | ||
| 158 | expect((yield* service.create().pipe(Effect.flip)).reason).toBe("reply"); | ||
| 159 | } | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | |||
| 164 | it.effect("propagates a stopped creation instead of navigating to an incomplete workspace", () => | ||
| 165 | use( | ||
| 166 | Effect.gen(function* () { | ||
| 167 | launches.mockImplementation(() => Effect.succeed(output("", 1))); | ||
| 168 | const service = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 169 | expect((yield* service.create().pipe(Effect.flip)).reason).toBe("create"); | ||
| 170 | }), | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | }); | ||
apps/server/src/workspace/ManagedWorkspaces.ts created+223| ... | @@ -0,0 +1,223 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | type ProjectId, | ||
| 4 | type ThreadId, | ||
| 5 | type ManagedWorkspaceArchived, | ||
| 6 | ManagedWorkspaceCreated, | ||
| 7 | ManagedWorkspaceError, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { resolveCommandPath } from "@t3tools/shared/shell"; | ||
| 10 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as FileSystem from "effect/FileSystem"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import * as Path from "effect/Path"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | |||
| 20 | import * as ServerConfig from "../config.ts"; | ||
| 21 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 22 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 23 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 24 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ||
| 25 | import { | ||
| 26 | normalizeThreadPullRequestKey, | ||
| 27 | visibleThreadPullRequests, | ||
| 28 | } from "@t3tools/shared/threadPullRequests"; | ||
| 29 | import { isManagedWorkspaceArchiveCandidate } from "./managedWorkspaceArchive.ts"; | ||
| 30 | |||
| 31 | const decodeConfiguration = Schema.decodeUnknownEffect( | ||
| 32 | Schema.fromJsonString(Schema.Struct({ repository: Schema.String })), | ||
| 33 | ); | ||
| 34 | const decodeCreated = Schema.decodeUnknownEffect(Schema.fromJsonString(ManagedWorkspaceCreated)); | ||
| 35 | |||
| 36 | export class ManagedWorkspaces extends Context.Service< | ||
| 37 | ManagedWorkspaces, | ||
| 38 | { | ||
| 39 | readonly isManagedThread: (threadId: ThreadId) => Effect.Effect<boolean, ManagedWorkspaceError>; | ||
| 40 | readonly status: () => Effect.Effect<{ readonly projectId: ProjectId | null }>; | ||
| 41 | readonly create: () => Effect.Effect<ManagedWorkspaceCreated, ManagedWorkspaceError>; | ||
| 42 | readonly archiveCompleted: (input: { | ||
| 43 | readonly workspacePath: string; | ||
| 44 | }) => Effect.Effect<ManagedWorkspaceArchived, ManagedWorkspaceError>; | ||
| 45 | } | ||
| 46 | >()("t3/workspace/ManagedWorkspaces") {} | ||
| 47 | |||
| 48 | const make = Effect.gen(function* () { | ||
| 49 | const fs = yield* FileSystem.FileSystem; | ||
| 50 | const path = yield* Path.Path; | ||
| 51 | const home = yield* HostProcess.HomeDirectory; | ||
| 52 | const platform = yield* HostProcess.Platform; | ||
| 53 | const environment = yield* HostProcess.Environment; | ||
| 54 | const config = yield* ServerConfig.ServerConfig; | ||
| 55 | const runner = yield* ProcessRunner.ProcessRunner; | ||
| 56 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 57 | const projects = yield* ProjectStore.ProjectStoreV2; | ||
| 58 | const pullRequests = yield* PullRequestService.PullRequestService; | ||
| 59 | const crypto = yield* Crypto.Crypto; | ||
| 60 | |||
| 61 | const configuration = Effect.gen(function* () { | ||
| 62 | if (platform === "win32") return null; | ||
| 63 | const value = yield* fs.readFileString(path.join(home, ".config/workspaces.json")); | ||
| 64 | const configured = yield* decodeConfiguration(value); | ||
| 65 | const repository = yield* fs.realPath(configured.repository); | ||
| 66 | const executable = yield* resolveCommandPath("ws", { env: environment }).pipe( | ||
| 67 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 68 | Effect.provideService(Path.Path, path), | ||
| 69 | Effect.provideService(HostProcess.Platform, platform), | ||
| 70 | ); | ||
| 71 | if (executable === null) return null; | ||
| 72 | return { repository, executable }; | ||
| 73 | }).pipe(Effect.orElseSucceed(() => null)); | ||
| 74 | |||
| 75 | const status = () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const configured = yield* configuration; | ||
| 78 | if (configured === null) return { projectId: null }; | ||
| 79 | const sources = yield* projects.listShells(); | ||
| 80 | for (const project of sources) { | ||
| 81 | const root = yield* fs | ||
| 82 | .realPath(project.workspaceRoot) | ||
| 83 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 84 | if (root === configured.repository) return { projectId: project.id }; | ||
| 85 | } | ||
| 86 | return { projectId: null }; | ||
| 87 | }).pipe(Effect.orElseSucceed(() => ({ projectId: null }))); | ||
| 88 | |||
| 89 | const create = Effect.fn("ManagedWorkspaces.create")(function* () { | ||
| 90 | const configured = yield* configuration; | ||
| 91 | if (configured === null || (yield* status()).projectId === null) | ||
| 92 | return yield* new ManagedWorkspaceError({ reason: "unavailable" }); | ||
| 93 | const result = yield* runner | ||
| 94 | .run({ | ||
| 95 | command: configured.executable, | ||
| 96 | args: ["new", "--background", "--no-open", "--json"], | ||
| 97 | cwd: configured.repository, | ||
| 98 | env: { T3CODE_HOME: config.baseDir }, | ||
| 99 | timeout: "30 seconds", | ||
| 100 | maxOutputBytes: 16 * 1024, | ||
| 101 | }) | ||
| 102 | .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "create", cause }))); | ||
| 103 | if (result.code !== 0 || result.timedOut) { | ||
| 104 | return yield* new ManagedWorkspaceError({ reason: "create" }); | ||
| 105 | } | ||
| 106 | return yield* decodeCreated(result.stdout).pipe( | ||
| 107 | Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "reply", cause })), | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | const managedDirectory = Effect.fn("ManagedWorkspaces.managedDirectory")(function* ( | ||
| 112 | directory: string, | ||
| 113 | ) { | ||
| 114 | const root = path.join(home, "workspaces"); | ||
| 115 | const id = path.relative(root, directory); | ||
| 116 | if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) return null; | ||
| 117 | const workspacePath = yield* fs | ||
| 118 | .realPath(directory) | ||
| 119 | .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause }))); | ||
| 120 | if ( | ||
| 121 | workspacePath !== path.join(root, id) || | ||
| 122 | !(yield* fs | ||
| 123 | .exists(path.join(home, ".local/state/workspaces/workspaces", `${id}.json`)) | ||
| 124 | .pipe( | ||
| 125 | Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause })), | ||
| 126 | )) | ||
| 127 | ) { | ||
| 128 | return null; | ||
| 129 | } | ||
| 130 | return { id, workspacePath }; | ||
| 131 | }); | ||
| 132 | |||
| 133 | const isManagedThread = Effect.fn("ManagedWorkspaces.isManagedThread")(function* ( | ||
| 134 | threadId: ThreadId, | ||
| 135 | ) { | ||
| 136 | const thread = yield* orchestrator | ||
| 137 | .getThreadShell(threadId) | ||
| 138 | .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause }))); | ||
| 139 | if (thread === null) return false; | ||
| 140 | const project = yield* projects | ||
| 141 | .getShell(thread.projectId) | ||
| 142 | .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "workspace", cause }))); | ||
| 143 | if (Option.isNone(project)) return false; | ||
| 144 | return (yield* managedDirectory(thread.worktreePath ?? project.value.workspaceRoot)) !== null; | ||
| 145 | }); | ||
| 146 | |||
| 147 | const archiveCompleted = Effect.fn("ManagedWorkspaces.archiveCompleted")(function* (input: { | ||
| 148 | readonly workspacePath: string; | ||
| 149 | }) { | ||
| 150 | const managed = yield* managedDirectory(input.workspacePath); | ||
| 151 | if (managed === null) return yield* new ManagedWorkspaceError({ reason: "workspace" }); | ||
| 152 | const { workspacePath } = managed; | ||
| 153 | const configured = yield* configuration; | ||
| 154 | if (configured === null) return yield* new ManagedWorkspaceError({ reason: "unavailable" }); | ||
| 155 | const [snapshot, projectShells] = yield* Effect.all([ | ||
| 156 | orchestrator.getShellSnapshot({ location: "active", unsettledOnly: false }), | ||
| 157 | projects.listShells(), | ||
| 158 | ]).pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause }))); | ||
| 159 | const roots = new Map(projectShells.map((project) => [project.id, project.workspaceRoot])); | ||
| 160 | const threads = []; | ||
| 161 | for (const thread of snapshot.threads) { | ||
| 162 | const cwd = thread.worktreePath ?? roots.get(thread.projectId); | ||
| 163 | if (cwd === undefined) return yield* new ManagedWorkspaceError({ reason: "workspace" }); | ||
| 164 | const relative = path.relative(workspacePath, cwd); | ||
| 165 | if ( | ||
| 166 | relative === "" || | ||
| 167 | (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) | ||
| 168 | ) | ||
| 169 | threads.push(thread); | ||
| 170 | } | ||
| 171 | const candidates = threads.filter(isManagedWorkspaceArchiveCandidate); | ||
| 172 | if (candidates.length === 0 || candidates.length !== threads.length) | ||
| 173 | return { archivedThreadIds: [] }; | ||
| 174 | for (const thread of candidates) { | ||
| 175 | const links = visibleThreadPullRequests(thread.pullRequests ?? []); | ||
| 176 | if (links.length === 0) return { archivedThreadIds: [] }; | ||
| 177 | for (const link of links) { | ||
| 178 | const ref = { | ||
| 179 | ...normalizeThreadPullRequestKey(link), | ||
| 180 | projectId: thread.projectId, | ||
| 181 | allowStale: false, | ||
| 182 | }; | ||
| 183 | yield* pullRequests | ||
| 184 | .invalidate({ reference: ref }) | ||
| 185 | .pipe( | ||
| 186 | Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })), | ||
| 187 | ); | ||
| 188 | const summary = yield* pullRequests | ||
| 189 | .summary(ref, { recoverTransientFailure: false }) | ||
| 190 | .pipe( | ||
| 191 | Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause })), | ||
| 192 | ); | ||
| 193 | if (summary.state !== "merged" || !Number.isFinite(Date.parse(summary.mergedAt ?? ""))) | ||
| 194 | return { archivedThreadIds: [] }; | ||
| 195 | } | ||
| 196 | } | ||
| 197 | const archivedThreadIds: ThreadId[] = []; | ||
| 198 | for (const thread of candidates) { | ||
| 199 | const uuid = yield* crypto | ||
| 200 | .randomUUIDv4() | ||
| 201 | .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause }))); | ||
| 202 | yield* orchestrator | ||
| 203 | .dispatch({ | ||
| 204 | type: "thread.archive", | ||
| 205 | commandId: CommandId.make(`managed-workspace:${uuid}`), | ||
| 206 | threadId: thread.id, | ||
| 207 | snapshotSequence: snapshot.snapshotSequence, | ||
| 208 | }) | ||
| 209 | .pipe(Effect.mapError((cause) => new ManagedWorkspaceError({ reason: "archive", cause }))); | ||
| 210 | archivedThreadIds.push(thread.id); | ||
| 211 | } | ||
| 212 | return { archivedThreadIds }; | ||
| 213 | }); | ||
| 214 | |||
| 215 | return { | ||
| 216 | status, | ||
| 217 | create, | ||
| 218 | archiveCompleted, | ||
| 219 | isManagedThread, | ||
| 220 | } satisfies ManagedWorkspaces["Service"]; | ||
| 221 | }); | ||
| 222 | |||
| 223 | export const layer = Layer.effect(ManagedWorkspaces, make); | ||
apps/server/src/workspace/managedWorkspaceArchive.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export function isManagedWorkspaceArchiveCandidate( | ||
| 4 | thread: OrchestrationV2ThreadShell, | ||
| 5 | ): thread is OrchestrationV2ThreadShell & { | ||
| 6 | readonly settledAt: NonNullable<OrchestrationV2ThreadShell["settledAt"]>; | ||
| 7 | } { | ||
| 8 | return ( | ||
| 9 | thread.settledOverride === "settled" && | ||
| 10 | thread.settledAt !== null && | ||
| 11 | thread.archivedAt === null && | ||
| 12 | thread.deletedAt === null && | ||
| 13 | thread.activeRunId === null && | ||
| 14 | ["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( | ||
| 15 | thread.status, | ||
| 16 | ) && | ||
| 17 | thread.pendingRuntimeRequest === null && | ||
| 18 | (thread.pendingBackgroundTasks?.length ?? 0) === 0 | ||
| 19 | ); | ||
| 20 | } | ||
apps/server/src/ws.ts+10-4| ... | @@ -8,14 +8,12 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -8,14 +8,12 @@ import * as DateTime from "effect/DateTime"; |
| 8 | import * as Duration from "effect/Duration"; | 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as Base64 from "effect/encoding/Base64"; | 9 | import * as Base64 from "effect/encoding/Base64"; |
| 10 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 15 | import * as Path from "effect/Path"; | 14 | import * as Path from "effect/Path"; |
| 16 | import * as Queue from "effect/Queue"; | 15 | import * as Queue from "effect/Queue"; |
| 17 | import * as Ref from "effect/Ref"; | 16 | import * as Ref from "effect/Ref"; |
| 18 | import * as Schedule from "effect/Schedule"; | ||
| 19 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 20 | import * as Result from "effect/Result"; | 18 | import * as Result from "effect/Result"; |
| 21 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| ... | @@ -191,6 +189,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; | ... | @@ -191,6 +189,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; |
| 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 189 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 192 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | 190 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; |
| 193 | import * as DevServer from "./workspace/DevServer.ts"; | 191 | import * as DevServer from "./workspace/DevServer.ts"; |
| 192 | import * as ManagedWorkspaces from "./workspace/ManagedWorkspaces.ts"; | ||
| 194 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 193 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 195 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; | 194 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; |
| 196 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 195 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| ... | @@ -204,7 +203,6 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts | ... | @@ -204,7 +203,6 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts |
| 204 | import * as ProjectService from "./project/ProjectService.ts"; | 203 | import * as ProjectService from "./project/ProjectService.ts"; |
| 205 | import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; | 204 | import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; |
| 206 | import { projectMutationOperation } from "./project/ProjectMutation.ts"; | 205 | import { projectMutationOperation } from "./project/ProjectMutation.ts"; |
| 207 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | ||
| 208 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | 206 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; |
| 209 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; | 207 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 210 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; | 208 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; |
| ... | @@ -1197,7 +1195,6 @@ const layerWsRpc = ( | ... | @@ -1197,7 +1195,6 @@ const layerWsRpc = ( |
| 1197 | ); | 1195 | ); |
| 1198 | const usage = yield* UsageService.UsageService; | 1196 | const usage = yield* UsageService.UsageService; |
| 1199 | const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; | 1197 | const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; |
| 1200 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | ||
| 1201 | const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; | 1198 | const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; |
| 1202 | const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; | 1199 | const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; |
| 1203 | const repositoryIdentityResolver = | 1200 | const repositoryIdentityResolver = |
| ... | @@ -1236,6 +1233,7 @@ const layerWsRpc = ( | ... | @@ -1236,6 +1233,7 @@ const layerWsRpc = ( |
| 1236 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; | 1233 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; |
| 1237 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 1234 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 1238 | const devServer = yield* DevServer.DevServer; | 1235 | const devServer = yield* DevServer.DevServer; |
| 1236 | const managedWorkspaces = yield* ManagedWorkspaces.ManagedWorkspaces; | ||
| 1239 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | 1237 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; |
| 1240 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | 1238 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 1241 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | 1239 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| ... | @@ -1678,6 +1676,7 @@ const layerWsRpc = ( | ... | @@ -1678,6 +1676,7 @@ const layerWsRpc = ( |
| 1678 | }, | 1676 | }, |
| 1679 | settings, | 1677 | settings, |
| 1680 | devServerStatus: true, | 1678 | devServerStatus: true, |
| 1679 | managedWorkspaces: true, | ||
| 1681 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), | 1680 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), |
| 1682 | shellResumeCompletionMarker: true, | 1681 | shellResumeCompletionMarker: true, |
| 1683 | threadResumeCompletionMarker: true, | 1682 | threadResumeCompletionMarker: true, |
| ... | @@ -1809,6 +1808,9 @@ const layerWsRpc = ( | ... | @@ -1809,6 +1808,9 @@ const layerWsRpc = ( |
| 1809 | return new OrchestrationV2DispatchCommandError({ | 1808 | return new OrchestrationV2DispatchCommandError({ |
| 1810 | commandId: command.commandId, | 1809 | commandId: command.commandId, |
| 1811 | commandType: command.type, | 1810 | commandType: command.type, |
| 1811 | ...(cause._tag === "OrchestratorDispatchError" && cause.reason !== undefined | ||
| 1812 | ? { reason: cause.reason } | ||
| 1813 | : {}), | ||
| 1812 | message: detail ?? "Failed to dispatch orchestration V2 command", | 1814 | message: detail ?? "Failed to dispatch orchestration V2 command", |
| 1813 | ...(detail === undefined ? {} : { detail }), | 1815 | ...(detail === undefined ? {} : { detail }), |
| 1814 | cause, | 1816 | cause, |
| ... | @@ -2604,6 +2606,10 @@ const layerWsRpc = ( | ... | @@ -2604,6 +2606,10 @@ const layerWsRpc = ( |
| 2604 | }), | 2606 | }), |
| 2605 | ), | 2607 | ), |
| 2606 | ), | 2608 | ), |
| 2609 | [WS_METHODS.managedWorkspacesStatus]: () => managedWorkspaces.status(), | ||
| 2610 | [WS_METHODS.managedWorkspacesCreate]: () => managedWorkspaces.create(), | ||
| 2611 | [WS_METHODS.managedWorkspacesArchiveCompleted]: (input) => | ||
| 2612 | managedWorkspaces.archiveCompleted(input), | ||
| 2607 | [WS_METHODS.devServerStatus]: (input) => devServer.status(input), | 2613 | [WS_METHODS.devServerStatus]: (input) => devServer.status(input), |
| 2608 | [WS_METHODS.devServerStart]: (input) => devServer.start(input), | 2614 | [WS_METHODS.devServerStart]: (input) => devServer.start(input), |
| 2609 | [WS_METHODS.projectsReadFile]: (input) => | 2615 | [WS_METHODS.projectsReadFile]: (input) => |
apps/web/src/components/AppSidebarLayout.tsx+6| ... | @@ -35,6 +35,7 @@ import LegacyThreadSidebar from "./LegacySidebar"; | ... | @@ -35,6 +35,7 @@ import LegacyThreadSidebar from "./LegacySidebar"; |
| 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; | 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; |
| 36 | import ThreadSidebar from "./Sidebar"; | 36 | import ThreadSidebar from "./Sidebar"; |
| 37 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | 37 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 38 | import { NewWorkWorkspaceButton } from "./sidebar/NewWorkWorkspaceButton"; | ||
| 38 | import { AppMenu } from "./sidebar/AppMenu"; | 39 | import { AppMenu } from "./sidebar/AppMenu"; |
| 39 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; | 40 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 40 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; | 41 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; |
| ... | @@ -155,6 +156,11 @@ function SidebarControl() { | ... | @@ -155,6 +156,11 @@ function SidebarControl() { |
| 155 | Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} | 156 | Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} |
| 156 | </TooltipPopup> | 157 | </TooltipPopup> |
| 157 | </Tooltip> | 158 | </Tooltip> |
| 159 | {isSidebarVisible ? ( | ||
| 160 | <div className="pointer-events-auto ml-2 [-webkit-app-region:no-drag]"> | ||
| 161 | <NewWorkWorkspaceButton /> | ||
| 162 | </div> | ||
| 163 | ) : null} | ||
| 158 | </div> | 164 | </div> |
| 159 | ); | 165 | ); |
| 160 | } | 166 | } |
apps/web/src/components/CommandPalette.tsx+13| ... | @@ -119,6 +119,7 @@ import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ... | @@ -119,6 +119,7 @@ import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 119 | import { sourceControlEnvironment } from "../state/sourceControl"; | 119 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 120 | import { useAtomCommand } from "../state/use-atom-command"; | 120 | import { useAtomCommand } from "../state/use-atom-command"; |
| 121 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 121 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 122 | import { useNewWorkWorkspace } from "../hooks/useNewWorkWorkspace"; | ||
| 122 | import { useScratchProject } from "../hooks/useScratchProject"; | 123 | import { useScratchProject } from "../hooks/useScratchProject"; |
| 123 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 124 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 124 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 125 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| ... | @@ -621,6 +622,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -621,6 +622,7 @@ function OpenCommandPaletteDialog(props: { |
| 621 | const { environments } = useEnvironments(); | 622 | const { environments } = useEnvironments(); |
| 622 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); | 623 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); |
| 623 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 624 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 625 | const newWorkWorkspace = useNewWorkWorkspace(); | ||
| 624 | const availableSettingsSearchItems = useAvailableSettingsSearchItems(); | 626 | const availableSettingsSearchItems = useAvailableSettingsSearchItems(); |
| 625 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = | 627 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 626 | useHandleNewThread(); | 628 | useHandleNewThread(); |
| ... | @@ -1774,6 +1776,17 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1774,6 +1776,17 @@ function OpenCommandPaletteDialog(props: { |
| 1774 | }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]); | 1776 | }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]); |
| 1775 | 1777 | ||
| 1776 | const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = []; | 1778 | const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = []; |
| 1779 | if (newWorkWorkspace.project) { | ||
| 1780 | actionItems.push({ | ||
| 1781 | kind: "action", | ||
| 1782 | value: "action:new-work-workspace", | ||
| 1783 | searchTerms: ["my work", "workspace", "new", "create"], | ||
| 1784 | title: newWorkWorkspace.label, | ||
| 1785 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | ||
| 1786 | disabled: newWorkWorkspace.disabled, | ||
| 1787 | run: newWorkWorkspace.run, | ||
| 1788 | }); | ||
| 1789 | } | ||
| 1777 | 1790 | ||
| 1778 | if (projects.length > 0) { | 1791 | if (projects.length > 0) { |
| 1779 | const activeProjectTitle = | 1792 | const activeProjectTitle = |
apps/web/src/components/Sidebar.logic.test.ts+78-3| ... | @@ -60,7 +60,15 @@ import { | ... | @@ -60,7 +60,15 @@ import { |
| 60 | resolveSidebarDropVerb, | 60 | resolveSidebarDropVerb, |
| 61 | } from "./Sidebar.logic"; | 61 | } from "./Sidebar.logic"; |
| 62 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 62 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 63 | import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; | 63 | import { |
| 64 | CommandId, | ||
| 65 | EnvironmentId, | ||
| 66 | OrchestrationV2DispatchCommandError, | ||
| 67 | ProjectId, | ||
| 68 | ProviderInstanceId, | ||
| 69 | RunId, | ||
| 70 | ThreadId, | ||
| 71 | } from "@t3tools/contracts"; | ||
| 64 | 72 | ||
| 65 | import { | 73 | import { |
| 66 | DEFAULT_INTERACTION_MODE, | 74 | DEFAULT_INTERACTION_MODE, |
| ... | @@ -242,8 +250,8 @@ describe("deleteSelectedThreadEntries", () => { | ... | @@ -242,8 +250,8 @@ describe("deleteSelectedThreadEntries", () => { |
| 242 | 250 | ||
| 243 | describe("archiveSelectedThreadEntries", () => { | 251 | describe("archiveSelectedThreadEntries", () => { |
| 244 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; | 252 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; |
| 245 | const success = { _tag: "Success" } as const; | 253 | const success = AsyncResult.success(undefined); |
| 246 | const failure = { _tag: "Failure" } as const; | 254 | const failure = AsyncResult.failure(Cause.fail(new Error("Archive failed"))); |
| 247 | 255 | ||
| 248 | it("records every entry after full success", async () => { | 256 | it("records every entry after full success", async () => { |
| 249 | const outcome = await archiveSelectedThreadEntries({ | 257 | const outcome = await archiveSelectedThreadEntries({ |
| ... | @@ -291,6 +299,73 @@ describe("archiveSelectedThreadEntries", () => { | ... | @@ -291,6 +299,73 @@ describe("archiveSelectedThreadEntries", () => { |
| 291 | followupFailures: [failure], | 299 | followupFailures: [failure], |
| 292 | }); | 300 | }); |
| 293 | }); | 301 | }); |
| 302 | |||
| 303 | it("archives a full shelf beyond the visible tail and omits a revived entry", async () => { | ||
| 304 | const entries = Array.from({ length: 137 }, (_, index) => ({ threadKey: `settled-${index}` })); | ||
| 305 | const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { | ||
| 306 | if (entry.threadKey === "settled-92") return null; | ||
| 307 | onArchived(); | ||
| 308 | return success; | ||
| 309 | }); | ||
| 310 | const outcome = await archiveSelectedThreadEntries({ entries, archive }); | ||
| 311 | |||
| 312 | expect(archive).toHaveBeenCalledTimes(137); | ||
| 313 | expect(outcome.archivedThreadKeys).toHaveLength(136); | ||
| 314 | expect(outcome.archivedThreadKeys).toContain("settled-136"); | ||
| 315 | expect(outcome.archivedThreadKeys).not.toContain("settled-92"); | ||
| 316 | expect(outcome.mutationFailure).toBeNull(); | ||
| 317 | expect(outcome.followupFailures).toEqual([]); | ||
| 318 | }); | ||
| 319 | it("skips a typed condition rejection in the middle and archives later candidates", async () => { | ||
| 320 | const changed = AsyncResult.failure( | ||
| 321 | Cause.fail( | ||
| 322 | new OrchestrationV2DispatchCommandError({ | ||
| 323 | commandId: CommandId.make("archive-two"), | ||
| 324 | commandType: "thread.archive", | ||
| 325 | reason: "archive-condition-changed", | ||
| 326 | message: "The thread changed before automatic archival.", | ||
| 327 | }), | ||
| 328 | ), | ||
| 329 | ); | ||
| 330 | const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { | ||
| 331 | if (entry.threadKey === "two") return changed; | ||
| 332 | onArchived(); | ||
| 333 | return success; | ||
| 334 | }); | ||
| 335 | const outcome = await archiveSelectedThreadEntries({ entries, archive }); | ||
| 336 | |||
| 337 | expect(archive).toHaveBeenCalledTimes(3); | ||
| 338 | expect(outcome).toEqual({ | ||
| 339 | archivedThreadKeys: ["one", "three"], | ||
| 340 | mutationFailure: null, | ||
| 341 | followupFailures: [], | ||
| 342 | }); | ||
| 343 | }); | ||
| 344 | |||
| 345 | it("does not skip a genuine failure with the condition error's text", async () => { | ||
| 346 | const rejected = AsyncResult.failure( | ||
| 347 | Cause.fail( | ||
| 348 | new OrchestrationV2DispatchCommandError({ | ||
| 349 | commandId: CommandId.make("archive-two"), | ||
| 350 | commandType: "thread.archive", | ||
| 351 | message: "The thread changed before automatic archival.", | ||
| 352 | }), | ||
| 353 | ), | ||
| 354 | ); | ||
| 355 | const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { | ||
| 356 | if (entry.threadKey === "two") return rejected; | ||
| 357 | onArchived(); | ||
| 358 | return success; | ||
| 359 | }); | ||
| 360 | const outcome = await archiveSelectedThreadEntries({ entries, archive }); | ||
| 361 | |||
| 362 | expect(archive).toHaveBeenCalledTimes(2); | ||
| 363 | expect(outcome).toEqual({ | ||
| 364 | archivedThreadKeys: ["one"], | ||
| 365 | mutationFailure: rejected, | ||
| 366 | followupFailures: [], | ||
| 367 | }); | ||
| 368 | }); | ||
| 294 | }); | 369 | }); |
| 295 | 370 | ||
| 296 | describe("buildBulkUnpinContextMenuItem", () => { | 371 | describe("buildBulkUnpinContextMenuItem", () => { |
apps/web/src/components/Sidebar.logic.ts+24-9| ... | @@ -8,7 +8,14 @@ import { | ... | @@ -8,7 +8,14 @@ import { |
| 8 | } from "@t3tools/client-runtime/state/runtime"; | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; | 9 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 11 | import { |
| 12 | OrchestrationV2DispatchCommandError, | ||
| 13 | type ContextMenuItem, | ||
| 14 | type EnvironmentId, | ||
| 15 | type ThreadId, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as Cause from "effect/Cause"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 12 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 19 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 13 | import type { AsyncResult } from "effect/reactivity"; | 20 | import type { AsyncResult } from "effect/reactivity"; |
| 14 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; | 21 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; |
| ... | @@ -516,33 +523,41 @@ export async function deleteSelectedThreadEntries< | ... | @@ -516,33 +523,41 @@ export async function deleteSelectedThreadEntries< |
| 516 | return { deletedThreadKeys, firstFailure }; | 523 | return { deletedThreadKeys, firstFailure }; |
| 517 | } | 524 | } |
| 518 | 525 | ||
| 526 | const isDispatchCommandError = Schema.is(OrchestrationV2DispatchCommandError); | ||
| 527 | |||
| 519 | export async function archiveSelectedThreadEntries< | 528 | export async function archiveSelectedThreadEntries< |
| 520 | TEntry extends { readonly threadKey: string }, | 529 | TEntry extends { readonly threadKey: string }, |
| 521 | TResult extends { readonly _tag: "Success" | "Failure" }, | ||
| 522 | >(input: { | 530 | >(input: { |
| 523 | entries: readonly TEntry[]; | 531 | entries: readonly TEntry[]; |
| 524 | archive: (entry: TEntry, onArchived: () => void) => Promise<TResult>; | 532 | archive: ( |
| 533 | entry: TEntry, | ||
| 534 | onArchived: () => void, | ||
| 535 | ) => Promise<AtomCommandResult<unknown, unknown> | null>; | ||
| 525 | }): Promise<{ | 536 | }): Promise<{ |
| 526 | archivedThreadKeys: readonly string[]; | 537 | archivedThreadKeys: readonly string[]; |
| 527 | mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null; | 538 | mutationFailure: AsyncResult.Failure<unknown, unknown> | null; |
| 528 | followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[]; | 539 | followupFailures: readonly AsyncResult.Failure<unknown, unknown>[]; |
| 529 | }> { | 540 | }> { |
| 530 | const archivedThreadKeys: string[] = []; | 541 | const archivedThreadKeys: string[] = []; |
| 531 | const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = []; | 542 | const followupFailures: AsyncResult.Failure<unknown, unknown>[] = []; |
| 532 | 543 | ||
| 533 | for (const entry of input.entries) { | 544 | for (const entry of input.entries) { |
| 534 | let didArchive = false; | 545 | let didArchive = false; |
| 535 | const result = await input.archive(entry, () => { | 546 | const result = await input.archive(entry, () => { |
| 536 | didArchive = true; | 547 | didArchive = true; |
| 537 | }); | 548 | }); |
| 549 | if (result === null) continue; | ||
| 538 | if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey); | 550 | if (didArchive || result._tag === "Success") archivedThreadKeys.push(entry.threadKey); |
| 539 | if (result._tag === "Success") continue; | 551 | if (result._tag === "Success") continue; |
| 540 | const failure = result as Extract<TResult, { readonly _tag: "Failure" }>; | ||
| 541 | if (didArchive) { | 552 | if (didArchive) { |
| 542 | followupFailures.push(failure); | 553 | followupFailures.push(result); |
| 554 | continue; | ||
| 555 | } | ||
| 556 | const error = Cause.squash(result.cause); | ||
| 557 | if (isDispatchCommandError(error) && error.reason === "archive-condition-changed") { | ||
| 543 | continue; | 558 | continue; |
| 544 | } | 559 | } |
| 545 | return { archivedThreadKeys, mutationFailure: failure, followupFailures }; | 560 | return { archivedThreadKeys, mutationFailure: result, followupFailures }; |
| 546 | } | 561 | } |
| 547 | 562 | ||
| 548 | return { archivedThreadKeys, mutationFailure: null, followupFailures }; | 563 | return { archivedThreadKeys, mutationFailure: null, followupFailures }; |
apps/web/src/components/Sidebar.tsx+188-47| ... | @@ -172,6 +172,8 @@ import { | ... | @@ -172,6 +172,8 @@ import { |
| 172 | import { environmentProjects } from "../state/projects"; | 172 | import { environmentProjects } from "../state/projects"; |
| 173 | import { vcsEnvironment } from "../state/vcs"; | 173 | import { vcsEnvironment } from "../state/vcs"; |
| 174 | import { threadEnvironment } from "../state/threads"; | 174 | import { threadEnvironment } from "../state/threads"; |
| 175 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 176 | import { environmentSnapshotAtom } from "../state/shell"; | ||
| 175 | import { useEnvironmentQuery } from "../state/query"; | 177 | import { useEnvironmentQuery } from "../state/query"; |
| 176 | import { useThreadSearch } from "../state/queries"; | 178 | import { useThreadSearch } from "../state/queries"; |
| 177 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | 179 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| ... | @@ -201,10 +203,12 @@ import { | ... | @@ -201,10 +203,12 @@ import { |
| 201 | } from "./threadActionMenu.logic"; | 203 | } from "./threadActionMenu.logic"; |
| 202 | import { | 204 | import { |
| 203 | animateSidebarLayoutChanges, | 205 | animateSidebarLayoutChanges, |
| 206 | archiveSelectedThreadEntries, | ||
| 204 | applySidebarThreadDrop, | 207 | applySidebarThreadDrop, |
| 205 | filterSidebarV2VisibleThreads, | 208 | filterSidebarV2VisibleThreads, |
| 206 | buildBulkTitleRegenerationContextMenuItem, | 209 | buildBulkTitleRegenerationContextMenuItem, |
| 207 | buildBulkUnpinContextMenuItem, | 210 | buildBulkUnpinContextMenuItem, |
| 211 | buildMultiSelectThreadContextMenuItems, | ||
| 208 | deleteSelectedThreadEntries, | 212 | deleteSelectedThreadEntries, |
| 209 | filterSidebarProjectScopeItems, | 213 | filterSidebarProjectScopeItems, |
| 210 | formatWorkingDurationLabel, | 214 | formatWorkingDurationLabel, |
| ... | @@ -305,15 +309,12 @@ import { | ... | @@ -305,15 +309,12 @@ import { |
| 305 | type DraftSessionState, | 309 | type DraftSessionState, |
| 306 | } from "../composerDraftStore"; | 310 | } from "../composerDraftStore"; |
| 307 | 311 | ||
| 308 | // Settled-tail paging: recent history is the common lookup; the deep tail | ||
| 309 | // stays behind an explicit Show more. | ||
| 310 | const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); | 312 | const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); |
| 311 | // Collapsed shelves share one empty list so a route change alone does not | 313 | // Collapsed shelves share one empty list so a route change alone does not |
| 312 | // give the sidebar list a new identity. | 314 | // give the sidebar list a new identity. |
| 313 | const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; | 315 | const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; |
| 314 | 316 | ||
| 315 | const SETTLED_TAIL_INITIAL_COUNT = 10; | 317 | const SETTLED_TAIL_INITIAL_COUNT = 10; |
| 316 | const SETTLED_TAIL_PAGE_COUNT = 25; | ||
| 317 | // Fresh keys deliberately reset both shelves to collapsed for existing users. | 318 | // Fresh keys deliberately reset both shelves to collapsed for existing users. |
| 318 | const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; | 319 | const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; |
| 319 | const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; | 320 | const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; |
| ... | @@ -818,6 +819,7 @@ function SidebarSectionHeader(props: { | ... | @@ -818,6 +819,7 @@ function SidebarSectionHeader(props: { |
| 818 | dragging?: boolean; | 819 | dragging?: boolean; |
| 819 | isDropTarget?: boolean; | 820 | isDropTarget?: boolean; |
| 820 | toggle: { expanded: boolean; onToggle: () => void }; | 821 | toggle: { expanded: boolean; onToggle: () => void }; |
| 822 | onContextMenu?: (position: { x: number; y: number }) => void; | ||
| 821 | }) { | 823 | }) { |
| 822 | const shelf = | 824 | const shelf = |
| 823 | props.marker === "working-header" | 825 | props.marker === "working-header" |
| ... | @@ -834,6 +836,22 @@ function SidebarSectionHeader(props: { | ... | @@ -834,6 +836,22 @@ function SidebarSectionHeader(props: { |
| 834 | > | 836 | > |
| 835 | <CollapsibleSectionHeader | 837 | <CollapsibleSectionHeader |
| 836 | onClick={props.toggle.onToggle} | 838 | onClick={props.toggle.onToggle} |
| 839 | onContextMenu={(event) => { | ||
| 840 | if (!props.onContextMenu) return; | ||
| 841 | event.preventDefault(); | ||
| 842 | props.onContextMenu({ x: event.clientX, y: event.clientY }); | ||
| 843 | }} | ||
| 844 | onKeyDown={(event) => { | ||
| 845 | if ( | ||
| 846 | !props.onContextMenu || | ||
| 847 | (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) | ||
| 848 | ) { | ||
| 849 | return; | ||
| 850 | } | ||
| 851 | event.preventDefault(); | ||
| 852 | const rect = event.currentTarget.getBoundingClientRect(); | ||
| 853 | props.onContextMenu({ x: rect.left, y: rect.bottom }); | ||
| 854 | }} | ||
| 837 | expanded={props.toggle.expanded} | 855 | expanded={props.toggle.expanded} |
| 838 | tone={ | 856 | tone={ |
| 839 | props.isDropTarget ? "accent" : props.dragging ? "emphasized" : snoozed ? "info" : "muted" | 857 | props.isDropTarget ? "accent" : props.dragging ? "emphasized" : snoozed ? "info" : "muted" |
| ... | @@ -2454,6 +2472,7 @@ export default function Sidebar() { | ... | @@ -2454,6 +2472,7 @@ export default function Sidebar() { |
| 2454 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 2472 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 2455 | const { | 2473 | const { |
| 2456 | settleThread, | 2474 | settleThread, |
| 2475 | confirmThreadSettlement, | ||
| 2457 | unsettleThread, | 2476 | unsettleThread, |
| 2458 | snoozeThread, | 2477 | snoozeThread, |
| 2459 | unsnoozeThread, | 2478 | unsnoozeThread, |
| ... | @@ -2467,6 +2486,7 @@ export default function Sidebar() { | ... | @@ -2467,6 +2486,7 @@ export default function Sidebar() { |
| 2467 | archiveThread, | 2486 | archiveThread, |
| 2468 | deleteThread, | 2487 | deleteThread, |
| 2469 | } = useThreadActions(); | 2488 | } = useThreadActions(); |
| 2489 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | ||
| 2470 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | 2490 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { |
| 2471 | reportFailure: false, | 2491 | reportFailure: false, |
| 2472 | }); | 2492 | }); |
| ... | @@ -3009,10 +3029,7 @@ export default function Sidebar() { | ... | @@ -3009,10 +3029,7 @@ export default function Sidebar() { |
| 3009 | return () => window.clearTimeout(id); | 3029 | return () => window.clearTimeout(id); |
| 3010 | }, [snoozedThreads]); | 3030 | }, [snoozedThreads]); |
| 3011 | 3031 | ||
| 3012 | // The settled tail renders in pages: history shouldn't dominate the | 3032 | // Changing project scope restores the recent tail; the open thread stays visible. |
| 3013 | // sidebar, and the common lookups are recent. Expansion resets when the | ||
| 3014 | // filter context changes so a scope/search flip never inherits a deep | ||
| 3015 | // page state. | ||
| 3016 | const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); | 3033 | const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); |
| 3017 | const settledResetKey = projectScopeKey ?? "all"; | 3034 | const settledResetKey = projectScopeKey ?? "all"; |
| 3018 | const lastSettledResetKeyRef = useRef(settledResetKey); | 3035 | const lastSettledResetKeyRef = useRef(settledResetKey); |
| ... | @@ -3039,9 +3056,90 @@ export default function Sidebar() { | ... | @@ -3039,9 +3056,90 @@ export default function Sidebar() { |
| 3039 | }, [routeThreadKey, settledThreads, settledVisibleCount]); | 3056 | }, [routeThreadKey, settledThreads, settledVisibleCount]); |
| 3040 | const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; | 3057 | const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; |
| 3041 | const showMoreSettled = useCallback( | 3058 | const showMoreSettled = useCallback( |
| 3042 | () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), | 3059 | () => setSettledVisibleCount(settledThreads.length), |
| 3043 | [], | 3060 | [settledThreads.length], |
| 3044 | ); | 3061 | ); |
| 3062 | const [archivingThreads, setArchivingThreads] = useState(false); | ||
| 3063 | const archiveThreads = useCallback( | ||
| 3064 | async (targets: readonly EnvironmentThreadShell[], onlySettled = false) => { | ||
| 3065 | if (archivingThreads || targets.length === 0 || !checkThreadOperations(targets)) return; | ||
| 3066 | const entries = targets.map((thread) => { | ||
| 3067 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); | ||
| 3068 | return { threadRef, threadKey: scopedThreadKey(threadRef) }; | ||
| 3069 | }); | ||
| 3070 | setArchivingThreads(true); | ||
| 3071 | try { | ||
| 3072 | if (confirmThreadArchive) { | ||
| 3073 | const api = readLocalApi(); | ||
| 3074 | if (!api) return; | ||
| 3075 | const confirmation = await settlePromise(() => | ||
| 3076 | api.dialogs.confirm( | ||
| 3077 | `Archive ${entries.length}${onlySettled ? " settled" : ""} thread${entries.length === 1 ? "" : "s"}?`, | ||
| 3078 | ), | ||
| 3079 | ); | ||
| 3080 | if (confirmation._tag === "Failure" || !confirmation.value) return; | ||
| 3081 | } | ||
| 3082 | if (!checkThreadOperations(targets)) return; | ||
| 3083 | const outcome = await archiveSelectedThreadEntries({ | ||
| 3084 | entries, | ||
| 3085 | archive: ({ threadRef }, onArchived) => { | ||
| 3086 | const snapshot = appAtomRegistry.get(environmentSnapshotAtom(threadRef.environmentId)); | ||
| 3087 | const current = snapshot?.threads.find((thread) => thread.id === threadRef.threadId); | ||
| 3088 | if ( | ||
| 3089 | snapshot === null || | ||
| 3090 | current === undefined || | ||
| 3091 | current.archivedAt !== null || | ||
| 3092 | (onlySettled && current.settledOverride !== "settled") | ||
| 3093 | ) { | ||
| 3094 | return Promise.resolve(null); | ||
| 3095 | } | ||
| 3096 | return archiveThread(threadRef, { | ||
| 3097 | onArchived, | ||
| 3098 | ...(onlySettled ? { snapshotSequence: snapshot.snapshotSequence } : {}), | ||
| 3099 | }); | ||
| 3100 | }, | ||
| 3101 | }); | ||
| 3102 | removeFromSelection(outcome.archivedThreadKeys); | ||
| 3103 | for (const failure of [outcome.mutationFailure, ...outcome.followupFailures]) { | ||
| 3104 | if (failure === null || isAtomCommandInterrupted(failure)) continue; | ||
| 3105 | const error = squashAtomCommandFailure(failure); | ||
| 3106 | toastManager.add( | ||
| 3107 | stackedThreadToast({ | ||
| 3108 | type: "error", | ||
| 3109 | title: | ||
| 3110 | failure === outcome.mutationFailure | ||
| 3111 | ? "Failed to archive threads" | ||
| 3112 | : "Thread archived, but navigation failed", | ||
| 3113 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 3114 | }), | ||
| 3115 | ); | ||
| 3116 | } | ||
| 3117 | } finally { | ||
| 3118 | setArchivingThreads(false); | ||
| 3119 | } | ||
| 3120 | }, | ||
| 3121 | [archiveThread, archivingThreads, confirmThreadArchive, removeFromSelection], | ||
| 3122 | ); | ||
| 3123 | const handleSettledContextMenu = async (position: { x: number; y: number }) => { | ||
| 3124 | const api = readLocalApi(); | ||
| 3125 | if (!api) return; | ||
| 3126 | const clicked = await settlePromise(() => | ||
| 3127 | api.contextMenu.show( | ||
| 3128 | [ | ||
| 3129 | { | ||
| 3130 | id: "archive-all", | ||
| 3131 | label: "Archive all", | ||
| 3132 | disabled: | ||
| 3133 | settledThreads.length === 0 || archivingThreads || !canOperateThreads(settledThreads), | ||
| 3134 | }, | ||
| 3135 | ], | ||
| 3136 | position, | ||
| 3137 | ), | ||
| 3138 | ); | ||
| 3139 | if (clicked._tag === "Success" && clicked.value === "archive-all") { | ||
| 3140 | await archiveThreads(settledThreads, true); | ||
| 3141 | } | ||
| 3142 | }; | ||
| 3045 | const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( | 3143 | const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( |
| 3046 | SETTLED_SHELF_EXPANDED_KEY, | 3144 | SETTLED_SHELF_EXPANDED_KEY, |
| 3047 | false, | 3145 | false, |
| ... | @@ -3401,14 +3499,17 @@ export default function Sidebar() { | ... | @@ -3401,14 +3499,17 @@ export default function Sidebar() { |
| 3401 | ); | 3499 | ); |
| 3402 | 3500 | ||
| 3403 | const attemptSettle = useCallback( | 3501 | const attemptSettle = useCallback( |
| 3404 | (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => { | 3502 | ( |
| 3503 | threadRef: ScopedThreadRef, | ||
| 3504 | opts: { coSettlingKeys?: ReadonlySet<string>; confirmed?: true } = {}, | ||
| 3505 | ) => { | ||
| 3405 | void (async () => { | 3506 | void (async () => { |
| 3406 | const threadKey = scopedThreadKey(threadRef); | 3507 | const threadKey = scopedThreadKey(threadRef); |
| 3407 | if (settlingThreadKeysRef.current.has(threadKey)) return; | 3508 | if (settlingThreadKeysRef.current.has(threadKey)) return; |
| 3408 | settlingThreadKeysRef.current.add(threadKey); | 3509 | settlingThreadKeysRef.current.add(threadKey); |
| 3409 | try { | 3510 | try { |
| 3410 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); | 3511 | const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); |
| 3411 | const result = await settleThread(threadRef); | 3512 | const result = await settleThread(threadRef, opts.confirmed ? { confirmed: true } : {}); |
| 3412 | if (result._tag === "Failure") { | 3513 | if (result._tag === "Failure") { |
| 3413 | // Never navigate away from a thread that did not settle. | 3514 | // Never navigate away from a thread that did not settle. |
| 3414 | if (!isAtomCommandInterrupted(result)) { | 3515 | if (!isAtomCommandInterrupted(result)) { |
| ... | @@ -3451,15 +3552,35 @@ export default function Sidebar() { | ... | @@ -3451,15 +3552,35 @@ export default function Sidebar() { |
| 3451 | // Pinned rows ARE included: the decider clears the pin as part of settling, | 3552 | // Pinned rows ARE included: the decider clears the pin as part of settling, |
| 3452 | // so they park like the rest. | 3553 | // so they park like the rest. |
| 3453 | const settleThreads = useCallback( | 3554 | const settleThreads = useCallback( |
| 3454 | (threadKeys: readonly string[]) => { | 3555 | async (threadKeys: readonly string[]) => { |
| 3455 | const coSettlingKeys = new Set(threadKeys); | 3556 | const targets = threadKeys.flatMap((threadKey) => { |
| 3456 | for (const threadKey of threadKeys) { | ||
| 3457 | const thread = threadByKeyRef.current.get(threadKey); | 3557 | const thread = threadByKeyRef.current.get(threadKey); |
| 3458 | if (!thread || thread.settledOverride === "settled") continue; | 3558 | return thread && thread.settledOverride !== "settled" |
| 3459 | attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); | 3559 | ? [scopeThreadRef(thread.environmentId, thread.id)] |
| 3560 | : []; | ||
| 3561 | }); | ||
| 3562 | const confirmation = await confirmThreadSettlement(targets); | ||
| 3563 | if (confirmation._tag === "Failure") { | ||
| 3564 | if (!isAtomCommandInterrupted(confirmation)) { | ||
| 3565 | const error = squashAtomCommandFailure(confirmation); | ||
| 3566 | toastManager.add( | ||
| 3567 | stackedThreadToast({ | ||
| 3568 | type: "error", | ||
| 3569 | title: "Failed to settle chats", | ||
| 3570 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 3571 | }), | ||
| 3572 | ); | ||
| 3573 | } | ||
| 3574 | return false; | ||
| 3575 | } | ||
| 3576 | if (!confirmation.value) return false; | ||
| 3577 | const coSettlingKeys = new Set(targets.map(scopedThreadKey)); | ||
| 3578 | for (const target of targets) { | ||
| 3579 | attemptSettle(target, { coSettlingKeys, confirmed: true }); | ||
| 3460 | } | 3580 | } |
| 3581 | return true; | ||
| 3461 | }, | 3582 | }, |
| 3462 | [attemptSettle], | 3583 | [attemptSettle, confirmThreadSettlement], |
| 3463 | ); | 3584 | ); |
| 3464 | const attemptUnsettle = useCallback( | 3585 | const attemptUnsettle = useCallback( |
| 3465 | (threadRef: ScopedThreadRef) => { | 3586 | (threadRef: ScopedThreadRef) => { |
| ... | @@ -4293,28 +4414,28 @@ export default function Sidebar() { | ... | @@ -4293,28 +4414,28 @@ export default function Sidebar() { |
| 4293 | [performSnooze], | 4414 | [performSnooze], |
| 4294 | ); | 4415 | ); |
| 4295 | 4416 | ||
| 4296 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | ||
| 4297 | const handleMultiSelectContextMenu = useCallback( | 4417 | const handleMultiSelectContextMenu = useCallback( |
| 4298 | async (position: { x: number; y: number }) => { | 4418 | async (position: { x: number; y: number }) => { |
| 4299 | const api = readLocalApi(); | 4419 | const api = readLocalApi(); |
| 4300 | if (!api) return; | 4420 | if (!api) return; |
| 4301 | // One exact actionable set: keys whose rows are actually rendered | 4421 | const readSelectedThread = (threadKey: string) => { |
| 4302 | // right now. Selections can outlive their rows (settled-tail paging, | 4422 | const threadRef = parseScopedThreadKey(threadKey); |
| 4303 | // thread deletion elsewhere) and the menu labels must count only what | 4423 | const thread = threadRef ? readThreadShell(threadRef) : null; |
| 4304 | // the actions will touch. | 4424 | return thread?.archivedAt === null ? thread : null; |
| 4425 | }; | ||
| 4305 | const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; | 4426 | const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; |
| 4306 | const threadKeys = selectedThreadKeys.filter((threadKey) => | 4427 | const selectedThreads = selectedThreadKeys.flatMap((threadKey) => { |
| 4307 | threadByKeyRef.current.has(threadKey), | 4428 | const thread = readSelectedThread(threadKey); |
| 4429 | return thread ? [thread] : []; | ||
| 4430 | }); | ||
| 4431 | const threadKeys = selectedThreads.map((thread) => | ||
| 4432 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 4308 | ); | 4433 | ); |
| 4309 | if (threadKeys.length === 0) return; | 4434 | if (threadKeys.length === 0) return; |
| 4310 | const count = threadKeys.length; | 4435 | const count = threadKeys.length; |
| 4311 | // Snooze (N) is offered when every selected thread can actually take | 4436 | // Snooze (N) is offered when every selected thread can actually take |
| 4312 | // it — a mixed selection with blocked-on-you work would half-apply. | 4437 | // it — a mixed selection with blocked-on-you work would half-apply. |
| 4313 | const selectionNow = new Date(); | 4438 | const selectionNow = new Date(); |
| 4314 | const selectedThreads = threadKeys.flatMap((threadKey) => { | ||
| 4315 | const thread = threadByKeyRef.current.get(threadKey); | ||
| 4316 | return thread ? [thread] : []; | ||
| 4317 | }); | ||
| 4318 | const settlingThreads = selectedThreads.filter( | 4439 | const settlingThreads = selectedThreads.filter( |
| 4319 | (thread) => thread.settledOverride !== "settled", | 4440 | (thread) => thread.settledOverride !== "settled", |
| 4320 | ); | 4441 | ); |
| ... | @@ -4389,13 +4510,22 @@ export default function Sidebar() { | ... | @@ -4389,13 +4510,22 @@ export default function Sidebar() { |
| 4389 | }, | 4510 | }, |
| 4390 | ] | 4511 | ] |
| 4391 | : []), | 4512 | : []), |
| 4392 | { id: "mark-unread", label: `Mark unread (${count})` }, | 4513 | ...buildMultiSelectThreadContextMenuItems({ |
| 4393 | { | 4514 | count, |
| 4394 | id: "delete", | 4515 | hasRunningThread: selectedThreads.some( |
| 4395 | label: `Delete (${count})`, | 4516 | (thread) => !threadRuntimeCanArchive(thread.runtime), |
| 4396 | destructive: true, | 4517 | ), |
| 4397 | disabled: !canOperateThreads(selectedThreads), | 4518 | }).map((item) => |
| 4398 | }, | 4519 | item.id === "mark-unread" |
| 4520 | ? item | ||
| 4521 | : { | ||
| 4522 | ...item, | ||
| 4523 | disabled: | ||
| 4524 | item.disabled || | ||
| 4525 | !canOperateThreads(selectedThreads) || | ||
| 4526 | (item.id === "archive" && archivingThreads), | ||
| 4527 | }, | ||
| 4528 | ), | ||
| 4399 | ], | 4529 | ], |
| 4400 | position, | 4530 | position, |
| 4401 | ), | 4531 | ), |
| ... | @@ -4408,7 +4538,7 @@ export default function Sidebar() { | ... | @@ -4408,7 +4538,7 @@ export default function Sidebar() { |
| 4408 | ? regeneratableTitleThreads | 4538 | ? regeneratableTitleThreads |
| 4409 | : clicked.value === "settle" | 4539 | : clicked.value === "settle" |
| 4410 | ? settlingThreads.flatMap((thread) => { | 4540 | ? settlingThreads.flatMap((thread) => { |
| 4411 | const current = threadByKeyRef.current.get( | 4541 | const current = readSelectedThread( |
| 4412 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | 4542 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 4413 | ); | 4543 | ); |
| 4414 | return current && current.settledOverride !== "settled" ? [current] : []; | 4544 | return current && current.settledOverride !== "settled" ? [current] : []; |
| ... | @@ -4497,22 +4627,28 @@ export default function Sidebar() { | ... | @@ -4497,22 +4627,28 @@ export default function Sidebar() { |
| 4497 | return; | 4627 | return; |
| 4498 | } | 4628 | } |
| 4499 | if (clicked.value === "settle") { | 4629 | if (clicked.value === "settle") { |
| 4500 | settleThreads( | 4630 | if ( |
| 4501 | actionTargets.map((thread) => | 4631 | await settleThreads( |
| 4502 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | 4632 | actionTargets.map((thread) => |
| 4503 | ), | 4633 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 4504 | ); | 4634 | ), |
| 4505 | clearSelection(); | 4635 | ) |
| 4636 | ) | ||
| 4637 | clearSelection(); | ||
| 4506 | return; | 4638 | return; |
| 4507 | } | 4639 | } |
| 4508 | if (clicked.value === "mark-unread") { | 4640 | if (clicked.value === "mark-unread") { |
| 4509 | for (const threadKey of threadKeys) { | 4641 | for (const threadKey of threadKeys) { |
| 4510 | const thread = threadByKeyRef.current.get(threadKey); | 4642 | const thread = readSelectedThread(threadKey); |
| 4511 | if (thread) markThreadUnread(scopeThreadRef(thread.environmentId, thread.id)); | 4643 | if (thread) markThreadUnread(scopeThreadRef(thread.environmentId, thread.id)); |
| 4512 | } | 4644 | } |
| 4513 | clearSelection(); | 4645 | clearSelection(); |
| 4514 | return; | 4646 | return; |
| 4515 | } | 4647 | } |
| 4648 | if (clicked.value === "archive") { | ||
| 4649 | await archiveThreads(selectedThreads); | ||
| 4650 | return; | ||
| 4651 | } | ||
| 4516 | if (clicked.value !== "delete") return; | 4652 | if (clicked.value !== "delete") return; |
| 4517 | if (confirmThreadDelete) { | 4653 | if (confirmThreadDelete) { |
| 4518 | const confirmed = await settlePromise(() => | 4654 | const confirmed = await settlePromise(() => |
| ... | @@ -4529,7 +4665,7 @@ export default function Sidebar() { | ... | @@ -4529,7 +4665,7 @@ export default function Sidebar() { |
| 4529 | if ( | 4665 | if ( |
| 4530 | !checkThreadOperations( | 4666 | !checkThreadOperations( |
| 4531 | threadKeys.flatMap((threadKey) => { | 4667 | threadKeys.flatMap((threadKey) => { |
| 4532 | const thread = threadByKeyRef.current.get(threadKey); | 4668 | const thread = readSelectedThread(threadKey); |
| 4533 | return thread ? [thread] : []; | 4669 | return thread ? [thread] : []; |
| 4534 | }), | 4670 | }), |
| 4535 | ) | 4671 | ) |
| ... | @@ -4538,7 +4674,7 @@ export default function Sidebar() { | ... | @@ -4538,7 +4674,7 @@ export default function Sidebar() { |
| 4538 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ | 4674 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ |
| 4539 | entries: threadKeys.map((threadKey) => ({ threadKey })), | 4675 | entries: threadKeys.map((threadKey) => ({ threadKey })), |
| 4540 | delete: async ({ threadKey }, deletedThreadKeys) => { | 4676 | delete: async ({ threadKey }, deletedThreadKeys) => { |
| 4541 | const thread = threadByKeyRef.current.get(threadKey); | 4677 | const thread = readSelectedThread(threadKey); |
| 4542 | if (!thread) return null; | 4678 | if (!thread) return null; |
| 4543 | return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { | 4679 | return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { |
| 4544 | deletedThreadKeys, | 4680 | deletedThreadKeys, |
| ... | @@ -4563,6 +4699,8 @@ export default function Sidebar() { | ... | @@ -4563,6 +4699,8 @@ export default function Sidebar() { |
| 4563 | ); | 4699 | ); |
| 4564 | }, | 4700 | }, |
| 4565 | [ | 4701 | [ |
| 4702 | archiveThreads, | ||
| 4703 | archivingThreads, | ||
| 4566 | attemptUnpin, | 4704 | attemptUnpin, |
| 4567 | clearSelection, | 4705 | clearSelection, |
| 4568 | confirmThreadDelete, | 4706 | confirmThreadDelete, |
| ... | @@ -5577,6 +5715,9 @@ export default function Sidebar() { | ... | @@ -5577,6 +5715,9 @@ export default function Sidebar() { |
| 5577 | expanded: settledShelfExpanded, | 5715 | expanded: settledShelfExpanded, |
| 5578 | onToggle: toggleSettledShelf, | 5716 | onToggle: toggleSettledShelf, |
| 5579 | }} | 5717 | }} |
| 5718 | onContextMenu={(position) => | ||
| 5719 | void handleSettledContextMenu(position) | ||
| 5720 | } | ||
| 5580 | />, | 5721 | />, |
| 5581 | ); | 5722 | ); |
| 5582 | break; | 5723 | break; |
| ... | @@ -5607,10 +5748,10 @@ export default function Sidebar() { | ... | @@ -5607,10 +5748,10 @@ export default function Sidebar() { |
| 5607 | <button | 5748 | <button |
| 5608 | type="button" | 5749 | type="button" |
| 5609 | onClick={showMoreSettled} | 5750 | onClick={showMoreSettled} |
| 5610 | className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" | 5751 | className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" |
| 5611 | > | 5752 | > |
| 5612 | <PlusIcon aria-hidden className="size-4 shrink-0" /> | 5753 | <PlusIcon aria-hidden className="size-4 shrink-0" /> |
| 5613 | Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more | 5754 | Show {hiddenSettledCount} more |
| 5614 | </button> | 5755 | </button> |
| 5615 | </li> | 5756 | </li> |
| 5616 | ) : null} | 5757 | ) : null} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+86| ... | @@ -8,7 +8,10 @@ import { | ... | @@ -8,7 +8,10 @@ import { |
| 8 | ProjectId, | 8 | ProjectId, |
| 9 | RunId, | 9 | RunId, |
| 10 | ThreadId, | 10 | ThreadId, |
| 11 | TurnItemId, | ||
| 12 | type OrchestrationV2TurnItem, | ||
| 11 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import { | 15 | import { |
| 13 | act, | 16 | act, |
| 14 | createRef, | 17 | createRef, |
| ... | @@ -553,6 +556,89 @@ describe("MessagesTimeline", () => { | ... | @@ -553,6 +556,89 @@ describe("MessagesTimeline", () => { |
| 553 | } | 556 | } |
| 554 | }); | 557 | }); |
| 555 | 558 | ||
| 559 | it("shows a system notification's actionable link without opening its inspector", async () => { | ||
| 560 | vi.unstubAllGlobals(); | ||
| 561 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 562 | Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true }); | ||
| 563 | vi.stubGlobal("requestAnimationFrame", () => 0); | ||
| 564 | vi.stubGlobal("cancelAnimationFrame", () => {}); | ||
| 565 | const item = { | ||
| 566 | id: TurnItemId.make("ci-failure"), | ||
| 567 | threadId: ThreadId.make("thread-1"), | ||
| 568 | runId: null, | ||
| 569 | nodeId: null, | ||
| 570 | providerThreadId: null, | ||
| 571 | providerTurnId: null, | ||
| 572 | nativeItemRef: null, | ||
| 573 | parentItemId: null, | ||
| 574 | ordinal: 0, | ||
| 575 | status: "completed", | ||
| 576 | title: null, | ||
| 577 | startedAt: null, | ||
| 578 | completedAt: null, | ||
| 579 | updatedAt: DateTime.makeUnsafe(MESSAGE_CREATED_AT), | ||
| 580 | type: "notification", | ||
| 581 | source: { kind: "system" }, | ||
| 582 | outcome: "failed", | ||
| 583 | summary: "CI failed for PR #42", | ||
| 584 | detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)", | ||
| 585 | } satisfies OrchestrationV2TurnItem; | ||
| 586 | const container = document.createElement("div"); | ||
| 587 | document.body.append(container); | ||
| 588 | const root = createRoot(container); | ||
| 589 | try { | ||
| 590 | await act(() => { | ||
| 591 | root.render( | ||
| 592 | <MessagesTimeline | ||
| 593 | {...buildProps()} | ||
| 594 | timelineEntries={[ | ||
| 595 | { | ||
| 596 | id: item.id, | ||
| 597 | kind: "work", | ||
| 598 | createdAt: MESSAGE_CREATED_AT, | ||
| 599 | entry: { | ||
| 600 | id: item.id, | ||
| 601 | createdAt: MESSAGE_CREATED_AT, | ||
| 602 | runId: null, | ||
| 603 | label: item.summary, | ||
| 604 | detail: item.detail, | ||
| 605 | tone: "error", | ||
| 606 | itemType: "notification", | ||
| 607 | toolLifecycleStatus: "completed", | ||
| 608 | structuredPayload: item, | ||
| 609 | projectedItem: { | ||
| 610 | position: 0, | ||
| 611 | visibility: "local", | ||
| 612 | sourceThreadId: item.threadId, | ||
| 613 | sourceItemId: item.id, | ||
| 614 | item, | ||
| 615 | }, | ||
| 616 | }, | ||
| 617 | }, | ||
| 618 | ]} | ||
| 619 | />, | ||
| 620 | ); | ||
| 621 | }); | ||
| 622 | const notice = container.querySelector('[data-v2-item-type="notification"]')!; | ||
| 623 | expect(notice.textContent).toContain("CI failed for PR #42"); | ||
| 624 | expect(notice.querySelector("[aria-expanded], button")).toBeNull(); | ||
| 625 | const link = notice.querySelector<HTMLAnchorElement>("a")!; | ||
| 626 | expect(link.textContent).toBe("Open PR #42 in Mergebot"); | ||
| 627 | expect(link.href).toBe("https://example.com/queue/pr/42"); | ||
| 628 | expect(link.closest('[hidden], [aria-hidden="true"]')).toBeNull(); | ||
| 629 | await act(() => link.focus()); | ||
| 630 | expect(document.activeElement).toBe(link); | ||
| 631 | const follow = vi.fn((event: Event) => event.preventDefault()); | ||
| 632 | link.addEventListener("click", follow); | ||
| 633 | await act(() => link.click()); | ||
| 634 | expect(follow).toHaveBeenCalledOnce(); | ||
| 635 | } finally { | ||
| 636 | await act(() => root.unmount()); | ||
| 637 | container.remove(); | ||
| 638 | vi.unstubAllGlobals(); | ||
| 639 | } | ||
| 640 | }); | ||
| 641 | |||
| 556 | it("shows dynamic tool input without cached output when the row is expanded", async () => { | 642 | it("shows dynamic tool input without cached output when the row is expanded", async () => { |
| 557 | activityTestState.expanded = true; | 643 | activityTestState.expanded = true; |
| 558 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 644 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
apps/web/src/components/chat/MessagesTimeline.tsx+40| ... | @@ -5529,6 +5529,46 @@ type WorkEntryRowProps = { | ... | @@ -5529,6 +5529,46 @@ type WorkEntryRowProps = { |
| 5529 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { | 5529 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) { |
| 5530 | const ctx = use(TimelineRowCtx); | 5530 | const ctx = use(TimelineRowCtx); |
| 5531 | const item = props.workEntry.projectedItem?.item; | 5531 | const item = props.workEntry.projectedItem?.item; |
| 5532 | if (item?.type === "notification" && item.source.kind === "system") { | ||
| 5533 | const failed = item.outcome === "failed"; | ||
| 5534 | return ( | ||
| 5535 | <WorkLogRow | ||
| 5536 | role="status" | ||
| 5537 | data-v2-item-type="notification" | ||
| 5538 | data-v2-item-visibility={props.workEntry.projectedItem?.visibility} | ||
| 5539 | wrapLabel | ||
| 5540 | icon={ | ||
| 5541 | <WorkEntryIcon | ||
| 5542 | name={failed ? "circle-alert" : "t3-code"} | ||
| 5543 | className={cn("size-4", failed ? "text-destructive" : "text-secondary-label")} | ||
| 5544 | /> | ||
| 5545 | } | ||
| 5546 | label={ | ||
| 5547 | <span className={cn("text-sm font-medium", failed && "text-destructive")}> | ||
| 5548 | {item.summary} | ||
| 5549 | </span> | ||
| 5550 | } | ||
| 5551 | trailing={ | ||
| 5552 | <TimelineRowTimestamp | ||
| 5553 | createdAt={props.workEntry.createdAt} | ||
| 5554 | timestampFormat={ctx.timestampFormat} | ||
| 5555 | alwaysVisible | ||
| 5556 | /> | ||
| 5557 | } | ||
| 5558 | > | ||
| 5559 | {item.detail ? ( | ||
| 5560 | <div className="ms-7 py-1 text-sm" data-thread-find-text="true"> | ||
| 5561 | <ChatMarkdown | ||
| 5562 | text={item.detail} | ||
| 5563 | cwd={ctx.markdownCwd} | ||
| 5564 | threadRef={ctx.threadRef ?? undefined} | ||
| 5565 | headingLevelOffset={MESSAGE_HEADING_LEVEL} | ||
| 5566 | /> | ||
| 5567 | </div> | ||
| 5568 | ) : null} | ||
| 5569 | </WorkLogRow> | ||
| 5570 | ); | ||
| 5571 | } | ||
| 5532 | const childThreadId = | 5572 | const childThreadId = |
| 5533 | item?.type === "notification" ? notificationChildThreadId(item.source) : undefined; | 5573 | item?.type === "notification" ? notificationChildThreadId(item.source) : undefined; |
| 5534 | if (item?.type !== "notification" || childThreadId === undefined) { | 5574 | if (item?.type !== "notification" || childThreadId === undefined) { |
apps/web/src/components/sidebar/NewWorkWorkspaceButton.tsx created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { createLucideIcon } from "lucide-react"; | ||
| 2 | |||
| 3 | import { useNewWorkWorkspace } from "../../hooks/useNewWorkWorkspace"; | ||
| 4 | import { Button } from "../ui/button"; | ||
| 5 | import { useSidebar } from "../ui/sidebar"; | ||
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 7 | |||
| 8 | const BriefcasePlusIcon = createLucideIcon("briefcase-plus", [ | ||
| 9 | ["path", { d: "M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2", key: "handle" }], | ||
| 10 | ["path", { d: "M12 20H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4", key: "case" }], | ||
| 11 | ["path", { d: "M16 18h6M19 15v6", key: "plus" }], | ||
| 12 | ]); | ||
| 13 | |||
| 14 | export function NewWorkWorkspaceButton() { | ||
| 15 | const { setOpenMobile } = useSidebar(); | ||
| 16 | const { project, pending, label, disabled, run } = useNewWorkWorkspace(() => | ||
| 17 | setOpenMobile(false), | ||
| 18 | ); | ||
| 19 | if (!project) return null; | ||
| 20 | |||
| 21 | return ( | ||
| 22 | <Tooltip> | ||
| 23 | <TooltipTrigger | ||
| 24 | render={ | ||
| 25 | <Button | ||
| 26 | variant="ghost" | ||
| 27 | size="icon-sm" | ||
| 28 | aria-label={label} | ||
| 29 | aria-busy={pending} | ||
| 30 | disabled={disabled} | ||
| 31 | onClick={() => void run()} | ||
| 32 | > | ||
| 33 | <BriefcasePlusIcon className="size-4" aria-hidden="true" /> | ||
| 34 | </Button> | ||
| 35 | } | ||
| 36 | /> | ||
| 37 | <TooltipPopup side="bottom">{label}</TooltipPopup> | ||
| 38 | </Tooltip> | ||
| 39 | ); | ||
| 40 | } | ||
apps/web/src/components/sidebar/SidebarChrome.tsx+5-1| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | } from "../ui/sidebar"; | 19 | } from "../ui/sidebar"; |
| 20 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; | 20 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; |
| 21 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; | 21 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; |
| 22 | import { NewWorkWorkspaceButton } from "./NewWorkWorkspaceButton"; | ||
| 22 | import { AppMenu } from "./AppMenu"; | 23 | import { AppMenu } from "./AppMenu"; |
| 23 | import { SidebarMergebotButton } from "./SidebarMergebotButton"; | 24 | import { SidebarMergebotButton } from "./SidebarMergebotButton"; |
| 24 | import { SidebarUsageButton } from "./SidebarUsageButton"; | 25 | import { SidebarUsageButton } from "./SidebarUsageButton"; |
| ... | @@ -49,9 +50,12 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ | ... | @@ -49,9 +50,12 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 49 | <AppMenu /> | 50 | <AppMenu /> |
| 50 | </div> | 51 | </div> |
| 51 | <SidebarTrigger aria-label="Toggle main sidebar" /> | 52 | <SidebarTrigger aria-label="Toggle main sidebar" /> |
| 53 | <div className="ml-2"> | ||
| 54 | <NewWorkWorkspaceButton /> | ||
| 55 | </div> | ||
| 52 | </div> | 56 | </div> |
| 53 | {pillLabel ? ( | 57 | {pillLabel ? ( |
| 54 | <div className="relative z-10 ml-[var(--workspace-titlebar-content-left)] flex h-7 min-w-0 items-center"> | 58 | <div className="relative z-10 ml-[calc(var(--workspace-titlebar-content-left)+2.25rem)] max-md:ml-0 flex h-7 min-w-0 items-center"> |
| 55 | <Badge data-environment-identification="pill" size="sm" variant="secondary"> | 59 | <Badge data-environment-identification="pill" size="sm" variant="secondary"> |
| 56 | {pillLabel} | 60 | {pillLabel} |
| 57 | </Badge> | 61 | </Badge> |
apps/web/src/hooks/useNewWorkWorkspace.test.tsx created+202| ... | @@ -0,0 +1,202 @@ | ||
| 1 | import { RegistryContext } from "@effect/atom-react"; | ||
| 2 | import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import { act } from "react"; | ||
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 6 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | const state = vi.hoisted(() => ({ | ||
| 9 | create: vi.fn(), | ||
| 10 | navigate: vi.fn(), | ||
| 11 | toast: vi.fn(), | ||
| 12 | sidebarOpen: true, | ||
| 13 | onNavigated: vi.fn(() => { | ||
| 14 | state.sidebarOpen = false; | ||
| 15 | }), | ||
| 16 | router: { state: { location: { href: "/existing-chat" } } }, | ||
| 17 | target: null as object | null, | ||
| 18 | draftEnvironmentId: null as string | null, | ||
| 19 | allowed: true, | ||
| 20 | connected: true, | ||
| 21 | })); | ||
| 22 | vi.mock("@tanstack/react-router", () => ({ | ||
| 23 | useNavigate: () => state.navigate, | ||
| 24 | useRouter: () => state.router, | ||
| 25 | useParams: () => state.target, | ||
| 26 | })); | ||
| 27 | vi.mock("../state/environments", () => ({ | ||
| 28 | usePrimaryEnvironmentId: () => "local", | ||
| 29 | useEnvironment: () => ({ | ||
| 30 | connection: { phase: state.connected ? "connected" : "offline" }, | ||
| 31 | serverConfig: { managedWorkspaces: true }, | ||
| 32 | }), | ||
| 33 | })); | ||
| 34 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => state.draftEnvironmentId })); | ||
| 35 | vi.mock("../state/entities", () => ({ useProject: () => ({ id: "source" }) })); | ||
| 36 | vi.mock("../state/query", () => ({ | ||
| 37 | useEnvironmentQuery: () => ({ isSuccess: true, data: { projectId: "source" } }), | ||
| 38 | })); | ||
| 39 | vi.mock("../state/session", () => ({ useEnvironmentScope: () => true })); | ||
| 40 | vi.mock("../state/managedWorkspaces", () => ({ | ||
| 41 | managedWorkspaceStatus: () => null, | ||
| 42 | managedWorkspacePending: Atom.family(() => Atom.make(false)), | ||
| 43 | createManagedWorkspace: { permissionAtom: () => Atom.make(state.allowed) }, | ||
| 44 | })); | ||
| 45 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.create })); | ||
| 46 | vi.mock("../components/ui/toast", () => ({ toastManager: { add: state.toast } })); | ||
| 47 | |||
| 48 | import { useNewWorkWorkspace } from "./useNewWorkWorkspace"; | ||
| 49 | |||
| 50 | let renderer: ReactTestRenderer; | ||
| 51 | let registry: ReturnType<typeof AtomRegistry.make>; | ||
| 52 | let actions: ReturnType<typeof useNewWorkWorkspace>[]; | ||
| 53 | function Probe({ onNavigated }: { onNavigated?: () => void }) { | ||
| 54 | actions.push(useNewWorkWorkspace(onNavigated)); | ||
| 55 | return null; | ||
| 56 | } | ||
| 57 | const created = { | ||
| 58 | environmentId: "local", | ||
| 59 | projectId: "new-project", | ||
| 60 | threadId: "new-thread", | ||
| 61 | workspacePath: "/work/new", | ||
| 62 | }; | ||
| 63 | async function mount() { | ||
| 64 | await act(() => { | ||
| 65 | renderer = create( | ||
| 66 | <RegistryContext.Provider value={registry}> | ||
| 67 | <Probe onNavigated={state.onNavigated} /> | ||
| 68 | <Probe /> | ||
| 69 | </RegistryContext.Provider>, | ||
| 70 | ); | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | beforeEach(() => { | ||
| 74 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 75 | registry = AtomRegistry.make(); | ||
| 76 | actions = []; | ||
| 77 | state.target = null; | ||
| 78 | state.draftEnvironmentId = null; | ||
| 79 | state.allowed = true; | ||
| 80 | state.connected = true; | ||
| 81 | state.router.state.location.href = "/existing-chat"; | ||
| 82 | state.create.mockReset().mockResolvedValue(AsyncResult.success(created)); | ||
| 83 | state.navigate.mockReset(); | ||
| 84 | state.toast.mockReset(); | ||
| 85 | state.sidebarOpen = true; | ||
| 86 | state.onNavigated.mockClear(); | ||
| 87 | }); | ||
| 88 | afterEach(async () => { | ||
| 89 | await act(() => renderer?.unmount()); | ||
| 90 | registry.dispose(); | ||
| 91 | vi.unstubAllGlobals(); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("shares the in-flight guard between header and command palette, then opens the returned thread", async () => { | ||
| 95 | let finish!: (value: unknown) => void; | ||
| 96 | state.create.mockReturnValue( | ||
| 97 | new Promise((resolve) => { | ||
| 98 | finish = resolve; | ||
| 99 | }), | ||
| 100 | ); | ||
| 101 | await mount(); | ||
| 102 | let first!: Promise<void>; | ||
| 103 | await act(async () => { | ||
| 104 | first = actions[0]!.run(); | ||
| 105 | await actions[1]!.run(); | ||
| 106 | }); | ||
| 107 | expect(state.create).toHaveBeenCalledTimes(1); | ||
| 108 | await act(async () => { | ||
| 109 | finish(AsyncResult.success(created)); | ||
| 110 | await first; | ||
| 111 | }); | ||
| 112 | expect(state.navigate).toHaveBeenCalledExactlyOnceWith({ | ||
| 113 | to: "/$environmentId/$threadId", | ||
| 114 | params: { environmentId: "local", threadId: "new-thread" }, | ||
| 115 | }); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("releases the guard after failure so retry creates a workspace", async () => { | ||
| 119 | state.create.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("ws failed")))); | ||
| 120 | await mount(); | ||
| 121 | await act(() => actions[0]!.run()); | ||
| 122 | expect(state.navigate).not.toHaveBeenCalled(); | ||
| 123 | expect(state.sidebarOpen).toBe(true); | ||
| 124 | expect(state.toast).toHaveBeenCalledWith( | ||
| 125 | expect.objectContaining({ type: "error", description: "ws failed" }), | ||
| 126 | ); | ||
| 127 | await act(() => actions.at(-1)!.run()); | ||
| 128 | expect(state.create).toHaveBeenCalledTimes(2); | ||
| 129 | expect(state.navigate).toHaveBeenCalledTimes(1); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("keeps a newly selected chat open while creation finishes and offers an explicit Open action", async () => { | ||
| 133 | let finish!: (value: unknown) => void; | ||
| 134 | state.create.mockReturnValue( | ||
| 135 | new Promise((resolve) => { | ||
| 136 | finish = resolve; | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | await mount(); | ||
| 140 | let pending!: Promise<void>; | ||
| 141 | await act(() => { | ||
| 142 | pending = actions[0]!.run(); | ||
| 143 | }); | ||
| 144 | state.router.state.location.href = "/another-chat"; | ||
| 145 | await act(async () => { | ||
| 146 | finish(AsyncResult.success(created)); | ||
| 147 | await pending; | ||
| 148 | }); | ||
| 149 | expect(state.navigate).not.toHaveBeenCalled(); | ||
| 150 | expect(state.sidebarOpen).toBe(true); | ||
| 151 | const toast = state.toast.mock.calls[0]![0]; | ||
| 152 | expect(toast).toMatchObject({ type: "success", description: "/work/new" }); | ||
| 153 | await toast.actionProps.onClick(); | ||
| 154 | expect(state.navigate).toHaveBeenCalledTimes(1); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it.each(["offline", "denied"])("does not create when %s", async (unavailable) => { | ||
| 158 | state.connected = unavailable !== "offline"; | ||
| 159 | state.allowed = unavailable !== "denied"; | ||
| 160 | await mount(); | ||
| 161 | await act(() => actions[0]!.run()); | ||
| 162 | expect(state.create).not.toHaveBeenCalled(); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("creates on the draft's environment instead of the primary environment", async () => { | ||
| 166 | state.target = { kind: "draft", draftId: "remote-draft" }; | ||
| 167 | state.draftEnvironmentId = "remote"; | ||
| 168 | await mount(); | ||
| 169 | await act(() => actions[0]!.run()); | ||
| 170 | expect(state.create).toHaveBeenCalledExactlyOnceWith({ environmentId: "remote", input: {} }); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("reveals the created workspace only after its navigation succeeds", async () => { | ||
| 174 | let finishNavigation!: () => void; | ||
| 175 | state.navigate.mockReturnValue( | ||
| 176 | new Promise<void>((resolve) => { | ||
| 177 | finishNavigation = resolve; | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | await mount(); | ||
| 181 | let pending!: Promise<void>; | ||
| 182 | await act(async () => { | ||
| 183 | pending = actions[0]!.run(); | ||
| 184 | }); | ||
| 185 | expect(state.navigate).toHaveBeenCalledTimes(1); | ||
| 186 | expect(state.sidebarOpen).toBe(true); | ||
| 187 | await act(async () => { | ||
| 188 | finishNavigation(); | ||
| 189 | await pending; | ||
| 190 | }); | ||
| 191 | expect(state.sidebarOpen).toBe(false); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("keeps the mobile sidebar open if navigation to the created workspace fails", async () => { | ||
| 195 | state.navigate.mockRejectedValue(new Error("navigation failed")); | ||
| 196 | await mount(); | ||
| 197 | await act(() => actions[0]!.run()); | ||
| 198 | expect(state.sidebarOpen).toBe(true); | ||
| 199 | expect(state.toast).toHaveBeenCalledWith( | ||
| 200 | expect.objectContaining({ type: "error", description: "navigation failed" }), | ||
| 201 | ); | ||
| 202 | }); | ||
apps/web/src/hooks/useNewWorkWorkspace.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | ||
| 3 | import { | ||
| 4 | isAtomCommandInterrupted, | ||
| 5 | squashAtomCommandFailure, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import { AuthFilesystemReadScope } from "@t3tools/contracts"; | ||
| 8 | import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; | ||
| 9 | import { useCallback, useContext } from "react"; | ||
| 10 | |||
| 11 | import { toastManager } from "../components/ui/toast"; | ||
| 12 | import { resolveThreadRouteTarget } from "../threadRoutes"; | ||
| 13 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 14 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; | ||
| 15 | import { useProject } from "../state/entities"; | ||
| 16 | import { | ||
| 17 | createManagedWorkspace, | ||
| 18 | managedWorkspacePending, | ||
| 19 | managedWorkspaceStatus, | ||
| 20 | } from "../state/managedWorkspaces"; | ||
| 21 | import { useEnvironmentQuery } from "../state/query"; | ||
| 22 | import { useEnvironmentScope } from "../state/session"; | ||
| 23 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 24 | |||
| 25 | export function useNewWorkWorkspace(onNavigated?: () => void) { | ||
| 26 | const target = useParams({ strict: false, select: resolveThreadRouteTarget }); | ||
| 27 | const draftEnvironmentId = useComposerDraftStore((store) => | ||
| 28 | target?.kind === "draft" ? store.getDraftSession(target.draftId)?.environmentId : null, | ||
| 29 | ); | ||
| 30 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 31 | const environmentId = | ||
| 32 | target?.kind === "server" | ||
| 33 | ? target.threadRef.environmentId | ||
| 34 | : (draftEnvironmentId ?? primaryEnvironmentId); | ||
| 35 | const environment = useEnvironment(environmentId); | ||
| 36 | const connected = environment?.connection.phase === "connected"; | ||
| 37 | const supported = environment?.serverConfig?.managedWorkspaces === true; | ||
| 38 | const canRead = useEnvironmentScope(environmentId, AuthFilesystemReadScope); | ||
| 39 | const allowed = useAtomValue(createManagedWorkspace.permissionAtom(environmentId)); | ||
| 40 | const status = useEnvironmentQuery( | ||
| 41 | environmentId && connected && supported && canRead | ||
| 42 | ? managedWorkspaceStatus({ environmentId, input: {} }) | ||
| 43 | : null, | ||
| 44 | ); | ||
| 45 | const project = useProject( | ||
| 46 | environmentId && status.isSuccess && status.data?.projectId | ||
| 47 | ? scopeProjectRef(environmentId, status.data.projectId) | ||
| 48 | : null, | ||
| 49 | ); | ||
| 50 | const pendingAtom = managedWorkspacePending(environmentId); | ||
| 51 | const pending = useAtomValue(pendingAtom); | ||
| 52 | const registry = useContext(RegistryContext); | ||
| 53 | const create = useAtomCommand(createManagedWorkspace, { reportFailure: false }); | ||
| 54 | const navigate = useNavigate(); | ||
| 55 | const router = useRouter(); | ||
| 56 | const run = useCallback(async () => { | ||
| 57 | if (!environmentId || !connected || !allowed || !project || registry.get(pendingAtom)) return; | ||
| 58 | registry.set(pendingAtom, true); | ||
| 59 | const origin = router.state.location.href; | ||
| 60 | try { | ||
| 61 | const result = await create({ environmentId, input: {} }); | ||
| 62 | if (result._tag === "Failure") { | ||
| 63 | if (!isAtomCommandInterrupted(result)) throw squashAtomCommandFailure(result); | ||
| 64 | return; | ||
| 65 | } | ||
| 66 | const open = async () => { | ||
| 67 | await navigate({ | ||
| 68 | to: "/$environmentId/$threadId", | ||
| 69 | params: { environmentId: result.value.environmentId, threadId: result.value.threadId }, | ||
| 70 | }); | ||
| 71 | onNavigated?.(); | ||
| 72 | }; | ||
| 73 | if (router.state.location.href === origin) await open(); | ||
| 74 | else | ||
| 75 | toastManager.add({ | ||
| 76 | type: "success", | ||
| 77 | title: "Workspace for my work ready", | ||
| 78 | description: result.value.workspacePath, | ||
| 79 | actionProps: { children: "Open", onClick: () => void open() }, | ||
| 80 | }); | ||
| 81 | } catch (error) { | ||
| 82 | toastManager.add({ | ||
| 83 | type: "error", | ||
| 84 | title: "Could not create workspace", | ||
| 85 | description: | ||
| 86 | error instanceof Error ? error.message : "Try again when the environment is connected.", | ||
| 87 | }); | ||
| 88 | } finally { | ||
| 89 | registry.set(pendingAtom, false); | ||
| 90 | } | ||
| 91 | }, [ | ||
| 92 | environmentId, | ||
| 93 | connected, | ||
| 94 | allowed, | ||
| 95 | project, | ||
| 96 | registry, | ||
| 97 | pendingAtom, | ||
| 98 | router, | ||
| 99 | create, | ||
| 100 | navigate, | ||
| 101 | onNavigated, | ||
| 102 | ]); | ||
| 103 | |||
| 104 | return { | ||
| 105 | project, | ||
| 106 | pending, | ||
| 107 | label: pending ? "Creating workspace for my work…" : "New workspace for my work", | ||
| 108 | disabled: !connected || !allowed || pending, | ||
| 109 | run, | ||
| 110 | }; | ||
| 111 | } | ||
apps/web/src/hooks/useThreadActions.permissions.test.ts+110-4| ... | @@ -8,7 +8,7 @@ import { | ... | @@ -8,7 +8,7 @@ import { |
| 8 | type ScopedThreadRef, | 8 | type ScopedThreadRef, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import * as Cause from "effect/Cause"; | 10 | import * as Cause from "effect/Cause"; |
| 11 | import { AsyncResult } from "effect/reactivity"; | 11 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 12 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 12 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 13 | 13 | ||
| 14 | function deferred<T>() { | 14 | function deferred<T>() { |
| ... | @@ -19,8 +19,42 @@ function deferred<T>() { | ... | @@ -19,8 +19,42 @@ function deferred<T>() { |
| 19 | return { promise, resolve }; | 19 | return { promise, resolve }; |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | const resourceQueries = vi.hoisted(() => ({ | ||
| 23 | calls: 0, | ||
| 24 | terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, | ||
| 25 | tabs: null as unknown[] | null, | ||
| 26 | })); | ||
| 27 | vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ | ||
| 28 | ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()), | ||
| 29 | executeAtomQuery: async (_registry: unknown, atom: string) => { | ||
| 30 | resourceQueries.calls++; | ||
| 31 | const value = | ||
| 32 | atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs }; | ||
| 33 | return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) === | ||
| 34 | null | ||
| 35 | ? { _tag: "Failure", cause: {} } | ||
| 36 | : { _tag: "Success", value }; | ||
| 37 | }, | ||
| 38 | })); | ||
| 39 | vi.mock("../connection/hub", () => ({ | ||
| 40 | hubConnection: { | ||
| 41 | organizationPermissionAtom: (id: EnvironmentId) => Atom.make(state.organization.has(id)), | ||
| 42 | }, | ||
| 43 | })); | ||
| 44 | vi.mock("../connection/catalog", () => ({ | ||
| 45 | environmentCatalog: { | ||
| 46 | stateAtom: (_id: EnvironmentId) => Atom.make(AsyncResult.success({ phase: "offline" })), | ||
| 47 | catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }), | ||
| 48 | }, | ||
| 49 | })); | ||
| 50 | vi.mock("../state/terminal", () => ({ | ||
| 51 | terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, | ||
| 52 | })); | ||
| 53 | vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); | ||
| 54 | |||
| 22 | const state = vi.hoisted(() => ({ | 55 | const state = vi.hoisted(() => ({ |
| 23 | scopes: new Map<string, Set<string>>(), | 56 | scopes: new Map<string, Set<string>>(), |
| 57 | organization: new Set<string>(), | ||
| 24 | threads: [] as { | 58 | threads: [] as { |
| 25 | environmentId: EnvironmentId; | 59 | environmentId: EnvironmentId; |
| 26 | id: ThreadId; | 60 | id: ThreadId; |
| ... | @@ -47,6 +81,8 @@ vi.mock("@tanstack/react-router", () => ({ | ... | @@ -47,6 +81,8 @@ vi.mock("@tanstack/react-router", () => ({ |
| 47 | useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }), | 81 | useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }), |
| 48 | })); | 82 | })); |
| 49 | vi.mock("../state/session", () => ({ | 83 | vi.mock("../state/session", () => ({ |
| 84 | readThreadOrganizationPermission: (id: string) => | ||
| 85 | state.organization.has(id) || state.scopes.get(id)?.has("orchestration:operate") === true, | ||
| 50 | environmentSession: { sessionStateAtom: "session" }, | 86 | environmentSession: { sessionStateAtom: "session" }, |
| 51 | readEnvironmentScope: (environmentId: string, scope: string) => | 87 | readEnvironmentScope: (environmentId: string, scope: string) => |
| 52 | state.scopes.get(environmentId)?.has(scope) === true, | 88 | state.scopes.get(environmentId)?.has(scope) === true, |
| ... | @@ -58,7 +94,23 @@ vi.mock("../state/use-atom-command", () => ({ | ... | @@ -58,7 +94,23 @@ vi.mock("../state/use-atom-command", () => ({ |
| 58 | state.requests.push({ action, ...request }); | 94 | state.requests.push({ action, ...request }); |
| 59 | const scope = | 95 | const scope = |
| 60 | action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; | 96 | action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; |
| 61 | if (!state.scopes.get(request.environmentId)?.has(scope)) { | 97 | if ( |
| 98 | !state.scopes.get(request.environmentId)?.has(scope) && | ||
| 99 | !( | ||
| 100 | state.organization.has(request.environmentId) && | ||
| 101 | [ | ||
| 102 | "settle", | ||
| 103 | "unsettle", | ||
| 104 | "snooze", | ||
| 105 | "unsnooze", | ||
| 106 | "pin", | ||
| 107 | "unpin", | ||
| 108 | "reorderPin", | ||
| 109 | "reorderActive", | ||
| 110 | "setAutoSettle", | ||
| 111 | ].includes(action) | ||
| 112 | ) | ||
| 113 | ) { | ||
| 62 | return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); | 114 | return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); |
| 63 | } | 115 | } |
| 64 | state.afterRequest?.(action); | 116 | state.afterRequest?.(action); |
| ... | @@ -111,7 +163,8 @@ vi.mock("../state/entities", () => ({ | ... | @@ -111,7 +163,8 @@ vi.mock("../state/entities", () => ({ |
| 111 | .map((thread) => ({ environmentId, threadId: thread.id })), | 163 | .map((thread) => ({ environmentId, threadId: thread.id })), |
| 112 | readProject: () => ({ workspaceRoot: "/project" }), | 164 | readProject: () => ({ workspaceRoot: "/project" }), |
| 113 | })); | 165 | })); |
| 114 | vi.mock("../components/Sidebar.logic", () => ({ | 166 | vi.mock("../components/Sidebar.logic", async (original) => ({ |
| 167 | ...(await original<typeof import("../components/Sidebar.logic")>()), | ||
| 115 | getFallbackThreadIdAfterDelete: () => null, | 168 | getFallbackThreadIdAfterDelete: () => null, |
| 116 | pinOrderKeyBetween: () => "a", | 169 | pinOrderKeyBetween: () => "a", |
| 117 | })); | 170 | })); |
| ... | @@ -166,6 +219,7 @@ vi.mock("./useSettings", () => ({ | ... | @@ -166,6 +219,7 @@ vi.mock("./useSettings", () => ({ |
| 166 | })); | 219 | })); |
| 167 | 220 | ||
| 168 | import { useThreadActions } from "./useThreadActions"; | 221 | import { useThreadActions } from "./useThreadActions"; |
| 222 | import { archiveSelectedThreadEntries } from "../components/Sidebar.logic"; | ||
| 169 | 223 | ||
| 170 | const primary = EnvironmentId.make("primary"); | 224 | const primary = EnvironmentId.make("primary"); |
| 171 | const secondary = EnvironmentId.make("secondary"); | 225 | const secondary = EnvironmentId.make("secondary"); |
| ... | @@ -209,6 +263,9 @@ const operations = [ | ... | @@ -209,6 +263,9 @@ const operations = [ |
| 209 | ] as const; | 263 | ] as const; |
| 210 | 264 | ||
| 211 | beforeEach(() => { | 265 | beforeEach(() => { |
| 266 | resourceQueries.calls = 0; | ||
| 267 | resourceQueries.terminals = null; | ||
| 268 | resourceQueries.tabs = null; | ||
| 212 | state.scopes = new Map([ | 269 | state.scopes = new Map([ |
| 213 | [primary, new Set([AuthOrchestrationOperateScope])], | 270 | [primary, new Set([AuthOrchestrationOperateScope])], |
| 214 | [secondary, new Set<string>()], | 271 | [secondary, new Set<string>()], |
| ... | @@ -230,9 +287,44 @@ beforeEach(() => { | ... | @@ -230,9 +287,44 @@ beforeEach(() => { |
| 230 | state.toasts = []; | 287 | state.toasts = []; |
| 231 | state.afterRequest = undefined; | 288 | state.afterRequest = undefined; |
| 232 | state.sessionLookupFails = false; | 289 | state.sessionLookupFails = false; |
| 290 | state.organization.clear(); | ||
| 233 | }); | 291 | }); |
| 234 | 292 | ||
| 235 | describe("thread action permissions", () => { | 293 | describe("thread action permissions", () => { |
| 294 | it("stops a bulk archive when its environment loses permission between entries", async () => { | ||
| 295 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | ||
| 296 | const second = { ...target, threadId: ThreadId.make("second") }; | ||
| 297 | state.threads.push({ ...state.threads[0]!, id: second.threadId }); | ||
| 298 | state.afterRequest = (action) => { | ||
| 299 | if (action === "archive") state.scopes.get(secondary)!.clear(); | ||
| 300 | }; | ||
| 301 | const actions = useThreadActions(); | ||
| 302 | const entries = [target, second].map((ref) => ({ threadKey: ref.threadId, ref })); | ||
| 303 | const result = await archiveSelectedThreadEntries({ | ||
| 304 | entries, | ||
| 305 | archive: (entry, onArchived) => actions.archiveThread(entry.ref, { onArchived }), | ||
| 306 | }); | ||
| 307 | expect(state.requests.map((request) => request.input.threadId)).toEqual([target.threadId]); | ||
| 308 | expect(result.archivedThreadKeys).toEqual([target.threadId]); | ||
| 309 | expect(result.mutationFailure?._tag).toBe("Failure"); | ||
| 310 | }); | ||
| 311 | |||
| 312 | it("rechecks permission after a settle confirmation without dispatching", async () => { | ||
| 313 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | ||
| 314 | const confirmed = deferred<boolean>(); | ||
| 315 | state.confirm.mockReturnValue(confirmed.promise); | ||
| 316 | const pending = useThreadActions().settleThread(target); | ||
| 317 | expect(state.requests).toEqual([]); | ||
| 318 | state.scopes.get(secondary)!.clear(); | ||
| 319 | |||
| 320 | confirmed.resolve(true); | ||
| 321 | const result = await pending; | ||
| 322 | |||
| 323 | expect(result._tag).toBe("Failure"); | ||
| 324 | expect(state.requests).toEqual([]); | ||
| 325 | expect(state.localEffects).toEqual([]); | ||
| 326 | }); | ||
| 327 | |||
| 236 | it.each(operations)("$name requires the target environment's grant", async ({ run }) => { | 328 | it.each(operations)("$name requires the target environment's grant", async ({ run }) => { |
| 237 | const result = await run(useThreadActions(), target); | 329 | const result = await run(useThreadActions(), target); |
| 238 | expect(result._tag).toBe("Failure"); | 330 | expect(result._tag).toBe("Failure"); |
| ... | @@ -277,7 +369,7 @@ describe("thread action permissions", () => { | ... | @@ -277,7 +369,7 @@ describe("thread action permissions", () => { |
| 277 | const confirmation = deferred<boolean>(); | 369 | const confirmation = deferred<boolean>(); |
| 278 | state.confirm.mockReturnValue(confirmation.promise); | 370 | state.confirm.mockReturnValue(confirmation.promise); |
| 279 | const result = useThreadActions()[action](target); | 371 | const result = useThreadActions()[action](target); |
| 280 | expect(state.confirm).toHaveBeenCalledOnce(); | 372 | await vi.waitFor(() => expect(state.confirm).toHaveBeenCalledOnce()); |
| 281 | state.scopes.get(secondary)!.clear(); | 373 | state.scopes.get(secondary)!.clear(); |
| 282 | confirmation.resolve(true); | 374 | confirmation.resolve(true); |
| 283 | await result; | 375 | await result; |
| ... | @@ -353,3 +445,17 @@ describe("thread action permissions", () => { | ... | @@ -353,3 +445,17 @@ describe("thread action permissions", () => { |
| 353 | } | 445 | } |
| 354 | }); | 446 | }); |
| 355 | }); | 447 | }); |
| 448 | |||
| 449 | it("uses the central grant for organization without authorizing source execution", async () => { | ||
| 450 | resourceQueries.terminals = []; | ||
| 451 | resourceQueries.tabs = []; | ||
| 452 | state.organization.add(secondary); | ||
| 453 | const actions = useThreadActions(); | ||
| 454 | expect((await actions.settleThread(target))._tag).toBe("Success"); | ||
| 455 | expect(resourceQueries.calls).toBe(0); | ||
| 456 | expect((await actions.deleteThread(target))._tag).toBe("Failure"); | ||
| 457 | expect(state.requests.map((request) => request.action)).toEqual(["settle"]); | ||
| 458 | state.organization.clear(); | ||
| 459 | expect((await actions.unsettleThread(target))._tag).toBe("Failure"); | ||
| 460 | expect(state.requests).toHaveLength(1); | ||
| 461 | }); |
apps/web/src/hooks/useThreadActions.ts+163-26| ... | @@ -1,11 +1,21 @@ | ... | @@ -1,11 +1,21 @@ |
| 1 | import { environmentCatalog } from "../connection/catalog"; | ||
| 2 | import { hubConnection } from "../connection/hub"; | ||
| 1 | import { | 3 | import { |
| 2 | parseScopedThreadKey, | 4 | parseScopedThreadKey, |
| 3 | scopeProjectRef, | 5 | scopeProjectRef, |
| 4 | scopeThreadRef, | 6 | scopeThreadRef, |
| 5 | scopedThreadKey, | 7 | scopedThreadKey, |
| 6 | } from "@t3tools/client-runtime/environment"; | 8 | } from "@t3tools/client-runtime/environment"; |
| 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 9 | import { |
| 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 10 | executeAtomQuery, |
| 11 | settlePromise, | ||
| 12 | squashAtomCommandFailure, | ||
| 13 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 14 | import { | ||
| 15 | canSnooze, | ||
| 16 | threadWokeAt, | ||
| 17 | resolveThreadSettlementWarning, | ||
| 18 | } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 9 | import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; | 19 | import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; |
| 10 | import { | 20 | import { |
| 11 | AuthOrchestrationOperateScope, | 21 | AuthOrchestrationOperateScope, |
| ... | @@ -18,6 +28,7 @@ import { | ... | @@ -18,6 +28,7 @@ import { |
| 18 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 19 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 29 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 20 | import * as Cause from "effect/Cause"; | 30 | import * as Cause from "effect/Cause"; |
| 31 | import * as Option from "effect/Option"; | ||
| 21 | import * as Schema from "effect/Schema"; | 32 | import * as Schema from "effect/Schema"; |
| 22 | import { AsyncResult } from "effect/reactivity"; | 33 | import { AsyncResult } from "effect/reactivity"; |
| 23 | import { useRouter } from "@tanstack/react-router"; | 34 | import { useRouter } from "@tanstack/react-router"; |
| ... | @@ -25,13 +36,18 @@ import { useCallback, useMemo, useRef } from "react"; | ... | @@ -25,13 +36,18 @@ import { useCallback, useMemo, useRef } from "react"; |
| 25 | 36 | ||
| 26 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; | 37 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 27 | import { useComposerDraftStore } from "../composerDraftStore"; | 38 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 28 | import { environmentSession, readEnvironmentScope } from "../state/session"; | 39 | import { |
| 29 | import { terminalEnvironment } from "../state/terminal"; | 40 | environmentSession, |
| 41 | readEnvironmentScope, | ||
| 42 | readThreadOrganizationPermission, | ||
| 43 | } from "../state/session"; | ||
| 30 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 44 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 31 | import { environmentServerConfigsAtom } from "../state/server"; | 45 | import { environmentServerConfigsAtom } from "../state/server"; |
| 32 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 46 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 33 | import { threadEnvironment } from "../state/threads"; | 47 | import { threadEnvironment } from "../state/threads"; |
| 34 | import { vcsEnvironment } from "../state/vcs"; | 48 | import { vcsEnvironment } from "../state/vcs"; |
| 49 | import { terminalEnvironment } from "../state/terminal"; | ||
| 50 | import { previewEnvironment } from "../state/preview"; | ||
| 35 | import { useNewThreadHandler } from "./useHandleNewThread"; | 51 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 36 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | 52 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 37 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | 53 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; |
| ... | @@ -255,8 +271,11 @@ export function useAcknowledgeThreadWoke() { | ... | @@ -255,8 +271,11 @@ export function useAcknowledgeThreadWoke() { |
| 255 | ); | 271 | ); |
| 256 | } | 272 | } |
| 257 | 273 | ||
| 258 | function threadOperationFailure(target: ScopedThreadRef) { | 274 | function threadOperationFailure(target: ScopedThreadRef, organization = false) { |
| 259 | return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) | 275 | const permitted = organization |
| 276 | ? readThreadOrganizationPermission(target.environmentId) | ||
| 277 | : readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope); | ||
| 278 | return permitted | ||
| 260 | ? null | 279 | ? null |
| 261 | : AsyncResult.failure( | 280 | : AsyncResult.failure( |
| 262 | Cause.fail( | 281 | Cause.fail( |
| ... | @@ -279,30 +298,39 @@ export function useThreadActions() { | ... | @@ -279,30 +298,39 @@ export function useThreadActions() { |
| 279 | reportFailure: false, | 298 | reportFailure: false, |
| 280 | }); | 299 | }); |
| 281 | const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { | 300 | const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { |
| 301 | organization: true, | ||
| 282 | reportFailure: false, | 302 | reportFailure: false, |
| 283 | }); | 303 | }); |
| 284 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { | 304 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { |
| 305 | organization: true, | ||
| 285 | reportFailure: false, | 306 | reportFailure: false, |
| 286 | }); | 307 | }); |
| 287 | const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { | 308 | const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { |
| 309 | organization: true, | ||
| 288 | reportFailure: false, | 310 | reportFailure: false, |
| 289 | }); | 311 | }); |
| 290 | const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, { | 312 | const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, { |
| 313 | organization: true, | ||
| 291 | reportFailure: false, | 314 | reportFailure: false, |
| 292 | }); | 315 | }); |
| 293 | const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { | 316 | const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { |
| 317 | organization: true, | ||
| 294 | reportFailure: false, | 318 | reportFailure: false, |
| 295 | }); | 319 | }); |
| 296 | const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { | 320 | const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { |
| 321 | organization: true, | ||
| 297 | reportFailure: false, | 322 | reportFailure: false, |
| 298 | }); | 323 | }); |
| 299 | const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { | 324 | const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { |
| 325 | organization: true, | ||
| 300 | reportFailure: false, | 326 | reportFailure: false, |
| 301 | }); | 327 | }); |
| 302 | const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { | 328 | const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { |
| 329 | organization: true, | ||
| 303 | reportFailure: false, | 330 | reportFailure: false, |
| 304 | }); | 331 | }); |
| 305 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { | 332 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { |
| 333 | organization: true, | ||
| 306 | reportFailure: false, | 334 | reportFailure: false, |
| 307 | }); | 335 | }); |
| 308 | const markThreadUnread = useMarkThreadUnread(); | 336 | const markThreadUnread = useMarkThreadUnread(); |
| ... | @@ -374,7 +402,10 @@ export function useThreadActions() { | ... | @@ -374,7 +402,10 @@ export function useThreadActions() { |
| 374 | ); | 402 | ); |
| 375 | 403 | ||
| 376 | const archiveThread = useCallback( | 404 | const archiveThread = useCallback( |
| 377 | async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { | 405 | async ( |
| 406 | target: ScopedThreadRef, | ||
| 407 | opts: { onArchived?: () => void; snapshotSequence?: number } = {}, | ||
| 408 | ) => { | ||
| 378 | const resolved = resolveThreadTarget(target); | 409 | const resolved = resolveThreadTarget(target); |
| 379 | if (!resolved) return AsyncResult.success(undefined); | 410 | if (!resolved) return AsyncResult.success(undefined); |
| 380 | const { thread, threadRef } = resolved; | 411 | const { thread, threadRef } = resolved; |
| ... | @@ -389,14 +420,15 @@ export function useThreadActions() { | ... | @@ -389,14 +420,15 @@ export function useThreadActions() { |
| 389 | ); | 420 | ); |
| 390 | } | 421 | } |
| 391 | 422 | ||
| 392 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | ||
| 393 | const shouldNavigateToDraft = | ||
| 394 | currentRouteThreadRef?.threadId === threadRef.threadId && | ||
| 395 | currentRouteThreadRef.environmentId === threadRef.environmentId; | ||
| 396 | const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); | 423 | const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); |
| 397 | const archiveResult = await archiveThreadMutation({ | 424 | const archiveResult = await archiveThreadMutation({ |
| 398 | environmentId: threadRef.environmentId, | 425 | environmentId: threadRef.environmentId, |
| 399 | input: { threadId: threadRef.threadId }, | 426 | input: { |
| 427 | threadId: threadRef.threadId, | ||
| 428 | ...(opts.snapshotSequence === undefined | ||
| 429 | ? {} | ||
| 430 | : { snapshotSequence: opts.snapshotSequence }), | ||
| 431 | }, | ||
| 400 | }); | 432 | }); |
| 401 | if (archiveResult._tag === "Failure") { | 433 | if (archiveResult._tag === "Failure") { |
| 402 | action.finish(); | 434 | action.finish(); |
| ... | @@ -408,25 +440,27 @@ export function useThreadActions() { | ... | @@ -408,25 +440,27 @@ export function useThreadActions() { |
| 408 | } | 440 | } |
| 409 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | 441 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 410 | opts.onArchived?.(); | 442 | opts.onArchived?.(); |
| 443 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | ||
| 444 | const shouldNavigateToDraft = | ||
| 445 | currentRouteThreadRef?.threadId === threadRef.threadId && | ||
| 446 | currentRouteThreadRef.environmentId === threadRef.environmentId; | ||
| 447 | const navigationResult = shouldNavigateToDraft | ||
| 448 | ? await settlePromise(() => | ||
| 449 | handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), | ||
| 450 | ) | ||
| 451 | : null; | ||
| 411 | showThreadUndoNotice({ | 452 | showThreadUndoNotice({ |
| 412 | action: "Archived", | 453 | action: "Archived", |
| 413 | claim: action, | 454 | claim: action, |
| 414 | // Undo also brings the reader back when archiving moved them to a draft. | 455 | // Undo also brings the reader back when archiving moved them to a draft. |
| 415 | undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), | 456 | undo: () => |
| 457 | unarchiveThread(threadRef, { | ||
| 458 | navigate: navigationResult?._tag === "Success" && navigationResult.value !== null, | ||
| 459 | }), | ||
| 416 | failureTitle: "Failed to undo archive", | 460 | failureTitle: "Failed to undo archive", |
| 417 | }); | 461 | }); |
| 418 | 462 | ||
| 419 | if (shouldNavigateToDraft) { | 463 | return navigationResult?._tag === "Failure" ? navigationResult : archiveResult; |
| 420 | const navigationResult = await settlePromise(() => | ||
| 421 | handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), | ||
| 422 | ); | ||
| 423 | if (navigationResult._tag === "Failure") { | ||
| 424 | return navigationResult; | ||
| 425 | } | ||
| 426 | return archiveResult; | ||
| 427 | } | ||
| 428 | |||
| 429 | return archiveResult; | ||
| 430 | }, | 464 | }, |
| 431 | [ | 465 | [ |
| 432 | archiveThreadMutation, | 466 | archiveThreadMutation, |
| ... | @@ -767,8 +801,98 @@ export function useThreadActions() { | ... | @@ -767,8 +801,98 @@ export function useThreadActions() { |
| 767 | [pinThread, unpinThreadMutation], | 801 | [pinThread, unpinThreadMutation], |
| 768 | ); | 802 | ); |
| 769 | 803 | ||
| 804 | const confirmThreadSettlement = useCallback(async (targets: readonly ScopedThreadRef[]) => { | ||
| 805 | for (const target of targets) { | ||
| 806 | const permissionFailure = threadOperationFailure(target, true); | ||
| 807 | if (permissionFailure) return permissionFailure; | ||
| 808 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { | ||
| 809 | return AsyncResult.failure(Cause.fail(new ThreadSettlementUnsupportedError(target))); | ||
| 810 | } | ||
| 811 | } | ||
| 812 | if (targets.length === 0) return AsyncResult.success(false); | ||
| 813 | const offline = new Set( | ||
| 814 | targets.flatMap((target) => | ||
| 815 | appAtomRegistry.get(hubConnection.organizationPermissionAtom(target.environmentId)) && | ||
| 816 | Option.getOrNull( | ||
| 817 | AsyncResult.value( | ||
| 818 | appAtomRegistry.get(environmentCatalog.stateAtom(target.environmentId)), | ||
| 819 | ), | ||
| 820 | )?.phase !== "connected" | ||
| 821 | ? [target.environmentId] | ||
| 822 | : [], | ||
| 823 | ), | ||
| 824 | ); | ||
| 825 | const metadata = new Map( | ||
| 826 | await Promise.all( | ||
| 827 | [...new Set(targets.map((target) => target.environmentId))].map( | ||
| 828 | async (environmentId) => | ||
| 829 | [ | ||
| 830 | environmentId, | ||
| 831 | offline.has(environmentId) | ||
| 832 | ? null | ||
| 833 | : await executeAtomQuery( | ||
| 834 | appAtomRegistry, | ||
| 835 | terminalEnvironment.metadataSnapshot({ environmentId, input: null }), | ||
| 836 | { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) }, | ||
| 837 | ), | ||
| 838 | ] as const, | ||
| 839 | ), | ||
| 840 | ), | ||
| 841 | ); | ||
| 842 | const resources = await Promise.all( | ||
| 843 | targets.map(async (target) => { | ||
| 844 | const terminals = metadata.get(target.environmentId)!; | ||
| 845 | const previews = offline.has(target.environmentId) | ||
| 846 | ? null | ||
| 847 | : await executeAtomQuery( | ||
| 848 | appAtomRegistry, | ||
| 849 | previewEnvironment.list({ | ||
| 850 | environmentId: target.environmentId, | ||
| 851 | input: { threadId: target.threadId }, | ||
| 852 | }), | ||
| 853 | { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) }, | ||
| 854 | ); | ||
| 855 | return { | ||
| 856 | runningCommands: | ||
| 857 | terminals?._tag === "Success" | ||
| 858 | ? terminals.value.some( | ||
| 859 | (terminal) => | ||
| 860 | terminal.threadId === target.threadId && terminal.hasRunningSubprocess, | ||
| 861 | ) | ||
| 862 | : null, | ||
| 863 | browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null, | ||
| 864 | }; | ||
| 865 | }), | ||
| 866 | ); | ||
| 867 | const warning = resolveThreadSettlementWarning({ | ||
| 868 | runningCommands: resources.some((resource) => resource.runningCommands === null) | ||
| 869 | ? null | ||
| 870 | : resources.some((resource) => resource.runningCommands), | ||
| 871 | browserTabs: resources.some((resource) => resource.browserTabs === null) | ||
| 872 | ? null | ||
| 873 | : resources.some((resource) => resource.browserTabs), | ||
| 874 | }); | ||
| 875 | if (warning === null) return AsyncResult.success(true); | ||
| 876 | const title = | ||
| 877 | targets.length === 1 | ||
| 878 | ? `Settle "${readThreadShell(targets[0]!)?.title ?? "this chat"}"?` | ||
| 879 | : `Settle ${targets.length} chats?`; | ||
| 880 | const localApi = readLocalApi(); | ||
| 881 | if (!localApi) return AsyncResult.failure(Cause.interrupt()); | ||
| 882 | return settlePromise(() => | ||
| 883 | localApi.dialogs.confirm( | ||
| 884 | `${title}\n${warning}${offline.size > 0 ? " Cleanup waits until the computer reconnects." : ""}`, | ||
| 885 | { | ||
| 886 | variant: "destructive", | ||
| 887 | }, | ||
| 888 | ), | ||
| 889 | ); | ||
| 890 | }, []); | ||
| 891 | |||
| 770 | const settleThread = useCallback( | 892 | const settleThread = useCallback( |
| 771 | async (target: ScopedThreadRef) => { | 893 | async (target: ScopedThreadRef, options: { confirmed?: true } = {}) => { |
| 894 | const permissionFailure = threadOperationFailure(target, true); | ||
| 895 | if (permissionFailure) return permissionFailure; | ||
| 772 | // Version skew: never send the command to a server that predates it — | 896 | // Version skew: never send the command to a server that predates it — |
| 773 | // the raw protocol rejection would read as a random failure. | 897 | // the raw protocol rejection would read as a random failure. |
| 774 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { | 898 | if (!readEnvironmentSupportsSettlement(target.environmentId)) { |
| ... | @@ -781,6 +905,16 @@ export function useThreadActions() { | ... | @@ -781,6 +905,16 @@ export function useThreadActions() { |
| 781 | ), | 905 | ), |
| 782 | ); | 906 | ); |
| 783 | } | 907 | } |
| 908 | if (readThreadShell(target)?.settledOverride === "settled") { | ||
| 909 | return AsyncResult.success(undefined); | ||
| 910 | } | ||
| 911 | if (!options.confirmed) { | ||
| 912 | const confirmation = await confirmThreadSettlement([target]); | ||
| 913 | if (confirmation._tag === "Failure") return confirmation; | ||
| 914 | if (!confirmation.value) return AsyncResult.failure(Cause.interrupt()); | ||
| 915 | } | ||
| 916 | const currentPermissionFailure = threadOperationFailure(target, true); | ||
| 917 | if (currentPermissionFailure) return currentPermissionFailure; | ||
| 784 | const resolved = resolveThreadTarget(target); | 918 | const resolved = resolveThreadTarget(target); |
| 785 | const wokeAt = resolved | 919 | const wokeAt = resolved |
| 786 | ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) | 920 | ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) |
| ... | @@ -832,6 +966,7 @@ export function useThreadActions() { | ... | @@ -832,6 +966,7 @@ export function useThreadActions() { |
| 832 | return result; | 966 | return result; |
| 833 | }, | 967 | }, |
| 834 | [ | 968 | [ |
| 969 | confirmThreadSettlement, | ||
| 835 | markThreadVisited, | 970 | markThreadVisited, |
| 836 | pinThread, | 971 | pinThread, |
| 837 | resolveThreadTarget, | 972 | resolveThreadTarget, |
| ... | @@ -843,7 +978,7 @@ export function useThreadActions() { | ... | @@ -843,7 +978,7 @@ export function useThreadActions() { |
| 843 | 978 | ||
| 844 | const confirmAndUnpinThread = useCallback( | 979 | const confirmAndUnpinThread = useCallback( |
| 845 | async (target: ScopedThreadRef) => { | 980 | async (target: ScopedThreadRef) => { |
| 846 | const permissionFailure = threadOperationFailure(target); | 981 | const permissionFailure = threadOperationFailure(target, true); |
| 847 | if (permissionFailure) return permissionFailure; | 982 | if (permissionFailure) return permissionFailure; |
| 848 | const localApi = readLocalApi(); | 983 | const localApi = readLocalApi(); |
| 849 | const resolved = resolveThreadTarget(target); | 984 | const resolved = resolveThreadTarget(target); |
| ... | @@ -1013,6 +1148,7 @@ export function useThreadActions() { | ... | @@ -1013,6 +1148,7 @@ export function useThreadActions() { |
| 1013 | unarchiveThread, | 1148 | unarchiveThread, |
| 1014 | deleteThread, | 1149 | deleteThread, |
| 1015 | confirmAndDeleteThread, | 1150 | confirmAndDeleteThread, |
| 1151 | confirmThreadSettlement, | ||
| 1016 | settleThread, | 1152 | settleThread, |
| 1017 | unsettleThread, | 1153 | unsettleThread, |
| 1018 | snoozeThread, | 1154 | snoozeThread, |
| ... | @@ -1029,6 +1165,7 @@ export function useThreadActions() { | ... | @@ -1029,6 +1165,7 @@ export function useThreadActions() { |
| 1029 | archiveThread, | 1165 | archiveThread, |
| 1030 | confirmAndDeleteThread, | 1166 | confirmAndDeleteThread, |
| 1031 | confirmAndUnpinThread, | 1167 | confirmAndUnpinThread, |
| 1168 | confirmThreadSettlement, | ||
| 1032 | deleteThread, | 1169 | deleteThread, |
| 1033 | markThreadUnread, | 1170 | markThreadUnread, |
| 1034 | pinThread, | 1171 | pinThread, |
apps/web/src/hooks/useThreadActions.undo.test.ts+271-2| ... | @@ -1,11 +1,41 @@ | ... | @@ -1,11 +1,41 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { |
| 2 | CommandId, | ||
| 3 | EnvironmentId, | ||
| 4 | OrchestrationV2DispatchCommandError, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 8 | import { AsyncResult } from "effect/reactivity"; | ||
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 10 | ||
| 4 | import { useThreadActions } from "./useThreadActions"; | 11 | import { useThreadActions } from "./useThreadActions"; |
| 5 | import { threadEnvironment } from "../state/threads"; | 12 | import { threadEnvironment } from "../state/threads"; |
| 6 | import { toastManager } from "../components/ui/toast"; | 13 | import { toastManager } from "../components/ui/toast"; |
| 7 | import { useThreadUndoNotice } from "./showThreadUndoNotice"; | 14 | import { useThreadUndoNotice } from "./showThreadUndoNotice"; |
| 15 | import { archiveSelectedThreadEntries } from "../components/Sidebar.logic"; | ||
| 8 | 16 | ||
| 17 | const resourceQueries = vi.hoisted(() => ({ | ||
| 18 | terminals: null as { threadId: string; hasRunningSubprocess: boolean }[] | null, | ||
| 19 | tabs: null as unknown[] | null, | ||
| 20 | })); | ||
| 21 | vi.mock("@t3tools/client-runtime/state/runtime", async (original) => ({ | ||
| 22 | ...(await original<typeof import("@t3tools/client-runtime/state/runtime")>()), | ||
| 23 | executeAtomQuery: async (_registry: unknown, atom: string) => { | ||
| 24 | const value = | ||
| 25 | atom === "terminal-metadata" ? resourceQueries.terminals : { sessions: resourceQueries.tabs }; | ||
| 26 | return (atom === "terminal-metadata" ? resourceQueries.terminals : resourceQueries.tabs) === | ||
| 27 | null | ||
| 28 | ? { _tag: "Failure", cause: {} } | ||
| 29 | : { _tag: "Success", value }; | ||
| 30 | }, | ||
| 31 | })); | ||
| 32 | vi.mock("../state/terminal", () => ({ | ||
| 33 | terminalEnvironment: { metadataSnapshot: () => "terminal-metadata" }, | ||
| 34 | })); | ||
| 35 | vi.mock("../state/preview", () => ({ previewEnvironment: { list: () => "preview-list" } })); | ||
| 36 | |||
| 37 | const confirmation = vi.hoisted(() => vi.fn(async (_message: string, _options?: unknown) => true)); | ||
| 38 | vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: confirmation } }) })); | ||
| 9 | const commands = vi.hoisted(() => ({ | 39 | const commands = vi.hoisted(() => ({ |
| 10 | pin: vi.fn(), | 40 | pin: vi.fn(), |
| 11 | unpin: vi.fn(), | 41 | unpin: vi.fn(), |
| ... | @@ -20,6 +50,12 @@ const router = vi.hoisted(() => ({ | ... | @@ -20,6 +50,12 @@ const router = vi.hoisted(() => ({ |
| 20 | navigate: vi.fn(async () => {}), | 50 | navigate: vi.fn(async () => {}), |
| 21 | state: { matches: [{ params: {} as Record<string, string> }] }, | 51 | state: { matches: [{ params: {} as Record<string, string> }] }, |
| 22 | })); | 52 | })); |
| 53 | const newThread = vi.hoisted(() => | ||
| 54 | vi.fn<() => Promise<{ draftId: string; threadId: string } | null>>(async () => ({ | ||
| 55 | draftId: "draft", | ||
| 56 | threadId: "draft-thread", | ||
| 57 | })), | ||
| 58 | ); | ||
| 23 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); | 59 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); |
| 24 | vi.mock("../state/session", async (original) => ({ | 60 | vi.mock("../state/session", async (original) => ({ |
| 25 | ...(await original<typeof import("../state/session")>()), | 61 | ...(await original<typeof import("../state/session")>()), |
| ... | @@ -33,7 +69,7 @@ vi.mock("react", async (original) => ({ | ... | @@ -33,7 +69,7 @@ vi.mock("react", async (original) => ({ |
| 33 | })); | 69 | })); |
| 34 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); | 70 | vi.mock("@tanstack/react-router", () => ({ useRouter: () => router })); |
| 35 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); | 71 | vi.mock("./useSettings", () => ({ useClientSettings: () => false })); |
| 36 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); | 72 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => newThread })); |
| 37 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); | 73 | vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => vi.fn() })); |
| 38 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); | 74 | vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); |
| 39 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); | 75 | vi.mock("../uiStateStore", () => ({ useUiStateStore: () => vi.fn() })); |
| ... | @@ -43,6 +79,7 @@ const threadShell = vi.hoisted(() => ({ | ... | @@ -43,6 +79,7 @@ const threadShell = vi.hoisted(() => ({ |
| 43 | pinOrderKey: "a0", | 79 | pinOrderKey: "a0", |
| 44 | pinnedAt: null as string | null, | 80 | pinnedAt: null as string | null, |
| 45 | snoozedUntil: null as string | null, | 81 | snoozedUntil: null as string | null, |
| 82 | settledOverride: null as "settled" | null, | ||
| 46 | projectId: "project", | 83 | projectId: "project", |
| 47 | environmentId: "undo-env", | 84 | environmentId: "undo-env", |
| 48 | session: null, | 85 | session: null, |
| ... | @@ -91,14 +128,19 @@ function currentUndo() { | ... | @@ -91,14 +128,19 @@ function currentUndo() { |
| 91 | } | 128 | } |
| 92 | 129 | ||
| 93 | beforeEach(() => { | 130 | beforeEach(() => { |
| 131 | resourceQueries.terminals = null; | ||
| 132 | resourceQueries.tabs = null; | ||
| 94 | vi.useFakeTimers(); | 133 | vi.useFakeTimers(); |
| 134 | confirmation.mockReset().mockResolvedValue(true); | ||
| 95 | for (const command of Object.values(commands)) { | 135 | for (const command of Object.values(commands)) { |
| 96 | command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | 136 | command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); |
| 97 | } | 137 | } |
| 98 | router.navigate.mockClear(); | 138 | router.navigate.mockClear(); |
| 139 | newThread.mockReset().mockResolvedValue({ draftId: "draft", threadId: "draft-thread" }); | ||
| 99 | router.state.matches[0]!.params = {}; | 140 | router.state.matches[0]!.params = {}; |
| 100 | threadShell.pinnedAt = null; | 141 | threadShell.pinnedAt = null; |
| 101 | threadShell.snoozedUntil = null; | 142 | threadShell.snoozedUntil = null; |
| 143 | threadShell.settledOverride = null; | ||
| 102 | }); | 144 | }); |
| 103 | afterEach(() => { | 145 | afterEach(() => { |
| 104 | vi.runAllTimers(); | 146 | vi.runAllTimers(); |
| ... | @@ -129,6 +171,134 @@ describe("unpin Undo", () => { | ... | @@ -129,6 +171,134 @@ describe("unpin Undo", () => { |
| 129 | }); | 171 | }); |
| 130 | 172 | ||
| 131 | describe("archive Undo", () => { | 173 | describe("archive Undo", () => { |
| 174 | it("keeps a rejected current thread in place and groups Undo only for successful candidates", async () => { | ||
| 175 | const entries = ["first", target.threadId, "last"].map((id) => ({ | ||
| 176 | threadRef: { ...target, threadId: ThreadId.make(id) }, | ||
| 177 | threadKey: `${target.environmentId}:${id}`, | ||
| 178 | })); | ||
| 179 | const changed = AsyncResult.failure( | ||
| 180 | Cause.fail( | ||
| 181 | new OrchestrationV2DispatchCommandError({ | ||
| 182 | commandId: CommandId.make("archive-condition-rejected"), | ||
| 183 | commandType: "thread.archive", | ||
| 184 | reason: "archive-condition-changed", | ||
| 185 | message: "The thread changed before automatic archival.", | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined)); | ||
| 190 | commands.archive.mockResolvedValueOnce(changed); | ||
| 191 | commands.archive.mockResolvedValueOnce(AsyncResult.success(undefined)); | ||
| 192 | router.state.matches[0]!.params = { | ||
| 193 | environmentId: target.environmentId, | ||
| 194 | threadId: target.threadId, | ||
| 195 | }; | ||
| 196 | const { archiveThread } = useThreadActions(); | ||
| 197 | const outcome = await archiveSelectedThreadEntries({ | ||
| 198 | entries, | ||
| 199 | archive: ({ threadRef }, onArchived) => | ||
| 200 | archiveThread(threadRef, { onArchived, snapshotSequence: 7 }), | ||
| 201 | }); | ||
| 202 | |||
| 203 | expect(outcome.archivedThreadKeys).toEqual([entries[0]!.threadKey, entries[2]!.threadKey]); | ||
| 204 | expect(newThread).not.toHaveBeenCalled(); | ||
| 205 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 206 | expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 }); | ||
| 207 | |||
| 208 | await currentUndo()(); | ||
| 209 | |||
| 210 | expect(commands.unarchive.mock.calls.map(([request]) => request.input.threadId)).toEqual([ | ||
| 211 | "last", | ||
| 212 | "first", | ||
| 213 | ]); | ||
| 214 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("preserves navigation and Undo stays put when the reader leaves during archive", async () => { | ||
| 218 | let completeArchive!: () => void; | ||
| 219 | const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { | ||
| 220 | completeArchive = () => resolve({ _tag: "Success", value: undefined }); | ||
| 221 | }); | ||
| 222 | commands.archive.mockReturnValueOnce(pending); | ||
| 223 | router.state.matches[0]!.params = { | ||
| 224 | environmentId: target.environmentId, | ||
| 225 | threadId: target.threadId, | ||
| 226 | }; | ||
| 227 | const archiving = useThreadActions().archiveThread(target); | ||
| 228 | expect(commands.archive).toHaveBeenCalledOnce(); | ||
| 229 | router.state.matches[0]!.params = { | ||
| 230 | environmentId: target.environmentId, | ||
| 231 | threadId: "another-thread", | ||
| 232 | }; | ||
| 233 | completeArchive(); | ||
| 234 | await archiving; | ||
| 235 | |||
| 236 | expect(newThread).not.toHaveBeenCalled(); | ||
| 237 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 238 | await currentUndo()(); | ||
| 239 | expect(commands.unarchive).toHaveBeenCalledOnce(); | ||
| 240 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 241 | expect(router.state.matches[0]!.params.threadId).toBe("another-thread"); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("keeps archive Undo without return navigation when leaving the archived thread failed", async () => { | ||
| 245 | router.state.matches[0]!.params = { | ||
| 246 | environmentId: target.environmentId, | ||
| 247 | threadId: target.threadId, | ||
| 248 | }; | ||
| 249 | newThread.mockRejectedValueOnce(new Error("Navigation failed")); | ||
| 250 | const result = await useThreadActions().archiveThread(target); | ||
| 251 | |||
| 252 | expect(result._tag).toBe("Failure"); | ||
| 253 | expect(newThread).toHaveBeenCalledOnce(); | ||
| 254 | expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 1 }); | ||
| 255 | await currentUndo()(); | ||
| 256 | expect(commands.unarchive).toHaveBeenCalledOnce(); | ||
| 257 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 258 | }); | ||
| 259 | |||
| 260 | it("does not return on Undo when draft creation declined navigation", async () => { | ||
| 261 | router.state.matches[0]!.params = { | ||
| 262 | environmentId: target.environmentId, | ||
| 263 | threadId: target.threadId, | ||
| 264 | }; | ||
| 265 | newThread.mockResolvedValueOnce(null); | ||
| 266 | const result = await useThreadActions().archiveThread(target); | ||
| 267 | |||
| 268 | expect(result._tag).toBe("Success"); | ||
| 269 | expect(newThread).toHaveBeenCalledOnce(); | ||
| 270 | await currentUndo()(); | ||
| 271 | expect(commands.unarchive).toHaveBeenCalledOnce(); | ||
| 272 | expect(router.navigate).not.toHaveBeenCalled(); | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("preserves each archive snapshot guard and restores a bulk notice together", async () => { | ||
| 276 | const actions = useThreadActions(); | ||
| 277 | const second = { ...target, threadId: ThreadId.make("second-thread") }; | ||
| 278 | await actions.archiveThread(target, { snapshotSequence: 17 }); | ||
| 279 | await actions.archiveThread(second, { snapshotSequence: 42 }); | ||
| 280 | |||
| 281 | expect(commands.archive).toHaveBeenNthCalledWith(1, { | ||
| 282 | environmentId: target.environmentId, | ||
| 283 | input: { threadId: target.threadId, snapshotSequence: 17 }, | ||
| 284 | }); | ||
| 285 | expect(commands.archive).toHaveBeenNthCalledWith(2, { | ||
| 286 | environmentId: second.environmentId, | ||
| 287 | input: { threadId: second.threadId, snapshotSequence: 42 }, | ||
| 288 | }); | ||
| 289 | expect(useThreadUndoNotice.getState().notice).toMatchObject({ action: "Archived", count: 2 }); | ||
| 290 | await currentUndo()(); | ||
| 291 | expect(commands.unarchive).toHaveBeenCalledTimes(2); | ||
| 292 | expect(commands.unarchive).toHaveBeenCalledWith({ | ||
| 293 | environmentId: target.environmentId, | ||
| 294 | input: { threadId: target.threadId }, | ||
| 295 | }); | ||
| 296 | expect(commands.unarchive).toHaveBeenCalledWith({ | ||
| 297 | environmentId: second.environmentId, | ||
| 298 | input: { threadId: second.threadId }, | ||
| 299 | }); | ||
| 300 | }); | ||
| 301 | |||
| 132 | it("unarchives and returns to the thread when archiving left it", async () => { | 302 | it("unarchives and returns to the thread when archiving left it", async () => { |
| 133 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | 303 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); |
| 134 | router.state.matches[0]!.params = { | 304 | router.state.matches[0]!.params = { |
| ... | @@ -169,6 +339,105 @@ describe("archive Undo", () => { | ... | @@ -169,6 +339,105 @@ describe("archive Undo", () => { |
| 169 | }); | 339 | }); |
| 170 | 340 | ||
| 171 | describe("settle and snooze Undo", () => { | 341 | describe("settle and snooze Undo", () => { |
| 342 | it.each([ | ||
| 343 | { terminals: [] }, | ||
| 344 | { terminals: [{ threadId: "thread", hasRunningSubprocess: false }] }, | ||
| 345 | ])("settles empty or shell-only terminals without confirmation", async ({ terminals }) => { | ||
| 346 | resourceQueries.terminals = terminals; | ||
| 347 | resourceQueries.tabs = []; | ||
| 348 | await useThreadActions().settleThread(target); | ||
| 349 | expect(confirmation).not.toHaveBeenCalled(); | ||
| 350 | expect(commands.settle).toHaveBeenCalledOnce(); | ||
| 351 | }); | ||
| 352 | |||
| 353 | it.each([ | ||
| 354 | { | ||
| 355 | terminals: [{ threadId: "thread", hasRunningSubprocess: true }], | ||
| 356 | tabs: [], | ||
| 357 | text: "Undo cannot restart them.", | ||
| 358 | }, | ||
| 359 | { terminals: [], tabs: [{}], text: "Undo cannot restore page state." }, | ||
| 360 | { terminals: null, tabs: [], text: "could not be checked" }, | ||
| 361 | ])("warns accurately before settlement: $text", async ({ terminals, tabs, text }) => { | ||
| 362 | resourceQueries.terminals = terminals; | ||
| 363 | resourceQueries.tabs = tabs; | ||
| 364 | confirmation.mockResolvedValue(false); | ||
| 365 | await useThreadActions().settleThread(target); | ||
| 366 | expect(confirmation).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(text), { | ||
| 367 | variant: "destructive", | ||
| 368 | }); | ||
| 369 | expect(commands.settle).not.toHaveBeenCalled(); | ||
| 370 | }); | ||
| 371 | |||
| 372 | it("ignores commands belonging to a different chat", async () => { | ||
| 373 | resourceQueries.terminals = [{ threadId: "other-thread", hasRunningSubprocess: true }]; | ||
| 374 | resourceQueries.tabs = []; | ||
| 375 | await useThreadActions().settleThread(target); | ||
| 376 | expect(confirmation).not.toHaveBeenCalled(); | ||
| 377 | expect(commands.settle).toHaveBeenCalledOnce(); | ||
| 378 | }); | ||
| 379 | |||
| 380 | it("cancels without changing the chat or invalidating its existing Undo", async () => { | ||
| 381 | const actions = useThreadActions(); | ||
| 382 | await actions.unpinThread(target); | ||
| 383 | const undo = currentUndo(); | ||
| 384 | confirmation.mockResolvedValue(false); | ||
| 385 | |||
| 386 | const result = await actions.settleThread(target); | ||
| 387 | |||
| 388 | expect(result._tag).toBe("Failure"); | ||
| 389 | expect(commands.settle).not.toHaveBeenCalled(); | ||
| 390 | await undo(); | ||
| 391 | expect(commands.pin).toHaveBeenCalledOnce(); | ||
| 392 | expect(confirmation).toHaveBeenCalledExactlyOnceWith( | ||
| 393 | expect.stringContaining("Undo cannot restore commands or page state"), | ||
| 394 | { variant: "destructive" }, | ||
| 395 | ); | ||
| 396 | }); | ||
| 397 | |||
| 398 | it("waits for confirmation before dispatching settlement", async () => { | ||
| 399 | let confirm!: (allowed: boolean) => void; | ||
| 400 | confirmation.mockImplementation( | ||
| 401 | () => | ||
| 402 | new Promise((resolve) => { | ||
| 403 | confirm = resolve; | ||
| 404 | }), | ||
| 405 | ); | ||
| 406 | const pending = useThreadActions().settleThread(target); | ||
| 407 | expect(commands.settle).not.toHaveBeenCalled(); | ||
| 408 | |||
| 409 | await vi.waitFor(() => expect(confirmation).toHaveBeenCalledOnce()); | ||
| 410 | confirm(true); | ||
| 411 | await pending; | ||
| 412 | |||
| 413 | expect(commands.settle).toHaveBeenCalledOnce(); | ||
| 414 | }); | ||
| 415 | |||
| 416 | it("does not warn or settle again when the chat is already settled", async () => { | ||
| 417 | threadShell.settledOverride = "settled"; | ||
| 418 | |||
| 419 | await useThreadActions().settleThread(target); | ||
| 420 | |||
| 421 | expect(confirmation).not.toHaveBeenCalled(); | ||
| 422 | expect(commands.settle).not.toHaveBeenCalled(); | ||
| 423 | }); | ||
| 424 | |||
| 425 | it("warns once before a confirmed batch and never again during Undo", async () => { | ||
| 426 | const actions = useThreadActions(); | ||
| 427 | const second = { ...target, threadId: ThreadId.make("second-thread") }; | ||
| 428 | const result = await actions.confirmThreadSettlement([target, second]); | ||
| 429 | expect(result).toMatchObject({ _tag: "Success", value: true }); | ||
| 430 | expect(commands.settle).not.toHaveBeenCalled(); | ||
| 431 | |||
| 432 | await actions.settleThread(target, { confirmed: true }); | ||
| 433 | await actions.settleThread(second, { confirmed: true }); | ||
| 434 | await currentUndo()(); | ||
| 435 | |||
| 436 | expect(confirmation).toHaveBeenCalledOnce(); | ||
| 437 | expect(confirmation.mock.calls[0]?.[0]).toContain("Settle 2 chats?"); | ||
| 438 | expect(commands.settle).toHaveBeenCalledTimes(2); | ||
| 439 | }); | ||
| 440 | |||
| 172 | it("un-settles from the notice and expires the Undo after a manual un-settle", async () => { | 441 | it("un-settles from the notice and expires the Undo after a manual un-settle", async () => { |
| 173 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); | 442 | const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); |
| 174 | const actions = useThreadActions(); | 443 | const actions = useThreadActions(); |
apps/web/src/state/managedWorkspaces.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import { | ||
| 2 | createEnvironmentRpcCommand, | ||
| 3 | createEnvironmentRpcQueryAtomFamily, | ||
| 4 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 5 | import { WS_METHODS, type EnvironmentId } from "@t3tools/contracts"; | ||
| 6 | import { Atom } from "effect/reactivity"; | ||
| 7 | |||
| 8 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 9 | |||
| 10 | export const managedWorkspaceStatus = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { | ||
| 11 | label: "environment-data:managed-workspaces:status", | ||
| 12 | tag: WS_METHODS.managedWorkspacesStatus, | ||
| 13 | staleTimeMs: 30_000, | ||
| 14 | idleTtlMs: 60_000, | ||
| 15 | }); | ||
| 16 | |||
| 17 | export const createManagedWorkspace = createEnvironmentRpcCommand(connectionAtomRuntime, { | ||
| 18 | label: "environment-data:managed-workspaces:create", | ||
| 19 | tag: WS_METHODS.managedWorkspacesCreate, | ||
| 20 | }); | ||
| 21 | |||
| 22 | export const managedWorkspacePending = Atom.family< | ||
| 23 | EnvironmentId | null, | ||
| 24 | Atom.Writable<boolean, boolean> | ||
| 25 | >(() => Atom.make(false)); | ||
docs/user/thread-sidebar.md+34-9| ... | @@ -130,17 +130,39 @@ Choose **Settle thread** from its menu to move finished work out of the active l | ... | @@ -130,17 +130,39 @@ Choose **Settle thread** from its menu to move finished work out of the active l |
| 130 | without deleting the conversation. **Un-settle thread** restores it to active work | 130 | without deleting the conversation. **Un-settle thread** restores it to active work |
| 131 | and prevents automatic settlement until new activity resumes the usual rules. | 131 | and prevents automatic settlement until new activity resumes the usual rules. |
| 132 | Manually settling an idle thread dismisses unanswered async questions without | 132 | Manually settling an idle thread dismisses unanswered async questions without |
| 133 | sending an answer or restarting the agent. Settling also closes the thread's | 133 | sending an answer or restarting the agent. Manual settlement closes all of the |
| 134 | terminals that wait at an idle prompt, and keeps their output. A terminal that | 134 | thread's terminals and browser tabs, including running commands and playing media. |
| 135 | runs a command, such as a dev server, stays open. | 135 | Running programs or open browser tabs ask for confirmation; empty chats and idle |
| 136 | shells settle directly. Terminal output stays in the conversation. Undo or | ||
| 137 | unsettling restores the chat, but cannot restart those commands or restore its tabs. | ||
| 138 | Other chats keep their own terminals and tabs, including in a shared workspace. | ||
| 139 | The workspace's detached dev server stops once no | ||
| 140 | active chat or pending work needs it; shared workspaces keep their server while | ||
| 141 | another chat is active. | ||
| 136 | 142 | ||
| 137 | To reclaim disk space from settled work, turn on **Run in the thread's worktree when the | 143 | To reclaim disk space from settled work, turn on **Run in the thread's worktree when the |
| 138 | thread settles** for one of the project's actions, or set `"runOnSettle": true` on a | 144 | thread settles** for one of the project's actions, or set `"runOnSettle": true` on a |
| 139 | `t3.json` script, for example `cargo clean`. It runs each time a thread in its own | 145 | `t3.json` script, for example `cargo clean`. It runs each time a thread in its own |
| 140 | worktree settles, manually or automatically, even if a terminal there still runs a | 146 | worktree settles, manually or automatically. Threads in the project's main checkout skip it. Its terminal |
| 141 | command such as a dev server. Threads in the project's main checkout skip it. Its terminal | ||
| 142 | closes when the command succeeds and stays open when it fails. | 147 | closes when the command succeeds and stays open when it fails. |
| 143 | 148 | ||
| 149 | Settled threads archive on Saturday at 9:00 AM in the server's local time. If the | ||
| 150 | server was offline, it catches up when it starts. Threads with running work or | ||
| 151 | pending requests wait until that work finishes. | ||
| 152 | |||
| 153 | On web and desktop, right-click **Settled** and choose **Archive all** to archive | ||
| 154 | the settled chats in the current project filter, including hidden chats. To archive | ||
| 155 | a selection, choose **Archive** from its context menu. Undo restores each archived | ||
| 156 | group together. Chats that resume work during Archive all stay available. | ||
| 157 | |||
| 158 | Workspaces for my work stay available while their chats are settled; settlement | ||
| 159 | does not run a cleanup script there. A settled work chat archives once all its | ||
| 160 | linked pull requests merge, including when you settle it after the merge. | ||
| 161 | Workspace cleanup waits for archival and for other chats or external tools | ||
| 162 | to release the folder. Mergebot supplies live PR and check updates. A new CI failure returns the chat to Active and adds a | ||
| 163 | Mergebot link to the transcript without starting an agent. Settling it again | ||
| 164 | acknowledges that failure; a failed rerun can wake it again. | ||
| 165 | |||
| 144 | On web and desktop, press a thread's **Settle** button and drag up or down to | 166 | On web and desktop, press a thread's **Settle** button and drag up or down to |
| 145 | settle every thread in that section between it and the one you release on. | 167 | settle every thread in that section between it and the one you release on. |
| 146 | The **Un-settle** and **Wake** buttons work the same way in their sections. | 168 | The **Un-settle** and **Wake** buttons work the same way in their sections. |
| ... | @@ -148,12 +170,15 @@ Press `Escape` while dragging to cancel. | ... | @@ -148,12 +170,15 @@ Press `Escape` while dragging to cancel. |
| 148 | 170 | ||
| 149 | By default, environments settle inactive threads after three days and settle | 171 | By default, environments settle inactive threads after three days and settle |
| 150 | threads whose pull request merged. A closed pull request can also settle an idle | 172 | threads whose pull request merged. A closed pull request can also settle an idle |
| 151 | thread. Work in progress, pending questions or approvals, and live background work | 173 | thread. Work in progress, pending questions or approvals, live background work, |
| 152 | prevent automatic settlement. An open pull request does not prevent inactivity | 174 | and open terminals or browser tabs prevent automatic settlement. An open pull |
| 153 | settlement unless you turn on **Keep threads with a PR** in **Settings → General**, | 175 | request does not prevent inactivity settlement unless you turn on **Keep threads |
| 154 | but an old closed or merged pull request does not settle work you | 176 | with a PR** in **Settings → General**, but an old closed or merged pull request |
| 177 | does not settle work you | ||
| 155 | resumed after it closed. Only your own messages count as resuming. A turn that | 178 | resumed after it closed. Only your own messages count as resuming. A turn that |
| 156 | finished background work or a pull request watch starts on its own does not. | 179 | finished background work or a pull request watch starts on its own does not. |
| 180 | Disconnected or unverified Mergebot data cannot trigger merge archival, PR-based | ||
| 181 | settlement, or a CI wakeup. Scheduled archival and inactivity rules still apply. | ||
| 157 | 182 | ||
| 158 | To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, | 183 | To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, |
| 159 | choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick | 184 | choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick |
packages/client-runtime/src/operations/commands.test.ts+20| ... | @@ -234,6 +234,26 @@ describe("V2 environment commands", () => { | ... | @@ -234,6 +234,26 @@ describe("V2 environment commands", () => { |
| 234 | }).pipe(Effect.provide(layerTestCrypto)), | 234 | }).pipe(Effect.provide(layerTestCrypto)), |
| 235 | ); | 235 | ); |
| 236 | 236 | ||
| 237 | it.effect("preserves the settled shelf snapshot guard when archiving", () => | ||
| 238 | Effect.gen(function* () { | ||
| 239 | const commands: OrchestrationV2Command[] = []; | ||
| 240 | const supervisor = yield* makeSupervisor({ commands, projects: [] }); | ||
| 241 | yield* archiveThread({ | ||
| 242 | commandId: CommandId.make("guarded-archive"), | ||
| 243 | threadId: ThreadId.make("settled-thread"), | ||
| 244 | snapshotSequence: 137, | ||
| 245 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | ||
| 246 | expect(commands).toEqual([ | ||
| 247 | { | ||
| 248 | type: "thread.archive", | ||
| 249 | commandId: "guarded-archive", | ||
| 250 | threadId: "settled-thread", | ||
| 251 | snapshotSequence: 137, | ||
| 252 | }, | ||
| 253 | ]); | ||
| 254 | }).pipe(Effect.provide(layerTestCrypto)), | ||
| 255 | ); | ||
| 256 | |||
| 237 | it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () => | 257 | it.effect("resolves run ordinal zero to the persisted thread-start checkpoint", () => |
| 238 | Effect.gen(function* () { | 258 | Effect.gen(function* () { |
| 239 | const scopeId = CheckpointScopeId.make("checkpoint-scope-root"); | 259 | const scopeId = CheckpointScopeId.make("checkpoint-scope-root"); |
packages/client-runtime/src/operations/commands.ts+9-2| ... | @@ -90,7 +90,9 @@ export interface ThreadCommandInput extends CommandMetadata { | ... | @@ -90,7 +90,9 @@ export interface ThreadCommandInput extends CommandMetadata { |
| 90 | } | 90 | } |
| 91 | 91 | ||
| 92 | export type DeleteThreadInput = ThreadCommandInput; | 92 | export type DeleteThreadInput = ThreadCommandInput; |
| 93 | export type ArchiveThreadInput = ThreadCommandInput; | 93 | export interface ArchiveThreadInput extends ThreadCommandInput { |
| 94 | readonly snapshotSequence?: number; | ||
| 95 | } | ||
| 94 | export type UnarchiveThreadInput = ThreadCommandInput; | 96 | export type UnarchiveThreadInput = ThreadCommandInput; |
| 95 | export type SettleThreadInput = ThreadCommandInput; | 97 | export type SettleThreadInput = ThreadCommandInput; |
| 96 | 98 | ||
| ... | @@ -458,7 +460,12 @@ export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(functi | ... | @@ -458,7 +460,12 @@ export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(functi |
| 458 | export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* ( | 460 | export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* ( |
| 459 | input: ArchiveThreadInput, | 461 | input: ArchiveThreadInput, |
| 460 | ) { | 462 | ) { |
| 461 | return yield* simpleThreadCommand("thread.archive", input); | 463 | return yield* dispatch({ |
| 464 | type: "thread.archive", | ||
| 465 | commandId: yield* allocateCommandId(input), | ||
| 466 | threadId: input.threadId, | ||
| 467 | ...(input.snapshotSequence === undefined ? {} : { snapshotSequence: input.snapshotSequence }), | ||
| 468 | }); | ||
| 462 | }); | 469 | }); |
| 463 | 470 | ||
| 464 | export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* ( | 471 | export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* ( |
packages/client-runtime/src/rpc/client.test.ts+28| ... | @@ -33,6 +33,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | ... | @@ -33,6 +33,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 33 | import { | 33 | import { |
| 34 | EnvironmentRpcRequestObserver, | 34 | EnvironmentRpcRequestObserver, |
| 35 | request, | 35 | request, |
| 36 | requestGuarded, | ||
| 36 | runStream, | 37 | runStream, |
| 37 | subscribe, | 38 | subscribe, |
| 38 | subscribeDynamicWithSession, | 39 | subscribeDynamicWithSession, |
| ... | @@ -89,6 +90,33 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { | ... | @@ -89,6 +90,33 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { |
| 89 | }); | 90 | }); |
| 90 | 91 | ||
| 91 | describe("environment RPC", () => { | 92 | describe("environment RPC", () => { |
| 93 | it.effect("reads managed workspace status without a command boundary and denies creation", () => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | let creates = 0; | ||
| 96 | const client = { | ||
| 97 | [WS_METHODS.managedWorkspacesStatus]: () => Effect.succeed({ projectId: null }), | ||
| 98 | [WS_METHODS.managedWorkspacesCreate]: () => { | ||
| 99 | creates += 1; | ||
| 100 | return Effect.die("Protected workspace creation reached the transport"); | ||
| 101 | }, | ||
| 102 | } as unknown as WsRpcProtocolClient; | ||
| 103 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 104 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | ||
| 105 | |||
| 106 | const status = yield* request(WS_METHODS.managedWorkspacesStatus, {}).pipe( | ||
| 107 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 108 | ); | ||
| 109 | expect(status).toEqual({ projectId: null }); | ||
| 110 | |||
| 111 | const create = yield* requestGuarded(WS_METHODS.managedWorkspacesCreate, {}).pipe( | ||
| 112 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 113 | Effect.flip, | ||
| 114 | ); | ||
| 115 | expect(create._tag).toBe("EnvironmentAuthorizationError"); | ||
| 116 | expect(creates).toBe(0); | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | |||
| 92 | it.effect("reuses the session config stream instead of opening a duplicate subscription", () => | 120 | it.effect("reuses the session config stream instead of opening a duplicate subscription", () => |
| 93 | Effect.gen(function* () { | 121 | Effect.gen(function* () { |
| 94 | const event: ServerConfigStreamEvent = { | 122 | const event: ServerConfigStreamEvent = { |
packages/client-runtime/src/state/commandPermissions.test.ts+18| ... | @@ -86,6 +86,24 @@ describe("command permissions", () => { | ... | @@ -86,6 +86,24 @@ describe("command permissions", () => { |
| 86 | ), | 86 | ), |
| 87 | ); | 87 | ); |
| 88 | 88 | ||
| 89 | it.effect("checks destination grants before creating a managed workspace", () => | ||
| 90 | Effect.scoped( | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const registry = yield* setup; | ||
| 93 | const create = createCommandPermissions(runtime, WS_METHODS.managedWorkspacesCreate); | ||
| 94 | registry.set(sessions(env), AsyncResult.success(grant(false))); | ||
| 95 | expect(registry.get(create.permissionAtom(env))).toBe(false); | ||
| 96 | expect((yield* create.authorize(registry, env).pipe(Effect.flip))._tag).toBe( | ||
| 97 | "EnvironmentAuthorizationError", | ||
| 98 | ); | ||
| 99 | registry.set(sessions(env), AsyncResult.success(grant(true))); | ||
| 100 | yield* create.authorize(registry, env); | ||
| 101 | registry.set(sessions(other), AsyncResult.success(grant(false))); | ||
| 102 | expect(registry.get(create.permissionAtom(other))).toBe(false); | ||
| 103 | }), | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | |||
| 89 | it.effect("requires destination read grants for dev status and terminal authority to start", () => | 107 | it.effect("requires destination read grants for dev status and terminal authority to start", () => |
| 90 | Effect.scoped( | 108 | Effect.scoped( |
| 91 | Effect.gen(function* () { | 109 | Effect.gen(function* () { |
packages/client-runtime/src/state/terminal.test.ts created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId, type TerminalMetadataStreamEvent, WS_METHODS } from "@t3tools/contracts"; | ||
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | ||
| 9 | import { vi } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 12 | import { createTerminalEnvironmentAtoms } from "./terminal.ts"; | ||
| 13 | import { executeAtomQuery } from "./runtime.ts"; | ||
| 14 | |||
| 15 | const source = vi.hoisted(() => ({ stream: undefined as unknown })); | ||
| 16 | vi.mock("../rpc/client.ts", async (original) => ({ | ||
| 17 | ...(await original<typeof import("../rpc/client.ts")>()), | ||
| 18 | subscribe: (tag: string) => { | ||
| 19 | expect(tag).toBe(WS_METHODS.subscribeTerminalMetadata); | ||
| 20 | return source.stream; | ||
| 21 | }, | ||
| 22 | })); | ||
| 23 | |||
| 24 | it.effect("waits for the server metadata snapshot before reporting an empty terminal list", () => | ||
| 25 | Effect.scoped( | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const subscribed = yield* Deferred.make<void>(); | ||
| 28 | const snapshot = yield* Deferred.make<TerminalMetadataStreamEvent>(); | ||
| 29 | source.stream = Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( | ||
| 30 | Stream.flatMap(() => Stream.fromEffect(Deferred.await(snapshot))), | ||
| 31 | ); | ||
| 32 | const runtime = Atom.runtime( | ||
| 33 | Layer.succeed(EnvironmentRegistry, { | ||
| 34 | followStream: (_id, stream) => Stream.concat(stream, Stream.never), | ||
| 35 | } as EnvironmentRegistry["Service"]), | ||
| 36 | ); | ||
| 37 | const terminals = createTerminalEnvironmentAtoms(runtime); | ||
| 38 | const registry = AtomRegistry.make(); | ||
| 39 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 40 | const atom = terminals.metadata({ | ||
| 41 | environmentId: EnvironmentId.make("metadata-proof"), | ||
| 42 | input: null, | ||
| 43 | }); | ||
| 44 | const read = yield* Effect.forkChild(AtomRegistry.getResult(registry, atom)); | ||
| 45 | yield* Deferred.await(subscribed); | ||
| 46 | expect(AsyncResult.isSuccess(registry.get(atom))).toBe(false); | ||
| 47 | yield* Deferred.succeed(snapshot, { type: "snapshot", terminals: [] }); | ||
| 48 | expect(yield* Fiber.join(read)).toEqual([]); | ||
| 49 | }), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | |||
| 53 | it.effect("refreshes terminal metadata while an existing subscriber keeps the stream mounted", () => | ||
| 54 | Effect.scoped( | ||
| 55 | Effect.gen(function* () { | ||
| 56 | let subscriptions = 0; | ||
| 57 | let current: TerminalMetadataStreamEvent = { type: "snapshot", terminals: [] }; | ||
| 58 | source.stream = Stream.suspend(() => { | ||
| 59 | subscriptions++; | ||
| 60 | return Stream.concat(Stream.succeed(current), Stream.never); | ||
| 61 | }); | ||
| 62 | const runtime = Atom.runtime( | ||
| 63 | Layer.succeed(EnvironmentRegistry, { | ||
| 64 | followStream: (_id, stream) => Stream.concat(stream, Stream.never), | ||
| 65 | } as EnvironmentRegistry["Service"]), | ||
| 66 | ); | ||
| 67 | const terminals = createTerminalEnvironmentAtoms(runtime); | ||
| 68 | const registry = AtomRegistry.make(); | ||
| 69 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 70 | const target = { environmentId: EnvironmentId.make("metadata-refresh"), input: null }; | ||
| 71 | const live = terminals.metadata(target); | ||
| 72 | yield* AtomRegistry.mount(registry, live); | ||
| 73 | expect(yield* AtomRegistry.getResult(registry, live)).toEqual([]); | ||
| 74 | const atom = terminals.metadataSnapshot(target); | ||
| 75 | const initial = yield* Effect.promise(() => | ||
| 76 | executeAtomQuery(registry, atom, { refresh: true }), | ||
| 77 | ); | ||
| 78 | expect(initial._tag).toBe("Success"); | ||
| 79 | if (initial._tag === "Success") expect(initial.value).toEqual([]); | ||
| 80 | const subscriptionsBeforeRefresh = subscriptions; | ||
| 81 | current = { | ||
| 82 | type: "snapshot", | ||
| 83 | terminals: [ | ||
| 84 | { | ||
| 85 | threadId: "thread", | ||
| 86 | terminalId: "term", | ||
| 87 | cwd: "/tmp", | ||
| 88 | worktreePath: null, | ||
| 89 | status: "running", | ||
| 90 | pid: 42, | ||
| 91 | exitCode: null, | ||
| 92 | exitSignal: null, | ||
| 93 | hasRunningSubprocess: true, | ||
| 94 | label: "sleep", | ||
| 95 | updatedAt: "2026-10-15T00:00:00.000Z", | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | }; | ||
| 99 | const result = yield* Effect.promise(() => | ||
| 100 | executeAtomQuery(registry, atom, { refresh: true }), | ||
| 101 | ); | ||
| 102 | expect(result._tag).toBe("Success"); | ||
| 103 | if (result._tag === "Success") expect(result.value).toEqual(current.terminals); | ||
| 104 | expect(subscriptions).toBeGreaterThan(subscriptionsBeforeRefresh); | ||
| 105 | expect(registry.get(live)).toMatchObject({ _tag: "Success", waiting: true, value: [] }); | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | ); | ||
packages/client-runtime/src/state/terminal.ts+10| ... | @@ -64,6 +64,16 @@ export function createTerminalEnvironmentAtoms<R, E>( | ... | @@ -64,6 +64,16 @@ export function createTerminalEnvironmentAtoms<R, E>( |
| 64 | subscribe: (_input: null) => | 64 | subscribe: (_input: null) => |
| 65 | subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( | 65 | subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( |
| 66 | Stream.scan((): ReadonlyArray<TerminalSummary> => [], applyTerminalMetadataStreamEvent), | 66 | Stream.scan((): ReadonlyArray<TerminalSummary> => [], applyTerminalMetadataStreamEvent), |
| 67 | Stream.drop(1), | ||
| 68 | ), | ||
| 69 | }), | ||
| 70 | metadataSnapshot: createEnvironmentSubscriptionAtomFamily(runtime, { | ||
| 71 | label: "environment-data:terminal:metadata-snapshot", | ||
| 72 | completeWhen: () => true, | ||
| 73 | subscribe: (_input: null) => | ||
| 74 | subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( | ||
| 75 | Stream.filter((event) => event.type === "snapshot"), | ||
| 76 | Stream.map((event) => event.terminals), | ||
| 67 | ), | 77 | ), |
| 68 | }), | 78 | }), |
| 69 | open: createEnvironmentRpcCommand(runtime, { | 79 | open: createEnvironmentRpcCommand(runtime, { |
packages/client-runtime/src/state/threadSettled.test.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { resolveThreadSettlementWarning } from "./threadSettled.ts"; | ||
| 4 | |||
| 5 | describe("settlement resource warning", () => { | ||
| 6 | it("skips confirmation when no commands or browser tabs need closing", () => { | ||
| 7 | expect( | ||
| 8 | resolveThreadSettlementWarning({ runningCommands: false, browserTabs: false }), | ||
| 9 | ).toBeNull(); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it.each([ | ||
| 13 | { runningCommands: true, browserTabs: false, text: "Undo cannot restart them." }, | ||
| 14 | { runningCommands: false, browserTabs: true, text: "Undo cannot restore page state." }, | ||
| 15 | { | ||
| 16 | runningCommands: true, | ||
| 17 | browserTabs: true, | ||
| 18 | text: "Undo cannot restore commands or page state.", | ||
| 19 | }, | ||
| 20 | ])( | ||
| 21 | "describes only the resources being closed: $runningCommands/$browserTabs", | ||
| 22 | ({ text, ...resources }) => { | ||
| 23 | expect(resolveThreadSettlementWarning(resources)).toContain(text); | ||
| 24 | }, | ||
| 25 | ); | ||
| 26 | |||
| 27 | it.each([ | ||
| 28 | { runningCommands: null, browserTabs: false }, | ||
| 29 | { runningCommands: false, browserTabs: null }, | ||
| 30 | { runningCommands: null, browserTabs: null }, | ||
| 31 | ])("never treats an unavailable resource read as empty", (resources) => { | ||
| 32 | expect(resolveThreadSettlementWarning(resources)).toContain("could not be checked"); | ||
| 33 | }); | ||
| 34 | }); | ||
packages/client-runtime/src/state/threadSettled.ts+17| ... | @@ -3,6 +3,23 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -3,6 +3,23 @@ import * as DateTime from "effect/DateTime"; |
| 3 | 3 | ||
| 4 | import type { PendingSubagentRequest } from "./models.ts"; | 4 | import type { PendingSubagentRequest } from "./models.ts"; |
| 5 | 5 | ||
| 6 | export function resolveThreadSettlementWarning(resources: { | ||
| 7 | readonly runningCommands: boolean | null; | ||
| 8 | readonly browserTabs: boolean | null; | ||
| 9 | }): string | null { | ||
| 10 | const { runningCommands, browserTabs } = resources; | ||
| 11 | if (runningCommands === false && browserTabs === false) return null; | ||
| 12 | if (runningCommands === null || browserTabs === null) { | ||
| 13 | return "Some terminals or browser tabs could not be checked. Settling closes them and stops any running commands. Undo cannot restore commands or page state."; | ||
| 14 | } | ||
| 15 | if (runningCommands && browserTabs) { | ||
| 16 | return "Settling stops running commands and closes browser tabs. Undo cannot restore commands or page state."; | ||
| 17 | } | ||
| 18 | return runningCommands | ||
| 19 | ? "Settling stops running commands. Undo cannot restart them." | ||
| 20 | : "Settling closes browser tabs. Undo cannot restore page state."; | ||
| 21 | } | ||
| 22 | |||
| 6 | interface SettlementRunLike { | 23 | interface SettlementRunLike { |
| 7 | readonly turnId?: unknown; | 24 | readonly turnId?: unknown; |
| 8 | readonly assistantMessageId?: unknown; | 25 | readonly assistantMessageId?: unknown; |
packages/contracts/src/clientRpcPermissions.ts+2| ... | @@ -17,6 +17,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { | ... | @@ -17,6 +17,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { |
| 17 | [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, | 17 | [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, |
| 18 | [WS_METHODS.devServerStatus]: AuthFilesystemReadScope, | 18 | [WS_METHODS.devServerStatus]: AuthFilesystemReadScope, |
| 19 | [WS_METHODS.devServerStart]: AuthTerminalOperateScope, | 19 | [WS_METHODS.devServerStart]: AuthTerminalOperateScope, |
| 20 | [WS_METHODS.managedWorkspacesCreate]: AuthOrchestrationOperateScope, | ||
| 21 | [WS_METHODS.managedWorkspacesArchiveCompleted]: AuthOrchestrationOperateScope, | ||
| 20 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, | 22 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, |
| 21 | // Endpoint checks exist to configure exports, so they need the same grant as saving them. | 23 | // Endpoint checks exist to configure exports, so they need the same grant as saving them. |
| 22 | [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, | 24 | [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, |
packages/contracts/src/index.ts+1| ... | @@ -66,6 +66,7 @@ export * from "./worktreeSetup.ts"; | ... | @@ -66,6 +66,7 @@ export * from "./worktreeSetup.ts"; |
| 66 | export * from "./secretRequest.ts"; | 66 | export * from "./secretRequest.ts"; |
| 67 | export * from "./clientRpcPermissions.ts"; | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | export * from "./devServer.ts"; | 68 | export * from "./devServer.ts"; |
| 69 | export * from "./managedWorkspaces.ts"; | ||
| 69 | export * from "./mergebot.ts"; | 70 | export * from "./mergebot.ts"; |
| 70 | export * from "./scriptProgress.ts"; | 71 | export * from "./scriptProgress.ts"; |
| 71 | export * from "./desktopTerminal.ts"; | 72 | export * from "./desktopTerminal.ts"; |
packages/contracts/src/managedWorkspaces.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 3 | |||
| 4 | export const ManagedWorkspaceStatus = Schema.Struct({ projectId: Schema.NullOr(ProjectId) }); | ||
| 5 | export const ManagedWorkspaceCreated = Schema.Struct({ | ||
| 6 | workspacePath: TrimmedNonEmptyString, | ||
| 7 | projectId: ProjectId, | ||
| 8 | threadId: ThreadId, | ||
| 9 | environmentId: EnvironmentId, | ||
| 10 | }); | ||
| 11 | export type ManagedWorkspaceCreated = typeof ManagedWorkspaceCreated.Type; | ||
| 12 | |||
| 13 | export const ManagedWorkspaceArchiveInput = Schema.Struct({ workspacePath: TrimmedNonEmptyString }); | ||
| 14 | export const ManagedWorkspaceArchived = Schema.Struct({ | ||
| 15 | archivedThreadIds: Schema.Array(ThreadId), | ||
| 16 | }); | ||
| 17 | export type ManagedWorkspaceArchived = typeof ManagedWorkspaceArchived.Type; | ||
| 18 | |||
| 19 | export class ManagedWorkspaceError extends Schema.TaggedError<ManagedWorkspaceError>()( | ||
| 20 | "ManagedWorkspaceError", | ||
| 21 | { | ||
| 22 | reason: Schema.Literals(["unavailable", "create", "reply", "workspace", "archive"]), | ||
| 23 | cause: Schema.optional(Schema.Defect()), | ||
| 24 | }, | ||
| 25 | ) { | ||
| 26 | override get message(): string { | ||
| 27 | switch (this.reason) { | ||
| 28 | case "workspace": | ||
| 29 | return "Workspace cleanup is paused. Check the managed workspace on this host."; | ||
| 30 | case "archive": | ||
| 31 | return "Workspace archival is paused. Check its linked pull requests and retry."; | ||
| 32 | case "unavailable": | ||
| 33 | return "Workspace creation is unavailable. Install ws, configure the source for my work, and add that source to T3 Code on this host."; | ||
| 34 | case "create": | ||
| 35 | return "Workspace creation stopped. Run ws new on this host for details."; | ||
| 36 | case "reply": | ||
| 37 | return "The workspace tool returned an unreadable reply. Update ws on this host before trying again."; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
packages/contracts/src/orchestrationV2.test.ts+17| ... | @@ -29,6 +29,7 @@ import { | ... | @@ -29,6 +29,7 @@ import { |
| 29 | OrchestrationV2Command, | 29 | OrchestrationV2Command, |
| 30 | OrchestrationV2LimitRecoveryUpdate, | 30 | OrchestrationV2LimitRecoveryUpdate, |
| 31 | OrchestrationV2DomainEvent, | 31 | OrchestrationV2DomainEvent, |
| 32 | OrchestrationV2DispatchCommandError, | ||
| 32 | OrchestrationV2ProviderCapabilities, | 33 | OrchestrationV2ProviderCapabilities, |
| 33 | OrchestrationV2ProviderThread, | 34 | OrchestrationV2ProviderThread, |
| 34 | OrchestrationV2ProviderThreadJson, | 35 | OrchestrationV2ProviderThreadJson, |
| ... | @@ -46,6 +47,22 @@ import { | ... | @@ -46,6 +47,22 @@ import { |
| 46 | } from "./orchestrationV2.ts"; | 47 | } from "./orchestrationV2.ts"; |
| 47 | 48 | ||
| 48 | const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); | 49 | const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); |
| 50 | const encodeDispatchCommandError = Schema.encodeSync(OrchestrationV2DispatchCommandError); | ||
| 51 | const decodeDispatchCommandError = Schema.decodeUnknownSync(OrchestrationV2DispatchCommandError); | ||
| 52 | |||
| 53 | it("preserves conditional archive rejection across the dispatch error wire schema", () => { | ||
| 54 | const error = new OrchestrationV2DispatchCommandError({ | ||
| 55 | commandId: CommandId.make("archive-condition-wire"), | ||
| 56 | commandType: "thread.archive", | ||
| 57 | reason: "archive-condition-changed", | ||
| 58 | message: "The thread changed before archival.", | ||
| 59 | }); | ||
| 60 | const encoded = encodeDispatchCommandError(error); | ||
| 61 | const decoded = decodeDispatchCommandError(JSON.parse(JSON.stringify(encoded))); | ||
| 62 | |||
| 63 | expect(decoded.reason).toBe("archive-condition-changed"); | ||
| 64 | }); | ||
| 65 | |||
| 49 | function emptyThreadProjection() { | 66 | function emptyThreadProjection() { |
| 50 | return { | 67 | return { |
| 51 | thread: { | 68 | thread: { |
packages/contracts/src/orchestrationV2.ts+12| ... | @@ -2746,6 +2746,7 @@ export const OrchestrationV2Command = Schema.Union([ | ... | @@ -2746,6 +2746,7 @@ export const OrchestrationV2Command = Schema.Union([ |
| 2746 | type: Schema.Literal("thread.archive"), | 2746 | type: Schema.Literal("thread.archive"), |
| 2747 | commandId: CommandId, | 2747 | commandId: CommandId, |
| 2748 | threadId: ThreadId, | 2748 | threadId: ThreadId, |
| 2749 | snapshotSequence: Schema.optional(NonNegativeInt), | ||
| 2749 | }), | 2750 | }), |
| 2750 | Schema.Struct({ | 2751 | Schema.Struct({ |
| 2751 | type: Schema.Literal("thread.unarchive"), | 2752 | type: Schema.Literal("thread.unarchive"), |
| ... | @@ -2781,6 +2782,7 @@ export const OrchestrationV2Command = Schema.Union([ | ... | @@ -2781,6 +2782,7 @@ export const OrchestrationV2Command = Schema.Union([ |
| 2781 | commandId: CommandId, | 2782 | commandId: CommandId, |
| 2782 | threadId: ThreadId, | 2783 | threadId: ThreadId, |
| 2783 | reason: Schema.Literal("user"), | 2784 | reason: Schema.Literal("user"), |
| 2785 | snapshotSequence: Schema.optional(NonNegativeInt), | ||
| 2784 | }), | 2786 | }), |
| 2785 | Schema.Struct({ | 2787 | Schema.Struct({ |
| 2786 | type: Schema.Literal("thread.snooze"), | 2788 | type: Schema.Literal("thread.snooze"), |
| ... | @@ -3179,6 +3181,15 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; | ... | @@ -3179,6 +3181,15 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; |
| 3179 | * send them. | 3181 | * send them. |
| 3180 | */ | 3182 | */ |
| 3181 | const OrchestrationV2InternalCommand = Schema.Union([ | 3183 | const OrchestrationV2InternalCommand = Schema.Union([ |
| 3184 | Schema.Struct({ | ||
| 3185 | type: Schema.Literal("thread.ci-failed"), | ||
| 3186 | commandId: CommandId, | ||
| 3187 | threadId: ThreadId, | ||
| 3188 | snapshotSequence: NonNegativeInt, | ||
| 3189 | itemId: TurnItemId, | ||
| 3190 | pullRequestNumber: PositiveInt, | ||
| 3191 | url: TrimmedNonEmptyString, | ||
| 3192 | }), | ||
| 3182 | /** | 3193 | /** |
| 3183 | * Records what a pull request watch saw, and wakes the agent in the same transaction when | 3194 | * Records what a pull request watch saw, and wakes the agent in the same transaction when |
| 3184 | * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is | 3195 | * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is |
| ... | @@ -3535,6 +3546,7 @@ export class OrchestrationV2DispatchCommandError extends Schema.TaggedError<Orch | ... | @@ -3535,6 +3546,7 @@ export class OrchestrationV2DispatchCommandError extends Schema.TaggedError<Orch |
| 3535 | { | 3546 | { |
| 3536 | commandId: CommandId, | 3547 | commandId: CommandId, |
| 3537 | commandType: Schema.String, | 3548 | commandType: Schema.String, |
| 3549 | reason: Schema.optional(Schema.Literal("archive-condition-changed")), | ||
| 3538 | message: Schema.String, | 3550 | message: Schema.String, |
| 3539 | detail: Schema.optional(Schema.String), | 3551 | detail: Schema.optional(Schema.String), |
| 3540 | cause: Schema.optional(Schema.Defect()), | 3552 | cause: Schema.optional(Schema.Defect()), |
packages/contracts/src/pullRequest.ts+1| ... | @@ -153,6 +153,7 @@ export const PullRequestCheck = Schema.Struct({ | ... | @@ -153,6 +153,7 @@ export const PullRequestCheck = Schema.Struct({ |
| 153 | status: PullRequestCheckStatus, | 153 | status: PullRequestCheckStatus, |
| 154 | description: Schema.NullOr(Schema.String), | 154 | description: Schema.NullOr(Schema.String), |
| 155 | url: Schema.NullOr(Schema.String), | 155 | url: Schema.NullOr(Schema.String), |
| 156 | completedAt: Schema.optional(IsoDateTime), | ||
| 156 | /** The base branch requires this check to merge. Absent where the host does not say. */ | 157 | /** The base branch requires this check to merge. Absent where the host does not say. */ |
| 157 | required: Schema.optional(Schema.Boolean), | 158 | required: Schema.optional(Schema.Boolean), |
| 158 | }); | 159 | }); |
packages/contracts/src/rpc.ts+35-3| ... | @@ -1,5 +1,3 @@ | ... | @@ -1,5 +1,3 @@ |
| 1 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; | ||
| 2 | import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts"; | ||
| 3 | import { | 1 | import { |
| 4 | UiSettingsProfile, | 2 | UiSettingsProfile, |
| 5 | UiSettingsPatchInput, | 3 | UiSettingsPatchInput, |
| ... | @@ -11,6 +9,15 @@ import { | ... | @@ -11,6 +9,15 @@ import { |
| 11 | ScriptProgressEvent, | 9 | ScriptProgressEvent, |
| 12 | ScriptProgressStreamError, | 10 | ScriptProgressStreamError, |
| 13 | } from "./scriptProgress.ts"; | 11 | } from "./scriptProgress.ts"; |
| 12 | import { | ||
| 13 | ManagedWorkspaceStatus, | ||
| 14 | ManagedWorkspaceCreated, | ||
| 15 | ManagedWorkspaceError, | ||
| 16 | ManagedWorkspaceArchiveInput, | ||
| 17 | ManagedWorkspaceArchived, | ||
| 18 | } from "./managedWorkspaces.ts"; | ||
| 19 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; | ||
| 20 | import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts"; | ||
| 14 | import { | 21 | import { |
| 15 | OrchestrationV2SearchThreadError, | 22 | OrchestrationV2SearchThreadError, |
| 16 | OrchestrationV2SearchThreadInput, | 23 | OrchestrationV2SearchThreadInput, |
| ... | @@ -299,7 +306,6 @@ import { | ... | @@ -299,7 +306,6 @@ import { |
| 299 | DeviceSession, | 306 | DeviceSession, |
| 300 | DeviceShutdownInput, | 307 | DeviceShutdownInput, |
| 301 | } from "./device.ts"; | 308 | } from "./device.ts"; |
| 302 | import {} from "./previewAutomation.ts"; | ||
| 303 | import { | 309 | import { |
| 304 | ServerConfigStreamEvent, | 310 | ServerConfigStreamEvent, |
| 305 | DesktopUpdateCommitInput, | 311 | DesktopUpdateCommitInput, |
| ... | @@ -400,6 +406,9 @@ export const WS_METHODS = { | ... | @@ -400,6 +406,9 @@ export const WS_METHODS = { |
| 400 | 406 | ||
| 401 | devServerStatus: "devServer.status", | 407 | devServerStatus: "devServer.status", |
| 402 | devServerStart: "devServer.start", | 408 | devServerStart: "devServer.start", |
| 409 | managedWorkspacesStatus: "managedWorkspaces.status", | ||
| 410 | managedWorkspacesCreate: "managedWorkspaces.create", | ||
| 411 | managedWorkspacesArchiveCompleted: "managedWorkspaces.archiveCompleted", | ||
| 403 | 412 | ||
| 404 | // Shell methods | 413 | // Shell methods |
| 405 | shellOpenInEditor: "shell.openInEditor", | 414 | shellOpenInEditor: "shell.openInEditor", |
| ... | @@ -1281,6 +1290,26 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { | ... | @@ -1281,6 +1290,26 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { |
| 1281 | error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), | 1290 | error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), |
| 1282 | }); | 1291 | }); |
| 1283 | 1292 | ||
| 1293 | const WsManagedWorkspacesArchiveCompletedRpc = Rpc.make( | ||
| 1294 | WS_METHODS.managedWorkspacesArchiveCompleted, | ||
| 1295 | { | ||
| 1296 | payload: ManagedWorkspaceArchiveInput, | ||
| 1297 | success: ManagedWorkspaceArchived, | ||
| 1298 | error: Schema.Union([ManagedWorkspaceError, EnvironmentAuthorizationError]), | ||
| 1299 | }, | ||
| 1300 | ); | ||
| 1301 | |||
| 1302 | const WsManagedWorkspacesStatusRpc = Rpc.make(WS_METHODS.managedWorkspacesStatus, { | ||
| 1303 | payload: Schema.Struct({}), | ||
| 1304 | success: ManagedWorkspaceStatus, | ||
| 1305 | error: EnvironmentAuthorizationError, | ||
| 1306 | }); | ||
| 1307 | const WsManagedWorkspacesCreateRpc = Rpc.make(WS_METHODS.managedWorkspacesCreate, { | ||
| 1308 | payload: Schema.Struct({}), | ||
| 1309 | success: ManagedWorkspaceCreated, | ||
| 1310 | error: Schema.Union([ManagedWorkspaceError, EnvironmentAuthorizationError]), | ||
| 1311 | }); | ||
| 1312 | |||
| 1284 | const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, { | 1313 | const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, { |
| 1285 | payload: DevServerTarget, | 1314 | payload: DevServerTarget, |
| 1286 | success: DevServerStatus, | 1315 | success: DevServerStatus, |
| ... | @@ -2080,6 +2109,9 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -2080,6 +2109,9 @@ export const WsRpcGroup = RpcGroup.make( |
| 2080 | WsProjectsReadFileRpc, | 2109 | WsProjectsReadFileRpc, |
| 2081 | WsProjectsWatchRpc, | 2110 | WsProjectsWatchRpc, |
| 2082 | WsDevServerStatusRpc, | 2111 | WsDevServerStatusRpc, |
| 2112 | WsManagedWorkspacesStatusRpc, | ||
| 2113 | WsManagedWorkspacesCreateRpc, | ||
| 2114 | WsManagedWorkspacesArchiveCompletedRpc, | ||
| 2083 | WsDevServerStartRpc, | 2115 | WsDevServerStartRpc, |
| 2084 | WsProjectsSearchContentsRpc, | 2116 | WsProjectsSearchContentsRpc, |
| 2085 | WsProjectsSearchEntriesRpc, | 2117 | WsProjectsSearchEntriesRpc, |
packages/contracts/src/server.ts+1| ... | @@ -675,6 +675,7 @@ export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; | ... | @@ -675,6 +675,7 @@ export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; |
| 675 | 675 | ||
| 676 | export const ServerConfig = Schema.Struct({ | 676 | export const ServerConfig = Schema.Struct({ |
| 677 | devServerStatus: Schema.optionalKey(Schema.Boolean), | 677 | devServerStatus: Schema.optionalKey(Schema.Boolean), |
| 678 | managedWorkspaces: Schema.optionalKey(Schema.Boolean), | ||
| 678 | mergebot: Schema.optionalKey(MergebotConfiguration), | 679 | mergebot: Schema.optionalKey(MergebotConfiguration), |
| 679 | environment: ExecutionEnvironmentDescriptor, | 680 | environment: ExecutionEnvironmentDescriptor, |
| 680 | auth: ServerAuthDescriptor, | 681 | auth: ServerAuthDescriptor, |
packages/source-control-github/src/server/gitHubPullRequestJson.test.ts+32| ... | @@ -435,6 +435,38 @@ describe("pull request detail decoding", () => { | ... | @@ -435,6 +435,38 @@ describe("pull request detail decoding", () => { |
| 435 | expect(detail.checksState).toBe("pending"); | 435 | expect(detail.checksState).toBe("pending"); |
| 436 | }); | 436 | }); |
| 437 | 437 | ||
| 438 | it("preserves a failed rerun's job URL and completion time", () => { | ||
| 439 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | ||
| 440 | const previous = { | ||
| 441 | __typename: "CheckRun", | ||
| 442 | name: "build", | ||
| 443 | workflowName: "Checks", | ||
| 444 | status: "COMPLETED", | ||
| 445 | conclusion: "FAILURE", | ||
| 446 | startedAt: "2026-08-11T16:00:00Z", | ||
| 447 | completedAt: "2026-08-11T16:01:00Z", | ||
| 448 | detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/2", | ||
| 449 | }; | ||
| 450 | const rerun = { | ||
| 451 | ...previous, | ||
| 452 | startedAt: "2026-08-11T17:00:00Z", | ||
| 453 | completedAt: "2026-08-11T17:01:00Z", | ||
| 454 | detailsUrl: "https://github.com/owner/repository/actions/runs/1/job/3", | ||
| 455 | }; | ||
| 456 | const detail = expectSuccess( | ||
| 457 | decodePullRequestDetailJson(JSON.stringify({ ...raw, statusCheckRollup: [previous, rerun] })), | ||
| 458 | ); | ||
| 459 | expect(detail.checks).toEqual([ | ||
| 460 | { | ||
| 461 | name: "build", | ||
| 462 | status: "failure", | ||
| 463 | description: null, | ||
| 464 | url: rerun.detailsUrl, | ||
| 465 | completedAt: rerun.completedAt, | ||
| 466 | }, | ||
| 467 | ]); | ||
| 468 | }); | ||
| 469 | |||
| 438 | it("keeps a check pending while another run of it on the same commit is still going", () => { | 470 | it("keeps a check pending while another run of it on the same commit is still going", () => { |
| 439 | // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The | 471 | // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The |
| 440 | // `pull_request` run finished after the `push` run started, and the `push` run is still going. | 472 | // `pull_request` run finished after the `push` run started, and the `push` run is still going. |
packages/source-control-github/src/server/gitHubPullRequestJson.ts+2| ... | @@ -1651,6 +1651,7 @@ function toCheckEntries( | ... | @@ -1651,6 +1651,7 @@ function toCheckEntries( |
| 1651 | const name = trimmed(check.name) ?? trimmed(check.context); | 1651 | const name = trimmed(check.name) ?? trimmed(check.context); |
| 1652 | if (name === null) return []; | 1652 | if (name === null) return []; |
| 1653 | const status = toCheckStatus(check); | 1653 | const status = toCheckStatus(check); |
| 1654 | const completedAt = trimmed(check.completedAt); | ||
| 1654 | return [ | 1655 | return [ |
| 1655 | { | 1656 | { |
| 1656 | check: { | 1657 | check: { |
| ... | @@ -1658,6 +1659,7 @@ function toCheckEntries( | ... | @@ -1658,6 +1659,7 @@ function toCheckEntries( |
| 1658 | status, | 1659 | status, |
| 1659 | description: trimmed(check.description), | 1660 | description: trimmed(check.description), |
| 1660 | url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), | 1661 | url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), |
| 1662 | ...(completedAt === null || completedAt.startsWith("0001-") ? {} : { completedAt }), | ||
| 1661 | ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), | 1663 | ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), |
| 1662 | }, | 1664 | }, |
| 1663 | workflowName: trimmed(check.workflowName), | 1665 | workflowName: trimmed(check.workflowName), |