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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Landing-order collision: two cases landed as `15.`; resolved by the `b` suffix o

20. E7's revised Squadron-picker row shape: `apps/web/src/components/CommandPalette.tsx:990-1004` delegates its row fields to J5-owned `apps/web/src/j5/squadron/SquadronPicker.logic.ts:18-23`. The visible row is the Squadron name alone and has no folder-description second line; folder title/path remain search terms, availability state, and launch substrate only. Amended by case 34 (Jackson's ruling, 2026-09-12): once the directory spans more than one environment, the row's description is the owning environment's label, because two environments can each hold a Squadron of the same name; a single-environment directory keeps the name-only row. The row helper's focused tests prove both the retained non-visual mechanics, the absence of a `description` field for one environment, and the label for two. On every rebase, preserve this presentation without changing the Registrar-derived identity or the missing-folder failure behavior.

21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone up front; the rule itself is on the server since case 37. The Captain rule is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the seat finish notifier and retires a Captain's live Crews as units when its `thread.archived` or `thread.deleted` commits, whichever door the archive came through; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). Both reactions ride a stream that resumes from its high-water mark and log rather than retry a failure, so the notifier daemon runs one sweep before streaming (2026-09-17, Bryant's choice after Jackson's review of PR #151): `CrewCaptainArchiveCascade.reconcile` retires every live Crew whose Captain thread is archived or gone, and `CrewSeatFinishNotifier.reconcile` tells the Captain of every live seat whose newest run finished with nothing reporting it. `archiveWithPreflight` passes `{ undoable }` to its `archive` callback; `archiveMayRetireCrews` is false only for a clean or non-Captain thread with a successful Crew read. `useThreadActions.archiveThread` takes optional `undoable` and skips upstream's Undo notice (#12848) when false, because `thread.unarchived` does not restore retired Crews (decision #7, 2026-09-24). Doors: Sidebar row menu, header menu (`useThreadActionMenu`), LegacySidebar. On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts.
21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone up front; the rule itself is on the server since case 37. The Captain rule is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the seat finish notifier and carries a Captain's Crews through its lifecycle whichever door the change came through (#312): its `thread.archived` or `thread.deleted` retires its live Crews as units and records that they retired with it, its `thread.unarchived` brings those Crews back with their seat threads, and its `thread.settled` or `thread.unsettled` is sent to each seat, where settle skips a seat with a pending runtime request, a live run, or background work as upstream's `isAutoSettlementCandidate` does; each Crew's step runs once, keyed by the event so an archive after an unarchive gets fresh command ids, and a failure is logged; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). Both reactions ride a stream that resumes from its high-water mark and log rather than retry a failure, so the notifier daemon runs one sweep before streaming (2026-09-17, Bryant's choice after Jackson's review of PR #151): `CrewCaptainArchiveCascade.reconcile` retires every live Crew whose Captain thread is archived or gone (unarchive, settle, and unsettle have no restart recovery), and `CrewSeatFinishNotifier.reconcile` tells the Captain of every live seat whose newest run finished with nothing reporting it. Every archive door keeps upstream's Undo notice (#12848): decision #7 (2026-09-24) withheld it for a Captain because `thread.unarchived` did not restore retired Crews, and since #312 it does, so the `undoable` plumbing is gone and the Sidebar, header menu (`apps/web/src/hooks/useThreadActionMenu.ts`), and LegacySidebar doors call `archiveThread` as upstream does. On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts.

22. B4's typed confirmation presentation seam: `apps/web/src/confirmDialog.ts:4-31,92-158` extends only the in-memory `requestConfirmDialog(message, options, presentation?)` coordinator state with optional `ReactNode` content and `confirmLabel`; `apps/web/src/components/ConfirmDialogHost.tsx:57-67,79-116` renders the typed node and label when present and otherwise keeps its existing string title/description plus `Confirm` path. The host has no J5 import, payload decoding, or private prefix branch. J5 owns the archive row content in `apps/web/src/j5/a2a/ArchiveWarningContent.tsx:6-130`; `apps/web/src/j5/a2a/archiveFlow.ts:13-17,40-94` builds and passes it for consequential archive facts, with `Archive anyway` only when one or more open asks are measured and `Archive` otherwise. `apps/web/src/components/Sidebar.tsx:3169-3181` plus `apps/web/src/components/LegacySidebar.tsx:1824-1836,2012-2018` use the existing confirmation coordinator to carry the presentation. Existing string callers omit the third argument and remain byte-for-byte on the prior path. Jackson's Variant B title `Archive <agent>?` supersedes the earlier Designer condition that put “anyway” in the question; the explicit button label now disambiguates the action. On every rebase, verify the host stays J5-agnostic, the absent-presentation string branch remains intact, and the three archive doors continue to pass J5-owned presentation only for fact-backed warnings.

Expand Down Expand Up @@ -620,7 +620,7 @@ indicate approval. The deleted hook remains explicitly marked.
| `apps/web/src/components/sidebar/SidebarChrome.tsx` | A | 6, B |
| `apps/web/src/confirmDialog.test.ts` | R | 22 |
| `apps/web/src/confirmDialog.ts` | R | 22 |
| `apps/web/src/hooks/useThreadActionMenu.ts` | A | 19 — deleted orphan hook |
| `apps/web/src/hooks/useThreadActionMenu.ts` | A | 21 |
| `apps/web/src/routeTree.gen.ts` | R | generated route registrations for 6/9 |
| `apps/web/src/components/onboarding/WelcomeWizard.tsx` | R | 39, B |
| `apps/web/src/routes/welcome.tsx` | R | 39 |
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/j5/a2a/AgentCrewInstanceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,48 @@ it.effect("refuses a same-name seat under a different identity, and any seat onc
assert.lengthOf((yield* service.read("crew:conflict"))!.members, 1);
}).pipe(Effect.provide(testLayer)),
);

