@teamscala/service-boot 0.23.349 → 0.23.352

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +479 -567
  2. package/src/agreement-gate.ts +1 -1
  3. package/src/auth-gate.ts +0 -11
  4. package/src/fn-routers/index.ts +1 -12
  5. package/src/fn-routers/public-agreement-router.ts +37 -49
  6. package/src/mount-orpc-router.test.ts +46 -0
  7. package/src/mount-orpc-router.ts +16 -3
  8. package/src/mutable-app-ref.ts +0 -20
  9. package/src/page-server-orpc/puck-pages-router.ts +0 -102
  10. package/src/register-page-server-client.ts +0 -4
  11. package/src/register-ws-routes.ts +10 -3
  12. package/src/two-phase-boot.ts +2 -1
  13. package/src/ui-service.ts +11 -18
  14. package/src/fn-routers/ai-health-router.ts +0 -64
  15. package/src/fn-routers/panel-data-router.ts +0 -93
  16. package/src/fn-routers/plans-router.ts +0 -141
  17. package/src/fn-routers/task-sync-assign.test.ts +0 -483
  18. package/src/fn-routers/task-sync-assign.ts +0 -485
  19. package/src/fn-routers/task-sync-bind.test.ts +0 -110
  20. package/src/fn-routers/task-sync-brief.test.ts +0 -120
  21. package/src/fn-routers/task-sync-brief.ts +0 -151
  22. package/src/fn-routers/task-sync-classify.test.ts +0 -109
  23. package/src/fn-routers/task-sync-close.test.ts +0 -44
  24. package/src/fn-routers/task-sync-close.ts +0 -82
  25. package/src/fn-routers/task-sync-created-status.test.ts +0 -58
  26. package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
  27. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
  28. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  29. package/src/fn-routers/task-sync-projection.test.ts +0 -172
  30. package/src/fn-routers/task-sync-projection.ts +0 -273
  31. package/src/fn-routers/task-sync-rename.test.ts +0 -53
  32. package/src/fn-routers/task-sync-rename.ts +0 -111
  33. package/src/fn-routers/tasks-router.ts +0 -77
  34. package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
  35. package/src/page-server-orpc/templates-router.ts +0 -116
  36. package/src/serve/serve-ws.test.ts +0 -135
  37. package/src/serve/serve.ts +0 -148
  38. package/src/serve/stamp-transport-identity.test.ts +0 -24
  39. package/src/serve/stamp-transport-identity.ts +0 -109
