@teamscala/service-boot 0.23.350 → 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.
- package/package.json +479 -567
- package/src/agreement-gate.ts +1 -1
- package/src/auth-gate.ts +0 -11
- package/src/fn-routers/index.ts +1 -12
- package/src/fn-routers/public-agreement-router.ts +37 -49
- package/src/mount-orpc-router.test.ts +46 -0
- package/src/mount-orpc-router.ts +16 -3
- package/src/mutable-app-ref.ts +0 -20
- package/src/page-server-orpc/puck-pages-router.ts +0 -102
- package/src/register-page-server-client.ts +0 -4
- package/src/register-ws-routes.ts +10 -3
- package/src/two-phase-boot.ts +2 -1
- package/src/ui-service.ts +11 -18
- package/src/fn-routers/ai-health-router.ts +0 -64
- package/src/fn-routers/panel-data-router.ts +0 -93
- package/src/fn-routers/plans-router.ts +0 -141
- package/src/fn-routers/task-sync-assign.test.ts +0 -483
- package/src/fn-routers/task-sync-assign.ts +0 -485
- package/src/fn-routers/task-sync-bind.test.ts +0 -110
- package/src/fn-routers/task-sync-brief.test.ts +0 -120
- package/src/fn-routers/task-sync-brief.ts +0 -151
- package/src/fn-routers/task-sync-classify.test.ts +0 -109
- package/src/fn-routers/task-sync-close.test.ts +0 -44
- package/src/fn-routers/task-sync-close.ts +0 -82
- package/src/fn-routers/task-sync-created-status.test.ts +0 -58
- package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
- package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
- package/src/fn-routers/task-sync-lane.test.ts +0 -36
- package/src/fn-routers/task-sync-projection.test.ts +0 -172
- package/src/fn-routers/task-sync-projection.ts +0 -273
- package/src/fn-routers/task-sync-rename.test.ts +0 -53
- package/src/fn-routers/task-sync-rename.ts +0 -111
- package/src/fn-routers/tasks-router.ts +0 -77
- package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
- package/src/page-server-orpc/templates-router.ts +0 -116
- package/src/serve/serve-ws.test.ts +0 -135
- package/src/serve/serve.ts +0 -148
- package/src/serve/stamp-transport-identity.test.ts +0 -24
- 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
|
-
});
|