it.effect("brings back only a Crew that retired with its Captain", () =>
Effect.gen(function* () {
yield* runJ5A2AMigrations();
const squadronId = SquadronId.make("squadron:crew-restore");
yield* (yield* A2ALedger).createSquadron({
squadron: { id: squadronId, name: "Restore Squadron", createdAt },
});
const service = yield* AgentCrewInstanceService;
const captainThreadId = ThreadId.make("thread:restore-captain");
const record = (id: string) =>
service.record({
id,
squadronId,
captainParticipantId: ParticipantId.make("agent:j5:a2a:restore-captain"),
captainThreadId,
displayName: id,
brief: "Keep going.",
createdAt,
members: [],
});
yield* record("crew:with-captain");
yield* record("crew:on-its-own");
yield* service.markArchived("crew:with-captain", "2026-09-09T17:00:00.000Z", {
withCaptain: true,
});
yield* service.markArchived("crew:on-its-own", "2026-09-09T17:00:00.000Z");
assert.deepStrictEqual(
(yield* service.listRetiredWithCaptain(captainThreadId)).map(({ id }) => id),
["crew:with-captain"],
);
assert.deepStrictEqual(
(yield* service.listRetiredWithCaptain(ThreadId.make("thread:other"))).map(({ id }) => id),
[],
);
assert.isFalse(yield* service.restoreWithCaptain("crew:on-its-own"));
assert.isTrue(yield* service.restoreWithCaptain("crew:with-captain"));
assert.isNull((yield* service.read("crew:with-captain"))?.archivedAt);
assert.isNotNull((yield* service.read("crew:on-its-own"))?.archivedAt);
// Restoring twice is a no-op, and a later archive on its own does not come back.
assert.isFalse(yield* service.restoreWithCaptain("crew:with-captain"));
yield* service.markArchived("crew:with-captain", "2026-09-09T18:00:00.000Z");
assert.deepStrictEqual(yield* service.listRetiredWithCaptain(captainThreadId), []);
}).pipe(Effect.provide(testLayer)),
);
47 changes: 44 additions & 3 deletions apps/server/src/j5/a2a/AgentCrewInstanceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,21 @@ export interface AgentCrewInstanceServiceShape {
readonly threadIds: ReadonlyArray<ThreadId>;
readonly participantIds: ReadonlyArray<ParticipantId>;
}) => Effect.Effect<ReadonlyArray<AgentCrewInstance>, SqlError>;
/** Idempotent: the first archive timestamp wins. */
readonly markArchived: (id: string, archivedAt: string) => Effect.Effect<void, SqlError>;
/**
* Idempotent: the first archive timestamp wins. `withCaptain` records that the Crew retired
* because its Captain was archived, so unarchiving the Captain brings it back.
*/
readonly markArchived: (
id: string,
archivedAt: string,
options?: { readonly withCaptain?: boolean },
) => Effect.Effect<void, SqlError>;
/** The retired Crews that retired with the Captain on this thread. */
readonly listRetiredWithCaptain: (
captainThreadId: ThreadId,
) => Effect.Effect<ReadonlyArray<AgentCrewInstance>, SqlError>;
/** Makes a Crew that retired with its Captain live again; false when it was not one. */
readonly restoreWithCaptain: (id: string) => Effect.Effect<boolean, SqlError>;
/**
* Run one unit step on a Crew with no other unit step on it: a launch or addition from its
* record or reservation through its briefs, and a unit archive from its roster read through
Expand Down Expand Up @@ -380,13 +393,39 @@ export const layer: Layer.Layer<AgentCrewInstanceService, never, SqlClient.SqlCl
const markArchived = Effect.fn("j5.a2a.agentCrewInstances.markArchived")(function* (
id: string,
archivedAt: string,
options?: { readonly withCaptain?: boolean },
) {
yield* sql`
UPDATE j5_agent_crew_instance SET archived_at = ${archivedAt}
UPDATE j5_agent_crew_instance
SET archived_at = ${archivedAt},
retired_with_captain = ${options?.withCaptain === true ? 1 : 0}
WHERE id = ${id} AND archived_at IS NULL
`;
});

const listRetiredWithCaptain = Effect.fn("j5.a2a.agentCrewInstances.listRetiredWithCaptain")(
function* (captainThreadId: ThreadId) {
const rows = yield* sql<InstanceRow>`
SELECT * FROM j5_agent_crew_instance
WHERE archived_at IS NOT NULL AND retired_with_captain = 1
AND captain_thread_id = ${captainThreadId}
ORDER BY created_at, id
`;
return yield* readMany(rows);
},
);

const restoreWithCaptain = Effect.fn("j5.a2a.agentCrewInstances.restoreWithCaptain")(
function* (id: string) {
const rows = yield* sql<{ readonly id: string }>`
UPDATE j5_agent_crew_instance SET archived_at = NULL, retired_with_captain = 0
WHERE id = ${id} AND archived_at IS NOT NULL AND retired_with_captain = 1
RETURNING id
`;
return rows.length > 0;
},
);

const removeMembers = Effect.fn("j5.a2a.agentCrewInstances.removeMembers")(function* (
id: string,
seatNames: ReadonlyArray<string>,
Expand Down Expand Up @@ -417,6 +456,8 @@ export const layer: Layer.Layer<AgentCrewInstanceService, never, SqlClient.SqlCl
listLive,
listInvolving,
markArchived,
listRetiredWithCaptain,
restoreWithCaptain,
});
}),
);
6 changes: 5 additions & 1 deletion apps/server/src/j5/a2a/ArchiveCrewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface ArchiveCrewInput {
*/
readonly confirmationSatisfied?: boolean;
readonly archivedAt: string;
/** Set by the Captain cascade: the Crew comes back when its Captain is unarchived. */
readonly withCaptain?: boolean;
/** Per-seat command ids derive from the caller's request key so retries replay each member. */
readonly commandIds: (seatName: string) => {
readonly interruptCommandId: CommandId;
Expand Down Expand Up @@ -455,7 +457,9 @@ export const layer = Layer.effect(
);
results.push({ seatName: member.seatName, participantId: member.participantId, result });
}
yield* crews.markArchived(instance.id, input.archivedAt).pipe(Effect.orDie);
yield* crews
.markArchived(instance.id, input.archivedAt, { withCaptain: input.withCaptain === true })
.pipe(Effect.orDie);
return { status: "archived" as const, members: results };
}).pipe((unit) => crews.serialize(input.crewInstanceId, unit));

Expand Down
Loading
Loading