@@ -1,120 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Unit tests for the self-brief leg: shapeBriefResponse (the pure
7
- * no-task/no-comment ⇒ nulls decision) + source-contract assertions that
8
- * readFirstTaskComment reads the FIRST launch comment and the handler resolves by
9
- * session. Covers `what-must-have-a-test` for the new procedure.
10
- */
11
- import { describe, it, expect } from "bun:test";
12
- import { shapeBriefResponse, readFirstTaskComment } from "./task-sync-brief.ts";
13
-
14
- describe("shapeBriefResponse (no verdict from no data)", () => {
15
- it("classifies a missing task as unbound (work_item_id null)", () => {
16
- // A lane with no resolvable task has no brief — null, never an empty fake.
17
- // status:"unbound" is the signal a non-fleet lane no-ops on and a fleet
18
- // lane (which resolves by work_item_id, so cannot reach this) never sees.
19
- expect(shapeBriefResponse(null, { id: "c-0", content: "x", created_at: new Date() })).toEqual({
20
- work_item_id: null,
21
- comment_id: null,
22
- brief: null,
23
- comment_created_at: null,
24
- status: "unbound",
25
- });
26
- });
27
-
28
- it("classifies a task with NO comment as no_comment (work_item_id SET)", () => {
29
- // The task resolved but no spawn comment landed. work_item_id is now SET
30
- // (the task IS bound) — distinct from unbound — and status:"no_comment"
31
- // is the broken state a fleet lane halts on rather than reading as success.
32
- expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
33
- work_item_id: "task-1",
34
- comment_id: null,
35
- brief: null,
36
- comment_created_at: null,
37
- status: "no_comment",
38
- });
39
- });
40
-
41
- it("classifies a task with a BLANK comment as empty_brief (the unreadable case)", () => {
42
- // The comment exists but its content is empty/whitespace — "loads it
43
- // empty". A fleet lane whose spawn minted a task MUST have a non-empty
44
- // brief, so a blank comment is a defect the hook halts on, not a success.
45
- expect(shapeBriefResponse({ id: "task-2" }, { id: "c-2", content: " ", created_at: new Date() })).toEqual({
46
- work_item_id: "task-2",
47
- comment_id: "c-2",
48
- brief: null,
49
- comment_created_at: null,
50
- status: "empty_brief",
51
- });
52
- expect(shapeBriefResponse({ id: "task-2b" }, { id: "c-2b", content: "", created_at: new Date() })).toEqual({
53
- work_item_id: "task-2b",
54
- comment_id: "c-2b",
55
- brief: null,
56
- comment_created_at: null,
57
- status: "empty_brief",
58
- });
59
- });
60
-
61
- it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
62
- const ts = new Date("2026-08-01T03:24:00.000Z");
63
- expect(shapeBriefResponse({ id: "task-7" }, { id: "c-7", content: "Do the thing", created_at: ts })).toEqual({
64
- work_item_id: "task-7",
65
- comment_id: "c-7",
66
- brief: "Do the thing",
67
- comment_created_at: "2026-08-01T03:24:00.000Z",
68
- status: "ready",
69
- });
70
- });
71
-
72
- it("serializes the comment created_at deterministically (Date → ISO)", () => {
73
- const ts = new Date(0);
74
- const out = shapeBriefResponse({ id: "t" }, { id: "c", content: "c", created_at: ts });
75
- expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
76
- });
77
- });
78
-
79
- describe("readAssignedBrief reads the FIRST launch comment (source contract)", () => {
80
- const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
81
-
82
- it("queries comments oldest-first with an id tie-breaker", async () => {
83
- // The first comment is immutable launch identity; later comments are dialogue.
84
- const text = await briefSrc.text();
85
- expect(text).toContain('orderBy: [{ created_at: "asc" }, { id: "asc" }]');
86
- expect(text).toContain("work_item_id: workItemId");
87
- });
88
-
89
- it("resolves the task by session before reading its comment", async () => {
90
- // The lane's own session id is the join key — a pane id is never the key
91
- // (it outlives the session and would inherit a prior session's brief).
92
- const text = await briefSrc.text();
93
- expect(text).toContain("resolveLaneTask(prisma, session)");
94
- });
95
-
96
- it("resolves by the authoritative work_item_id BEFORE the session (deletes the bind race)", async () => {
97
- // The spawn's known task id (SCALA_LANE_WORK_ITEM) cannot race — the row
98
- // exists because the spawn created it — so resolving by it first makes an
99
- // empty brief for a fleet lane unambiguously broken rather than a
100
- // transient "retry" the lane can never exit (absent-input-reads-as-success).
101
- const text = await briefSrc.text();
102
- expect(text).toContain("workItemId");
103
- expect(text).toContain("resolveTaskById(prisma, workItemId)");
104
- // The session bind is no longer load-bearing for brief delivery: it stays
105
- // only as the fallback for callers without a work_item_id.
106
- const idIdx = text.indexOf("resolveTaskById(prisma, workItemId)");
107
- const sessionIdx = text.indexOf("resolveLaneTask(prisma, session)");
108
- expect(idIdx).toBeGreaterThan(-1);
109
- expect(sessionIdx).toBeGreaterThan(idIdx);
110
- });
111
- });
112
-
113
- describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
114
- it("returns null when the prisma client has no comments model (feature-detect)", async () => {
115
- // `comments` is a universal shared model; a client without it degrades to
116
- // "no brief" rather than throwing.
117
- const out = await readFirstTaskComment({} as never, "task-1");
118
- expect(out).toBe(null);
119
- });
120
- });
@@ -1,151 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
7
- * lane's CLI extension reads its own session id on load, resolves the task
8
- * assigned to that session, and reads the brief from that task's FIRST comment.
9
- * Nothing is pasted, typed, passed as an argument or sent over a channel
10
- * (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
11
- * brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
12
- * the router mounts the handler.
13
- *
14
- * Split out of task-sync-router.ts for the same reason task-sync-lane.ts +
15
- * task-sync-projection.ts exist: the router sits at its 1000-line commit gate,
16
- * so every cohesive unit that can live in a sibling does. The self-brief leg is
17
- * one unit — the handler + the response-shaping decision belong together.
18
- *
19
- * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
20
- * candidate. Bound to the work_items + comments schema and to task-sync's own
21
- * kind_data.lane.session convention; no unrelated app could install this
22
- * unmodified.
23
- */
24
-
25
- import type { Prisma, CommentRow } from "./task-sync-router.ts";
26
- import type { WorkItem } from "./task-sync-router.ts";
27
- import { authed, type Context } from "./auth-router.ts";
28
- import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
29
-
30
- /**
31
- * The outcome of a brief read, single-sourced here so every CLI's self-brief
32
- * hook (zerostack/codex shell, opencode/pi TS) branches on ONE field rather
33
- * than each re-deriving "is the brief missing" from null/empty probes — the
34
- * seam at which absent-input-reads-as-success hid (`the-severe-bugs-live-at-seams`).
35
- *
36
- * The hook decides ACTION from this; the classification lives here. A fleet
37
- * lane (SCALA_LANE_WORK_ITEM set) treats anything but "ready" as a hard halt.
38
- */
39
- export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
40
-
41
- /**
42
- * Shape the readAssignedBrief response from a resolved task + its first
43
- * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
44
- * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
45
- *
46
- * `work_item_id` is set iff the TASK resolved (independent of whether a comment
47
- * exists), and `status` distinguishes the four states a hook must act on:
48
- * - "ready" — task + a non-empty comment: inject the brief.
49
- * - "empty_brief" — task resolved, comment exists but content is blank: the
50
- * spawn minted a task with no usable brief — broken.
51
- * - "no_comment" — task resolved, no comment at all: same broken class.
52
- * - "unbound" — no task resolved: the lane has no brief task (a non-fleet
53
- * operator tab, or — for the session-only path — a bind
54
- * that has not yet commit; the work_item_id path cannot
55
- * reach this for a fleet lane).
56
- *
57
- * "empty_brief" / "no_comment" with the task resolved is the state the
58
- * absent-input fix exists to catch: a fleet lane whose spawn succeeded MUST
59
- * have a non-empty brief comment, so its absence is a defect the hook halts on
60
- * rather than reading as success.
61
- */
62
- export function shapeBriefResponse(
63
- task: { id: string } | null,
64
- comment: Pick<CommentRow, "id" | "content" | "created_at"> | null,
65
- ): {
66
- work_item_id: string | null;
67
- comment_id: string | null;
68
- brief: string | null;
69
- comment_created_at: string | null;
70
- status: BriefReadStatus;
71
- } {
72
- if (!task) {
73
- return { work_item_id: null, comment_id: null, brief: null, comment_created_at: null, status: "unbound" };
74
- }
75
- if (!comment) {
76
- return { work_item_id: task.id, comment_id: null, brief: null, comment_created_at: null, status: "no_comment" };
77
- }
78
- const text = typeof comment.content === "string" ? comment.content : String(comment.content);
79
- if (text.trim() === "") {
80
- return { work_item_id: task.id, comment_id: comment.id, brief: null, comment_created_at: null, status: "empty_brief" };
81
- }
82
- return {
83
- work_item_id: task.id,
84
- comment_id: comment.id,
85
- brief: text,
86
- comment_created_at:
87
- comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
88
- status: "ready",
89
- };
90
- }
91
-
92
- /**
93
- * Read the immutable FIRST comment on a work_items task. It is the launch brief;
94
- * later comments are conversation and must never change the launch identity.
95
- * Oldest-first by created_at with id as a deterministic tie-breaker. Returns null
96
- * when the task has no comment yet.
97
- */
98
- export async function readFirstTaskComment(
99
- prisma: Prisma,
100
- workItemId: string,
101
- ): Promise<CommentRow | null> {
102
- const comments = (
103
- prisma as unknown as {
104
- comments?: {
105
- findFirst: (a: unknown) => Promise<CommentRow | null>;
106
- };
107
- }
108
- ).comments;
109
- if (!comments?.findFirst) return null;
110
- return comments.findFirst({
111
- where: { work_item_id: workItemId },
112
- orderBy: [{ created_at: "asc" }, { id: "asc" }],
113
- });
114
- }
115
-
116
- /**
117
- * readAssignedBrief ORPC handler. A lane's CLI extension calls this with its
118
- * own session id to pull its brief. Same transport as bindLane/readTaskList: the
119
- * session id arrives on x-lane-session (and body.lane_session as fallback). The
120
- * procedure is page-gate-exempt (fn.taskSync), so a localhost pane shell
121
- * authenticates as super-admin via resolveUser with no session cookie.
122
- */
123
- export const readAssignedBriefHandler = authed.handler(async ({ input, context }) => {
124
- const ctx = context as Context;
125
- const prisma = ctx.prisma as Prisma;
126
- const body = (input ?? {}) as { lane_session?: string; work_item_id?: string };
127
- const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
128
- // The spawn's known task id (SCALA_LANE_WORK_ITEM) — AUTHORITATIVE for brief
129
- // resolution. Resolving by it first deletes the bindLane race that made an
130
- // empty brief read as a transient "retry" a fleet lane could never exit
131
- // (absent-input-reads-as-success). Falls back to session for callers without
132
- // it (operator tabs / pre-id lanes).
133
- const workItemId =
134
- body.work_item_id?.trim() || ctx.headers?.get("x-lane-work-item")?.trim() || null;
135
- if (!session && !workItemId) {
136
- const { ORPCError } = await import("@orpc/server");
137
- throw new ORPCError("BAD_REQUEST", {
138
- message: "readAssignedBrief requires lane_session or work_item_id",
139
- });
140
- }
141
- let task: (WorkItem & { id: string }) | null = null;
142
- if (workItemId) {
143
- task = (await resolveTaskById(prisma, workItemId)) as (WorkItem & { id: string }) | null;
144
- }
145
- if (!task && session) {
146
- task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
147
- }
148
- if (!task) return shapeBriefResponse(null, null);
149
- const comment = await readFirstTaskComment(prisma, task.id);
150
- return shapeBriefResponse(task, comment);
151
- });
@@ -1,109 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Tests for classifyTaskHookEvent — the discriminator between the Claude task
7
- * lifecycle legs.
8
- *
9
- * The regression this locks: `TaskCreated` and `TaskCompleted` BOTH carry
10
- * `task_subject`, so the handler's original "has a subject → it's a creation"
11
- * test could not tell them apart. Only `TaskCreated` was ever wired, so nothing
12
- * exercised the ambiguity — but the moment completion is wired, a completion
13
- * classified as a creation mints a DUPLICATE subtask instead of closing one.
14
- * That is the operator-visible symptom exactly: a task list that only ever
15
- * grows, with everything reading incomplete (`task-projection-converges`).
16
- */
17
-
18
- import { describe, expect, test } from "bun:test";
19
- import { classifyTaskHookEvent, sanitizeTaskSubject } from "./task-sync-router.ts";
20
-
21
- describe("classifyTaskHookEvent", () => {
22
- test("a completion carrying a subject is COMPLETED, not a creation", () => {
23
- // The exact shape the TaskCompleted hook delivers: subject present.
24
- expect(
25
- classifyTaskHookEvent({
26
- hook_event_name: "TaskCompleted",
27
- task_subject: "Verify bindLane populates Task",
28
- }),
29
- ).toBe("completed");
30
- });
31
-
32
- test("a creation carrying a subject is CREATED", () => {
33
- expect(
34
- classifyTaskHookEvent({
35
- hook_event_name: "TaskCreated",
36
- task_subject: "Verify bindLane populates Task",
37
- }),
38
- ).toBe("created");
39
- });
40
-
41
- test("subject alone still classifies as created (payloads predating hook_event_name)", () => {
42
- expect(classifyTaskHookEvent({ task_subject: "Some task" })).toBe("created");
43
- });
44
-
45
- test("a PostToolUse-shaped payload with no subject falls through to the tool leg", () => {
46
- expect(classifyTaskHookEvent({})).toBe("tool");
47
- expect(classifyTaskHookEvent({ hook_event_name: "PostToolUse" })).toBe("tool");
48
- });
49
-
50
- test("event name WINS over subject presence — the whole point", () => {
51
- // If this ever inverts, completions silently become duplicate creations.
52
- const completed = classifyTaskHookEvent({
53
- hook_event_name: "TaskCompleted",
54
- task_subject: "x",
55
- });
56
- const created = classifyTaskHookEvent({
57
- hook_event_name: "TaskCreated",
58
- task_subject: "x",
59
- });
60
- expect(completed).not.toBe(created);
61
- });
62
- });
63
-
64
- // The subjects below are the REAL corrupted values found in work_items on
65
- // 2026-07-25 — three rows whose titles rendered on the fleet dashboard as a
66
- // wall of XML where the operator expects a task name. The malformation happens
67
- // upstream in the CLI's hook payload, so the write boundary is the only place
68
- // that can stop it reaching the single source of truth every surface reads.
69
- describe("sanitizeTaskSubject", () => {
70
- test("cuts the leaked tool call, keeping the real subject", () => {
71
- expect(
72
- sanitizeTaskSubject(
73
- 'Cut judge false positives on clean work</subject>\n<parameter name="description">Live gate blocks ~half of clean drafts (7 of 7 failures were clean).',
74
- ),
75
- ).toBe("Cut judge false positives on clean work");
76
-
77
- expect(
78
- sanitizeTaskSubject(
79
- 'Give benchmark drafts synthetic tool activity</subject>\n<parameter name="description">The labelled suite sends drafts with no tool activity.',
80
- ),
81
- ).toBe("Give benchmark drafts synthetic tool activity");
82
- });
83
-
84
- test("cuts on a bare next-parameter marker with no closing tag", () => {
85
- expect(sanitizeTaskSubject('Do the thing<parameter name="description">blah')).toBe(
86
- "Do the thing",
87
- );
88
- });
89
-
90
- test("leaves an ordinary subject untouched", () => {
91
- // Including ones with punctuation that merely LOOKS structural — angle
92
- // brackets alone are not a leak, only the tool-call markers are.
93
- expect(sanitizeTaskSubject("Bound the pg-boss pool")).toBe("Bound the pg-boss pool");
94
- expect(sanitizeTaskSubject(" Fix a < b comparison ")).toBe("Fix a < b comparison");
95
- expect(sanitizeTaskSubject("Rename foo -> bar")).toBe("Rename foo -> bar");
96
- });
97
-
98
- test("truncates rather than discards, so the task is not lost", () => {
99
- // Dropping the row would lose a task the lane genuinely created; the text
100
- // before the marker is the real subject and is worth keeping.
101
- expect(sanitizeTaskSubject("Real subject</subject>junk").length).toBeGreaterThan(0);
102
- });
103
-
104
- test("an entirely-markup subject collapses to empty so the caller discards it", () => {
105
- // The create leg treats "" as no-subject and records a discard — correct,
106
- // because there is no real title to keep here.
107
- expect(sanitizeTaskSubject('</subject><parameter name="description">x')).toBe("");
108
- });
109
- });
@@ -1,44 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- */
6
-
7
- import { describe, expect, it } from "bun:test";
8
- import { closedTaskState } from "./task-sync-close.ts";
9
-
10
- const task = {
11
- id: "task-1",
12
- kind_data: {
13
- lane: { session: "acp-session", cli_kind: "zerostack", live: true },
14
- assignee_type: "agent",
15
- },
16
- };
17
-
18
- describe("closedTaskState", () => {
19
- it("marks completed work done and the lane non-live", () => {
20
- expect(closedTaskState(task, "completed")).toEqual({
21
- task_id: "task-1",
22
- task_status: "done",
23
- completed: true,
24
- kind_data: {
25
- lane: { session: "acp-session", cli_kind: "zerostack", live: false },
26
- assignee_type: "agent",
27
- },
28
- });
29
- });
30
-
31
- it("archives every disposition that does not prove completion", () => {
32
- for (const disposition of [
33
- "needs_retry",
34
- "failed",
35
- "orchestrator_closed",
36
- "orphaned",
37
- ] as const) {
38
- const state = closedTaskState(task, disposition);
39
- expect(state.task_status).toBe("archived");
40
- expect(state.completed).toBe(false);
41
- expect((state.kind_data.lane as { live: boolean }).live).toBe(false);
42
- }
43
- });
44
- });
@@ -1,82 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Lane-close write-through for task-sync. The session-owning fleet runtime
7
- * calls this ORPC procedure before releasing an ACP lane, so work_items remains
8
- * the durable lifecycle source without a second raw-SQL writer in the host.
9
- */
10
-
11
- import { ORPCError } from "@orpc/server";
12
- import * as v from "valibot";
13
- import { authed, type Context } from "./auth-router.ts";
14
- import type { Prisma, WorkItem } from "./task-sync-router.ts";
15
-
16
- export const closeLaneInput = v.object({
17
- lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
18
- disposition: v.picklist([
19
- "completed",
20
- "needs_retry",
21
- "failed",
22
- "orchestrator_closed",
23
- "orphaned",
24
- ]),
25
- });
26
-
27
- export interface ClosedTaskState {
28
- task_id: string;
29
- task_status: "done" | "archived";
30
- completed: boolean;
31
- kind_data: Record<string, unknown>;
32
- }
33
-
34
- export function closedTaskState(
35
- task: Pick<WorkItem, "id" | "kind_data">,
36
- disposition: v.InferOutput<typeof closeLaneInput>["disposition"],
37
- ): ClosedTaskState {
38
- const completed = disposition === "completed";
39
- const lane =
40
- task.kind_data.lane && typeof task.kind_data.lane === "object"
41
- ? (task.kind_data.lane as Record<string, unknown>)
42
- : {};
43
- return {
44
- task_id: task.id,
45
- task_status: completed ? "done" : "archived",
46
- completed,
47
- kind_data: { ...task.kind_data, lane: { ...lane, live: false } },
48
- };
49
- }
50
-
51
- export const closeLaneHandler = authed
52
- .input(closeLaneInput)
53
- .handler(async ({ input, context }) => {
54
- const prisma = (context as Context).prisma as Prisma;
55
- const task = await prisma.work_items.findFirst({
56
- where: {
57
- kind: "task",
58
- kind_data: { path: ["lane", "session"], equals: input.lane_session },
59
- },
60
- orderBy: { created_at: "desc" },
61
- });
62
- if (!task) {
63
- throw new ORPCError("NOT_FOUND", {
64
- message: `No task is bound to ACP lane ${input.lane_session}`,
65
- });
66
- }
67
- const state = closedTaskState(task, input.disposition);
68
- await prisma.work_items.update({
69
- where: { id: task.id },
70
- data: {
71
- kind_data: state.kind_data,
72
- status: state.task_status,
73
- completed: state.completed,
74
- },
75
- select: { id: true },
76
- });
77
- return {
78
- task_id: state.task_id,
79
- task_status: state.task_status,
80
- reconciled: true,
81
- };
82
- });
@@ -1,58 +0,0 @@
1
- /**
2
- * A task CREATED already finished must project as done, not open.
3
- *
4
- * `TaskCreate({status:"completed"})` is the normal way to record work performed
5
- * after the fact. The create path hardcoded `status: "todo"`, so such a task
6
- * landed OPEN and the stop gate then blocked the lane on a task it had itself
7
- * recorded as done — observed twice on 2026-07-31, each needing a manual
8
- * work_items update to clear. The UPDATE path already mapped through
9
- * STATUS_TO_WORK_ITEM; only create dropped it.
10
- *
11
- * The derivation is asserted directly because that is the whole change: the map
12
- * lookup plus the `?? "todo"` default. The default is what makes the fix safe
13
- * regardless of whether the hook actually sends the field — correct if it does,
14
- * byte-identical to the old behaviour if it does not.
15
- */
16
- import { expect, test } from "bun:test";
17
- import { STATUS_TO_WORK_ITEM } from "./task-sync-projection.ts";
18
-
19
- type Status = "pending" | "in_progress" | "completed";
20
- const derive = (s: unknown) => STATUS_TO_WORK_ITEM[s as Status] ?? "todo";
21
-
22
- test("created completed -> done (the case that blocked the stop gate)", () => {
23
- expect(derive("completed")).toBe("done");
24
- expect(derive("completed") === "done").toBe(true);
25
- });
26
-
27
- test("created in_progress -> in-progress (DB enum uses the hyphen)", () => {
28
- expect(derive("in_progress")).toBe("in-progress");
29
- });
30
-
31
- test("created pending -> todo", () => {
32
- expect(derive("pending")).toBe("todo");
33
- });
34
-
35
- test("ABSENT status -> todo — the fix is inert when the hook omits the field", () => {
36
- // This is the safety property. If the payload carries no status the lookup
37
- // misses and the default reproduces the previous hardcoded behaviour exactly,
38
- // so shipping this cannot regress any existing create path.
39
- expect(derive(undefined)).toBe("todo");
40
- expect(derive(null)).toBe("todo");
41
- });
42
-
43
- test("unknown status -> todo rather than throwing", () => {
44
- // A CLI sending a status this platform does not model must degrade to open,
45
- // never crash the hook — a task-sync throw takes the lane's whole list down.
46
- expect(derive("archived")).toBe("todo");
47
- expect(derive(42)).toBe("todo");
48
- });
49
-
50
- test("only the done mapping sets completed", () => {
51
- // `completed` is a separate column from `status`; deriving it from the same
52
- // value keeps the two from disagreeing (a done row with completed=false reads
53
- // as open to every consumer that checks the boolean).
54
- for (const s of ["pending", "in_progress"] as const) {
55
- expect(derive(s) === "done").toBe(false);
56
- }
57
- expect(derive("completed") === "done").toBe(true);
58
- });
@@ -1,62 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Unit tests for the pure durable comment-payload builder in task-sync-dispatch.
7
- * The runtime path writes this payload through the canonical comments model.
8
- */
9
- import { describe, it, expect } from "bun:test";
10
- import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
11
-
12
- describe("task-sync buildTaskChangeComment", () => {
13
- it("addresses the canonical task and runtime with no pane transport", () => {
14
- const comment = buildTaskChangeComment("task-7", "session-5", "subtask-1:completed");
15
- expect(comment).toMatchObject({
16
- work_item_id: "task-7",
17
- source_cli_session_id: "session-5",
18
- sender_role: "system",
19
- sender_name: "TaskSync",
20
- });
21
- expect(comment.system_key).toBe("task-sync:task-7:session-5:subtask-1:completed");
22
- expect(comment.content).toContain("readTaskList");
23
- expect(comment).not.toHaveProperty("pane");
24
- });
25
-
26
- it("writes one durable comment addressed by the active cli_session", async () => {
27
- const calls: unknown[] = [];
28
- const result = await notifyLaneOfTaskChange(
29
- {
30
- cli_sessions: { findFirst: async () => ({ id: "session-5" }) },
31
- comments: {
32
- upsert: async (args) => {
33
- calls.push(args);
34
- return { id: "comment-1" };
35
- },
36
- },
37
- },
38
- "task-7",
39
- "subtask-1:completed",
40
- );
41
- expect(result).toMatchObject({ delivered: true, sessionId: "session-5", commentId: "comment-1" });
42
- expect(calls[0]).toMatchObject({ create: { work_item_id: "task-7", source_cli_session_id: "session-5" } });
43
- });
44
-
45
- it("does not fabricate a pre-launch session or comment", async () => {
46
- let writes = 0;
47
- const result = await notifyLaneOfTaskChange(
48
- {
49
- cli_sessions: { findFirst: async () => null },
50
- comments: {
51
- upsert: async () => {
52
- writes++;
53
- return { id: "unexpected" };
54
- },
55
- },
56
- },
57
- "task-7",
58
- );
59
- expect(result).toEqual({ delivered: false, sessionId: null, commentId: null });
60
- expect(writes).toBe(0);
61
- });
62
- });