@teamscala/service-boot 0.23.320 → 0.23.322
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
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle round-trip for the Claude Code per-item task projection:
|
|
7
|
+
* `upsertFromTaskHookHandler` (the extracted body of the upsertFromTaskHook
|
|
8
|
+
* ORPC handler) must carry a task through created -> in_progress -> completed ->
|
|
9
|
+
* deleted against an in-memory mock prisma, so `work_items` sees every state a
|
|
10
|
+
* task reaches (`task-projection-converges`). A projection that cannot express
|
|
11
|
+
* completion — or deletion — is an append-only log, and the operator cannot see
|
|
12
|
+
* what a lane is doing. The matcher covering TaskCreate|TaskUpdate|TaskDelete is
|
|
13
|
+
* what routes all three tool events here; these tests assert the body applies
|
|
14
|
+
* each one. Same mock-prisma shape as claim-orchestrator-session.test.ts.
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
17
|
+
|
|
18
|
+
const recordEventMock = mock((_event: unknown) => {});
|
|
19
|
+
mock.module("@teamscala/event-log/record-event", () => ({ recordEvent: recordEventMock }));
|
|
20
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
21
|
+
getAppLogger: () => ({
|
|
22
|
+
info: () => {},
|
|
23
|
+
warn: () => {},
|
|
24
|
+
error: () => {},
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { upsertFromTaskHookHandler } = await import("./task-sync-router.ts");
|
|
29
|
+
|
|
30
|
+
/** A lane's parent task, returned by the mocked resolveLaneTask findFirst. */
|
|
31
|
+
const LANE_TASK = {
|
|
32
|
+
id: "task-1",
|
|
33
|
+
kind: "task",
|
|
34
|
+
parent_id: null,
|
|
35
|
+
organisation_id: "org-1",
|
|
36
|
+
title: "Test Lane",
|
|
37
|
+
status: "active",
|
|
38
|
+
completed: false,
|
|
39
|
+
position: null,
|
|
40
|
+
kind_data: { lane: { session: "ses-1", pane: "tmux:%1" } },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function makePrisma() {
|
|
44
|
+
// In-memory subtask store, so the four states act on ONE evolving row.
|
|
45
|
+
const subtasks: Array<Record<string, unknown>> = [];
|
|
46
|
+
const findFirst = mock(async (args: { where: Record<string, unknown> }) => {
|
|
47
|
+
// resolveLaneTask asks for kind='task' + status active/archived; everything
|
|
48
|
+
// else (subtask lookups) returns null.
|
|
49
|
+
if (args.where?.kind === "task") return { ...LANE_TASK };
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
const findMany = mock(async (args: { where: Record<string, unknown> }) => {
|
|
53
|
+
const pid = args.where?.parent_id;
|
|
54
|
+
return subtasks.filter((s) => s.parent_id === pid);
|
|
55
|
+
});
|
|
56
|
+
const create = mock(async (args: { data: Record<string, unknown> }) => {
|
|
57
|
+
const row = { id: `s-${subtasks.length + 1}`, ...args.data };
|
|
58
|
+
subtasks.push(row);
|
|
59
|
+
return row;
|
|
60
|
+
});
|
|
61
|
+
const update = mock(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
62
|
+
const row = subtasks.find((s) => s.id === args.where.id);
|
|
63
|
+
if (row) Object.assign(row, args.data);
|
|
64
|
+
return row ?? null;
|
|
65
|
+
});
|
|
66
|
+
const deleteMany = mock(async (args: { where: { id: { in: string[] } } }) => {
|
|
67
|
+
// Real Prisma form: where.id.in (a bare array on the scalar id is a
|
|
68
|
+
// validation error). Mirrors the canonical deleteMany in reconcileTaskList.
|
|
69
|
+
const ids = args.where?.id?.in ?? [];
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (let i = subtasks.length - 1; i >= 0; i--) {
|
|
72
|
+
if (ids.includes(String(subtasks[i]?.id))) {
|
|
73
|
+
subtasks.splice(i, 1);
|
|
74
|
+
count++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { count };
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
store: subtasks,
|
|
81
|
+
prisma: { work_items: { findFirst, findMany, create, update, deleteMany } },
|
|
82
|
+
mocks: { findFirst, findMany, create, update, deleteMany },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A lane context bound to a mock prisma, so each call site passes only the event. */
|
|
87
|
+
function bindCtx(prisma: unknown, session = "ses-1") {
|
|
88
|
+
const headers = new Headers();
|
|
89
|
+
headers.set("x-lane-session", session);
|
|
90
|
+
headers.set("x-lane-pane", "tmux:%1");
|
|
91
|
+
return { headers, prisma, user: undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** makePrisma + a `hook(evt)` convenience bound to that prisma's session. */
|
|
95
|
+
function makeFixture() {
|
|
96
|
+
const fixture = makePrisma();
|
|
97
|
+
const hook = (evt: unknown) => upsertFromTaskHookHandler(evt, bindCtx(fixture.prisma));
|
|
98
|
+
return { ...fixture, hook };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("upsertFromTaskHookHandler — full lifecycle round-trip", () => {
|
|
102
|
+
beforeEach(() => recordEventMock.mockClear());
|
|
103
|
+
|
|
104
|
+
it("created -> in_progress -> completed -> deleted all write through to one subtask", async () => {
|
|
105
|
+
const { hook, store, mocks } = makeFixture();
|
|
106
|
+
|
|
107
|
+
// 1. CREATED — PostToolUse:TaskCreate. The harness assigns the task id in
|
|
108
|
+
// tool_response.task.id; the projection must stamp it as the mark so every
|
|
109
|
+
// later state is mark-addressable.
|
|
110
|
+
const created = await hook({
|
|
111
|
+
tool_name: "TaskCreate",
|
|
112
|
+
hook_event_name: "PostToolUse",
|
|
113
|
+
tool_input: { subject: "Ship the fix" },
|
|
114
|
+
tool_response: { task: { id: "42" } },
|
|
115
|
+
});
|
|
116
|
+
expect(created).toEqual({ applied: true, op: "create", taskId: "42" });
|
|
117
|
+
expect(mocks.create).toHaveBeenCalledTimes(1);
|
|
118
|
+
const row = store[0];
|
|
119
|
+
expect(row.title).toBe("Ship the fix");
|
|
120
|
+
expect(row.status).toBe("todo");
|
|
121
|
+
expect((row.kind_data as { task_sync: { mark: string } }).task_sync.mark).toBe("42");
|
|
122
|
+
|
|
123
|
+
// 2. IN_PROGRESS — PostToolUse:TaskUpdate with status in_progress. Matched
|
|
124
|
+
// by the mark stamped at creation; the projection expresses it, it does
|
|
125
|
+
// not append a new row.
|
|
126
|
+
mocks.create.mockClear();
|
|
127
|
+
const inProgress = await hook({
|
|
128
|
+
tool_name: "TaskUpdate",
|
|
129
|
+
hook_event_name: "PostToolUse",
|
|
130
|
+
tool_input: { taskId: "42", status: "in_progress" },
|
|
131
|
+
});
|
|
132
|
+
expect(inProgress).toEqual({ applied: true, op: "update", taskId: "42" });
|
|
133
|
+
expect(mocks.create).not.toHaveBeenCalled();
|
|
134
|
+
expect(mocks.update).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(row.status).toBe("in-progress");
|
|
136
|
+
expect(row.completed).toBe(false);
|
|
137
|
+
expect(store).toHaveLength(1);
|
|
138
|
+
|
|
139
|
+
// 3. COMPLETED — PostToolUse:TaskUpdate with status completed. A projection
|
|
140
|
+
// that cannot express completion is an append-only log.
|
|
141
|
+
mocks.update.mockClear();
|
|
142
|
+
const done = await hook({
|
|
143
|
+
tool_name: "TaskUpdate",
|
|
144
|
+
hook_event_name: "PostToolUse",
|
|
145
|
+
tool_input: { taskId: "42", status: "completed" },
|
|
146
|
+
});
|
|
147
|
+
expect(done).toEqual({ applied: true, op: "update", taskId: "42" });
|
|
148
|
+
expect(mocks.update).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(row.status).toBe("done");
|
|
150
|
+
expect(row.completed).toBe(true);
|
|
151
|
+
expect(store).toHaveLength(1);
|
|
152
|
+
|
|
153
|
+
// 4. DELETED — PostToolUse:TaskDelete. The row leaves the projection; the
|
|
154
|
+
// list empties, which is the half a create-only projection can never do.
|
|
155
|
+
const deleted = await hook({
|
|
156
|
+
tool_name: "TaskDelete",
|
|
157
|
+
hook_event_name: "PostToolUse",
|
|
158
|
+
tool_input: { taskId: "42" },
|
|
159
|
+
});
|
|
160
|
+
expect(deleted).toEqual({ applied: true, op: "delete", taskId: "42" });
|
|
161
|
+
expect(mocks.deleteMany).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(store).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("stamps the mark on a title-matched update so a later rename is mark-addressable", async () => {
|
|
166
|
+
// A row written with NO mark (pre-mark history, or a healed row) is only
|
|
167
|
+
// reachable by title. The first title-match must stamp the mark so the row
|
|
168
|
+
// never orphans on a later rename (the 2026-07-28 stranded-subtask class).
|
|
169
|
+
const { hook, store } = makeFixture();
|
|
170
|
+
store.push({
|
|
171
|
+
id: "legacy-1",
|
|
172
|
+
parent_id: "task-1",
|
|
173
|
+
title: "Old title",
|
|
174
|
+
status: "todo",
|
|
175
|
+
completed: false,
|
|
176
|
+
kind_data: {},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await hook({
|
|
180
|
+
tool_name: "TaskUpdate",
|
|
181
|
+
hook_event_name: "PostToolUse",
|
|
182
|
+
tool_input: { taskId: "99", subject: "Old title", status: "in_progress" },
|
|
183
|
+
});
|
|
184
|
+
expect((store[0].kind_data as { task_sync: { mark: string } }).task_sync.mark).toBe("99");
|
|
185
|
+
expect(store[0].status).toBe("in-progress");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("is idempotent on a retried TaskCreate (no duplicate subtask)", async () => {
|
|
189
|
+
// The hook curls with --retry, so one TaskCreate can arrive twice. A
|
|
190
|
+
// projection that duplicates on retry is not a projection.
|
|
191
|
+
const { hook, store } = makeFixture();
|
|
192
|
+
const evt = {
|
|
193
|
+
tool_name: "TaskCreate",
|
|
194
|
+
hook_event_name: "PostToolUse",
|
|
195
|
+
tool_input: { subject: "Once only" },
|
|
196
|
+
tool_response: { task: { id: "7" } },
|
|
197
|
+
};
|
|
198
|
+
await hook(evt);
|
|
199
|
+
const second = await hook(evt);
|
|
200
|
+
// The retried create matches the stored mark and is discarded, not doubled.
|
|
201
|
+
expect(second.applied).toBe(false);
|
|
202
|
+
expect(store.filter((s) => s.title === "Once only")).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -375,3 +375,28 @@ export declare const taskSyncRouter: {
|
|
|
375
375
|
notified: boolean;
|
|
376
376
|
}>, Record<never, never>, Record<never, never>>;
|
|
377
377
|
};
|
|
378
|
+
/**
|
|
379
|
+
* The Claude Code per-item task lifecycle applied to a lane's work_items
|
|
380
|
+
* subtasks — the AGENT→DB leg for the Task* tool family (TaskCreate /
|
|
381
|
+
* TaskUpdate / TaskDelete) and the dedicated TaskCreated / TaskCompleted events.
|
|
382
|
+
*
|
|
383
|
+
* Extracted verbatim from the upsertFromTaskHook ORPC handler so the FULL
|
|
384
|
+
* lifecycle — created, in_progress, completed, deleted — is unit-testable with a
|
|
385
|
+
* mock prisma (same testability split as claim-orchestrator-session, and the
|
|
386
|
+
* router-handlers-only-execute half of planReconcile). The ORPC handler is now a
|
|
387
|
+
* thin `authed` wrapper that delegates here; this function takes the exact
|
|
388
|
+
* `(input, context)` the handler would have bound, so behaviour is unchanged.
|
|
389
|
+
*
|
|
390
|
+
* `task-projection-converges`: every state a task reaches must write through, or
|
|
391
|
+
* the projection is an append-only log and the operator cannot see what a lane is
|
|
392
|
+
* doing. The matcher covering TaskCreate|TaskUpdate|TaskDelete is what makes all
|
|
393
|
+
* four states reach this handler; this body is what applies them.
|
|
394
|
+
*/
|
|
395
|
+
export declare function upsertFromTaskHookHandler(input: unknown, context: Context): Promise<{
|
|
396
|
+
applied: boolean;
|
|
397
|
+
op: string;
|
|
398
|
+
taskId: string;
|
|
399
|
+
} | ({
|
|
400
|
+
applied: false;
|
|
401
|
+
reason: string;
|
|
402
|
+
} & Record<string, unknown>)>;
|
|
@@ -721,265 +721,8 @@ export const taskSyncRouter = {
|
|
|
721
721
|
* less: fn.taskSync is page-gate-exempt; localhost pane shell authenticates
|
|
722
722
|
* as super-admin via resolveUser. Soft-returns on any no-op (never throws).
|
|
723
723
|
*/
|
|
724
|
-
upsertFromTaskHook: authed.handler(async ({ input, context }) =>
|
|
725
|
-
|
|
726
|
-
tool_name?: string;
|
|
727
|
-
tool_input?: Record<string, unknown>;
|
|
728
|
-
tool_response?: Record<string, unknown>;
|
|
729
|
-
json?: { tool_name?: string; tool_input?: Record<string, unknown>; tool_response?: Record<string, unknown> };
|
|
730
|
-
};
|
|
731
|
-
const evt = raw.tool_name ? raw : (raw.json ?? raw);
|
|
732
|
-
const toolName = typeof evt.tool_name === "string" ? evt.tool_name : "";
|
|
733
|
-
const toolInput = (evt.tool_input ?? {}) as Record<string, unknown>;
|
|
734
|
-
const toolResponse = (evt.tool_response ?? {}) as Record<string, unknown>;
|
|
735
|
-
const headerSession = (context as Context).headers?.get("x-lane-session") ?? null;
|
|
736
|
-
const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
|
|
737
|
-
const prisma = (context as Context).prisma as Prisma;
|
|
738
|
-
// TaskCreated/TaskCompleted event payloads do NOT carry session_id (only
|
|
739
|
-
// SessionStart does), so the task hooks cannot populate x-lane-session —
|
|
740
|
-
// the extraction in the hook command yields an empty string. Resolve the
|
|
741
|
-
// session from the lane's run row by pane instead: the bind (at
|
|
742
|
-
// session_start) already stamped kind_data.lane.session, and the run row's
|
|
743
|
-
// opencode_session_id is the same CLI-native id. This keeps matching
|
|
744
|
-
// session-keyed (resolveLaneTask still filters kind_data.lane.session)
|
|
745
|
-
// without requiring every task-event payload to carry the session id.
|
|
746
|
-
const session = headerSession || (headerPane ? await resolveSessionFromPane(prisma, headerPane) : null);
|
|
747
|
-
if (!session) return discardApplied("no-session", null);
|
|
748
|
-
const log = getAppLogger();
|
|
749
|
-
// Claude Code fires the dedicated `TaskCreated` EVENT for the Task* tools,
|
|
750
|
-
// NOT PostToolUse — a PostToolUse:TaskCreate matcher never fired (the prior
|
|
751
|
-
// hook shape silently produced zero subtasks, so Claude lanes showed an
|
|
752
|
-
// empty task panel). The TaskCreated payload carries task_subject + task_id
|
|
753
|
-
// on the event object (evt — already unwrapped from the {json:...} wire
|
|
754
|
-
// envelope by the raw.json ?? raw fallback above). Handle it before the
|
|
755
|
-
// toolName guard so the dedicated event path creates the subtask.
|
|
756
|
-
const createdEvt = evt as {
|
|
757
|
-
task_subject?: unknown;
|
|
758
|
-
task_id?: unknown;
|
|
759
|
-
hook_event_name?: unknown;
|
|
760
|
-
// Created-already-done must not project OPEN. Absent -> old behaviour.
|
|
761
|
-
status?: unknown;
|
|
762
|
-
};
|
|
763
|
-
// TaskCompleted BEFORE the TaskCreated branch. Both dedicated events carry
|
|
764
|
-
// `task_subject`, so the subject-presence test below cannot tell them apart
|
|
765
|
-
// — an unguarded TaskCompleted would fall into the create path and mint a
|
|
766
|
-
// DUPLICATE subtask instead of completing the existing one, which reads as
|
|
767
|
-
// "the list only ever grows and nothing is ever done"
|
|
768
|
-
// (`task-projection-converges`). `hook_event_name` is a common input field
|
|
769
|
-
// on every hook event, so it is the reliable discriminator.
|
|
770
|
-
const leg = classifyTaskHookEvent(createdEvt);
|
|
771
|
-
if (leg === "completed") {
|
|
772
|
-
const task3 = await resolveLaneTask(prisma, session);
|
|
773
|
-
if (!task3) return discardApplied("no-task-bound", session, { source: "TaskCompleted" });
|
|
774
|
-
const completedId = String(createdEvt.task_id ?? "");
|
|
775
|
-
const subject =
|
|
776
|
-
typeof createdEvt.task_subject === "string"
|
|
777
|
-
? sanitizeTaskSubject(createdEvt.task_subject)
|
|
778
|
-
: "";
|
|
779
|
-
// Match on the mark stamped at creation; fall back to the title so a
|
|
780
|
-
// subtask created before marks were recorded still closes.
|
|
781
|
-
const siblings = await prisma.work_items.findMany({
|
|
782
|
-
where: { parent_id: task3.id, kind: "subtask" },
|
|
783
|
-
});
|
|
784
|
-
const target =
|
|
785
|
-
siblings.find(
|
|
786
|
-
(s) =>
|
|
787
|
-
completedId &&
|
|
788
|
-
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark ===
|
|
789
|
-
completedId,
|
|
790
|
-
) ?? siblings.find((s) => subject && s.title === subject);
|
|
791
|
-
if (!target) {
|
|
792
|
-
return discardApplied("no-matching-subtask", session, {
|
|
793
|
-
source: "TaskCompleted",
|
|
794
|
-
taskId: completedId,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
await prisma.work_items.update({
|
|
798
|
-
where: { id: target.id },
|
|
799
|
-
data: { status: "done", completed: true },
|
|
800
|
-
});
|
|
801
|
-
log.info("[task-sync] TaskCompleted: closed subtask", {
|
|
802
|
-
session,
|
|
803
|
-
taskId: completedId,
|
|
804
|
-
subject,
|
|
805
|
-
});
|
|
806
|
-
return { applied: true, op: "complete", taskId: completedId };
|
|
807
|
-
}
|
|
808
|
-
if (leg === "created") {
|
|
809
|
-
// Sanitized on BOTH legs: the completion leg matches by title, so a
|
|
810
|
-
// raw subject here and a sanitized one at creation would never match.
|
|
811
|
-
const subject = sanitizeTaskSubject(String(createdEvt.task_subject ?? ""));
|
|
812
|
-
if (!subject) return discardApplied("no-subject", session, { source: "TaskCreated" });
|
|
813
|
-
// Bind on demand rather than discard: a lane must always be able to
|
|
814
|
-
// record the task it was just told to create.
|
|
815
|
-
const task2 = await ensureLaneTask(prisma, session, headerPane);
|
|
816
|
-
if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
|
|
817
|
-
const createdId = String(createdEvt.task_id ?? "");
|
|
818
|
-
const createdStatus =
|
|
819
|
-
STATUS_TO_WORK_ITEM[createdEvt.status as TaskListItem["status"]] ?? "todo";
|
|
820
|
-
const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
|
|
821
|
-
// IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
|
|
822
|
-
// --retry-connrefused`, so one TaskCreate can arrive twice (observed
|
|
823
|
-
// 2026-07-25: four tasks each duplicated ~4s apart), and this path used
|
|
824
|
-
// `existing` only to compute a position — never to check whether the
|
|
825
|
-
// subtask was already written. A projection that duplicates on retry is
|
|
826
|
-
// not a projection (task-projection-converges); the operator sees a list
|
|
827
|
-
// that only grows. Match on the stored mark, falling back to title for
|
|
828
|
-
// rows written before marks were recorded.
|
|
829
|
-
const already = existing.find((e) => {
|
|
830
|
-
const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
831
|
-
return createdId && mark ? mark === createdId : e.title === subject;
|
|
832
|
-
});
|
|
833
|
-
if (already) {
|
|
834
|
-
return discardApplied("duplicate-create", session, {
|
|
835
|
-
source: "TaskCreated",
|
|
836
|
-
taskId: createdId,
|
|
837
|
-
subtaskId: already.id,
|
|
838
|
-
});
|
|
839
|
-
}
|
|
840
|
-
try {
|
|
841
|
-
await prisma.work_items.create({
|
|
842
|
-
data: {
|
|
843
|
-
kind: "subtask",
|
|
844
|
-
parent_id: task2.id,
|
|
845
|
-
organisation_id: task2.organisation_id,
|
|
846
|
-
title: subject,
|
|
847
|
-
status: createdStatus,
|
|
848
|
-
completed: createdStatus === "done",
|
|
849
|
-
position: String(existing.length),
|
|
850
|
-
kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
|
|
851
|
-
},
|
|
852
|
-
});
|
|
853
|
-
} catch (err) {
|
|
854
|
-
// P2002 = unique violation on work_items_subtask_task_sync_mark_uniq.
|
|
855
|
-
// The `already` check above is a READ-THEN-WRITE and therefore loses
|
|
856
|
-
// a race by construction: two retries of one TaskCreate both read
|
|
857
|
-
// `existing` before either writes, both see nothing, and both insert.
|
|
858
|
-
// That is why cleaning duplicates by hand kept "fixing" a class that
|
|
859
|
-
// came straight back (31 rows cleaned earlier on 2026-07-25, 13 more
|
|
860
|
-
// by that evening). The index is what makes the second insert
|
|
861
|
-
// impossible; this catch turns losing the race into the CORRECT
|
|
862
|
-
// idempotent outcome instead of a 500 back to the hook.
|
|
863
|
-
if ((err as { code?: string })?.code !== "P2002") throw err;
|
|
864
|
-
return discardApplied("duplicate-create-race", session, {
|
|
865
|
-
source: "TaskCreated",
|
|
866
|
-
taskId: createdId,
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
log.info("[task-sync] TaskCreated: created subtask", { session, taskId: createdId, subject });
|
|
870
|
-
return { applied: true, op: "create", taskId: createdId };
|
|
871
|
-
}
|
|
872
|
-
if (toolName !== "TaskCreate" && toolName !== "TaskUpdate" && toolName !== "TaskDelete") {
|
|
873
|
-
return discardApplied("unhandled-tool", session, { toolName });
|
|
874
|
-
}
|
|
875
|
-
const task = await ensureLaneTask(prisma, session, headerPane);
|
|
876
|
-
if (!task) return discardApplied("no-task-bound", session, { toolName });
|
|
877
|
-
|
|
878
|
-
if (toolName === "TaskCreate") {
|
|
879
|
-
const subject = typeof toolInput.subject === "string" ? toolInput.subject.trim() : "";
|
|
880
|
-
if (!subject) return discardApplied("no-subject", session, { toolName });
|
|
881
|
-
const taskResp = toolResponse.task as Record<string, unknown> | undefined;
|
|
882
|
-
const taskId = String(toolResponse.id ?? toolResponse.taskId ?? taskResp?.id ?? toolInput.taskId ?? "");
|
|
883
|
-
const existing = await prisma.work_items.findMany({ where: { parent_id: task.id, kind: "subtask" } });
|
|
884
|
-
await prisma.work_items.create({
|
|
885
|
-
data: {
|
|
886
|
-
kind: "subtask",
|
|
887
|
-
parent_id: task.id,
|
|
888
|
-
organisation_id: task.organisation_id,
|
|
889
|
-
title: subject,
|
|
890
|
-
status: "todo",
|
|
891
|
-
completed: false,
|
|
892
|
-
position: String(existing.length),
|
|
893
|
-
kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
|
|
894
|
-
},
|
|
895
|
-
});
|
|
896
|
-
log.info("[task-sync] upsertFromTaskHook: created subtask", { session, taskId, subject });
|
|
897
|
-
return { applied: true, op: "create", taskId };
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
const taskId = String(toolInput.taskId ?? toolInput.id ?? "");
|
|
901
|
-
if (!taskId) return discardApplied("no-taskId", session, { toolName });
|
|
902
|
-
// The handler only EXECUTES; the matching rule lives in the pure
|
|
903
|
-
// resolveTaskUpdateTarget so it is unit-testable (same split as planReconcile).
|
|
904
|
-
const siblings = await prisma.work_items.findMany({
|
|
905
|
-
where: { parent_id: task.id, kind: "subtask" },
|
|
906
|
-
});
|
|
907
|
-
const subjectRaw = typeof toolInput.subject === "string" ? toolInput.subject : undefined;
|
|
908
|
-
const decision = resolveTaskUpdateTarget(siblings, { taskId, subject: subjectRaw });
|
|
909
|
-
if (decision.op === "heal" && toolName === "TaskUpdate") {
|
|
910
|
-
await prisma.work_items.create({
|
|
911
|
-
data: {
|
|
912
|
-
kind: "subtask",
|
|
913
|
-
parent_id: task.id,
|
|
914
|
-
organisation_id: task.organisation_id,
|
|
915
|
-
title: decision.title,
|
|
916
|
-
status: STATUS_TO_WORK_ITEM[toolInput.status as TaskListItem["status"]] ?? "todo",
|
|
917
|
-
completed: toolInput.status === "completed",
|
|
918
|
-
position: String(siblings.length),
|
|
919
|
-
kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
|
|
920
|
-
},
|
|
921
|
-
});
|
|
922
|
-
log.warn("[task-sync] TaskUpdate healed a missing subtask", {
|
|
923
|
-
session,
|
|
924
|
-
taskId,
|
|
925
|
-
subject: decision.title,
|
|
926
|
-
});
|
|
927
|
-
return { applied: true, op: "create", taskId };
|
|
928
|
-
}
|
|
929
|
-
const subtask = decision.op === "update" ? siblings.find((s) => s.id === decision.id) : null;
|
|
930
|
-
if (!subtask) return discardApplied("no-matching-subtask", session, { taskId });
|
|
931
|
-
if (toolName === "TaskDelete") {
|
|
932
|
-
await prisma.work_items.deleteMany({ where: { id: [subtask.id] } });
|
|
933
|
-
return { applied: true, op: "delete", taskId };
|
|
934
|
-
}
|
|
935
|
-
// TaskUpdate
|
|
936
|
-
const data: {
|
|
937
|
-
status?: string;
|
|
938
|
-
completed?: boolean;
|
|
939
|
-
title?: string;
|
|
940
|
-
kind_data?: Record<string, unknown>;
|
|
941
|
-
} = {};
|
|
942
|
-
const statusRaw = typeof toolInput.status === "string" ? toolInput.status : "";
|
|
943
|
-
if (statusRaw) {
|
|
944
|
-
data.status = STATUS_TO_WORK_ITEM[statusRaw as TaskListItem["status"]] ?? "todo";
|
|
945
|
-
data.completed = statusRaw === "completed";
|
|
946
|
-
}
|
|
947
|
-
if (typeof toolInput.subject === "string") data.title = toolInput.subject;
|
|
948
|
-
// STAMP THE MARK WHEN A ROW IS MATCHED BY TITLE.
|
|
949
|
-
//
|
|
950
|
-
// Matching is mark-first, then EXACT TITLE. A row that has never been
|
|
951
|
-
// stamped can therefore only ever be found by its title — so the moment a
|
|
952
|
-
// task is RENAMED, neither match succeeds, the event heals into a NEW row,
|
|
953
|
-
// and the pre-rename row is orphaned at whatever status it held. Forever:
|
|
954
|
-
// nothing else in the single-event path will ever look at it again.
|
|
955
|
-
//
|
|
956
|
-
// Observed 2026-07-28 on this lane — three subtasks stranded at `todo` and
|
|
957
|
-
// `in-progress` under their OLD titles while the CLI's own list read empty,
|
|
958
|
-
// which is what made the stop gate refuse a lane that had genuinely
|
|
959
|
-
// finished. `task-projection-converges` requires the projection to express
|
|
960
|
-
// every state a task reaches; a row it can no longer address expresses
|
|
961
|
-
// nothing.
|
|
962
|
-
//
|
|
963
|
-
// Stamping here closes the class rather than the instance: the FIRST
|
|
964
|
-
// title-match a row ever gets makes it mark-addressable, so every later
|
|
965
|
-
// rename updates it in place. The heal path already stamps on create; this
|
|
966
|
-
// is the half that was missing, and without it an unmarked row stays
|
|
967
|
-
// unmarked no matter how many times it is touched.
|
|
968
|
-
const alreadyMarked =
|
|
969
|
-
(subtask.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
970
|
-
if (taskId && !alreadyMarked) {
|
|
971
|
-
const priorKindData = (subtask.kind_data ?? {}) as Record<string, unknown>;
|
|
972
|
-
const priorSync = (priorKindData.task_sync ?? {}) as Record<string, unknown>;
|
|
973
|
-
data.kind_data = {
|
|
974
|
-
...priorKindData,
|
|
975
|
-
task_sync: { ...priorSync, mark: taskId, source: "claude-code" },
|
|
976
|
-
};
|
|
977
|
-
}
|
|
978
|
-
if (Object.keys(data).length === 0) return discardApplied("no-change", session, { taskId });
|
|
979
|
-
await prisma.work_items.update({ where: { id: subtask.id }, data });
|
|
980
|
-
log.info("[task-sync] upsertFromTaskHook: updated subtask", { session, taskId, ...data });
|
|
981
|
-
return { applied: true, op: "update", taskId };
|
|
982
|
-
}),
|
|
724
|
+
upsertFromTaskHook: authed.handler(async ({ input, context }) =>
|
|
725
|
+
upsertFromTaskHookHandler(input, context)),
|
|
983
726
|
|
|
984
727
|
/**
|
|
985
728
|
* DB→AGENT backstop read. Returns the lane's current subtasks in the
|
|
@@ -1073,3 +816,282 @@ export const taskSyncRouter = {
|
|
|
1073
816
|
return { task_id, lane_pane: lanePane ?? null, notified: !!lanePane };
|
|
1074
817
|
}),
|
|
1075
818
|
};
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The Claude Code per-item task lifecycle applied to a lane's work_items
|
|
822
|
+
* subtasks — the AGENT→DB leg for the Task* tool family (TaskCreate /
|
|
823
|
+
* TaskUpdate / TaskDelete) and the dedicated TaskCreated / TaskCompleted events.
|
|
824
|
+
*
|
|
825
|
+
* Extracted verbatim from the upsertFromTaskHook ORPC handler so the FULL
|
|
826
|
+
* lifecycle — created, in_progress, completed, deleted — is unit-testable with a
|
|
827
|
+
* mock prisma (same testability split as claim-orchestrator-session, and the
|
|
828
|
+
* router-handlers-only-execute half of planReconcile). The ORPC handler is now a
|
|
829
|
+
* thin `authed` wrapper that delegates here; this function takes the exact
|
|
830
|
+
* `(input, context)` the handler would have bound, so behaviour is unchanged.
|
|
831
|
+
*
|
|
832
|
+
* `task-projection-converges`: every state a task reaches must write through, or
|
|
833
|
+
* the projection is an append-only log and the operator cannot see what a lane is
|
|
834
|
+
* doing. The matcher covering TaskCreate|TaskUpdate|TaskDelete is what makes all
|
|
835
|
+
* four states reach this handler; this body is what applies them.
|
|
836
|
+
*/
|
|
837
|
+
export async function upsertFromTaskHookHandler(input: unknown, context: Context) {
|
|
838
|
+
const raw = (input ?? {}) as {
|
|
839
|
+
tool_name?: string;
|
|
840
|
+
tool_input?: Record<string, unknown>;
|
|
841
|
+
tool_response?: Record<string, unknown>;
|
|
842
|
+
json?: { tool_name?: string; tool_input?: Record<string, unknown>; tool_response?: Record<string, unknown> };
|
|
843
|
+
};
|
|
844
|
+
const evt = raw.tool_name ? raw : (raw.json ?? raw);
|
|
845
|
+
const toolName = typeof evt.tool_name === "string" ? evt.tool_name : "";
|
|
846
|
+
const toolInput = (evt.tool_input ?? {}) as Record<string, unknown>;
|
|
847
|
+
const toolResponse = (evt.tool_response ?? {}) as Record<string, unknown>;
|
|
848
|
+
const headerSession = (context as Context).headers?.get("x-lane-session") ?? null;
|
|
849
|
+
const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
|
|
850
|
+
const prisma = (context as Context).prisma as Prisma;
|
|
851
|
+
// TaskCreated/TaskCompleted event payloads do NOT carry session_id (only
|
|
852
|
+
// SessionStart does), so the task hooks cannot populate x-lane-session —
|
|
853
|
+
// the extraction in the hook command yields an empty string. Resolve the
|
|
854
|
+
// session from the lane's run row by pane instead: the bind (at
|
|
855
|
+
// session_start) already stamped kind_data.lane.session, and the run row's
|
|
856
|
+
// opencode_session_id is the same CLI-native id. This keeps matching
|
|
857
|
+
// session-keyed (resolveLaneTask still filters kind_data.lane.session)
|
|
858
|
+
// without requiring every task-event payload to carry the session id.
|
|
859
|
+
const session = headerSession || (headerPane ? await resolveSessionFromPane(prisma, headerPane) : null);
|
|
860
|
+
if (!session) return discardApplied("no-session", null);
|
|
861
|
+
const log = getAppLogger();
|
|
862
|
+
// PostToolUse is what Claude Code fires for the Task* tools (verified live:
|
|
863
|
+
// subtasks are created from PostToolUse:TaskCreate events). The dedicated
|
|
864
|
+
// TaskCreated/TaskCompleted EVENT legs below are kept as a defensive path —
|
|
865
|
+
// classifyTaskHookEvent discriminates them by hook_event_name — but the live
|
|
866
|
+
// matcher is PostToolUse:TaskCreate|TaskUpdate|TaskDelete, which lands in the
|
|
867
|
+
// `tool` leg. Handle the dedicated events first so a TaskCompleted cannot fall
|
|
868
|
+
// through to create a duplicate (task-projection-converges).
|
|
869
|
+
const createdEvt = evt as {
|
|
870
|
+
task_subject?: unknown;
|
|
871
|
+
task_id?: unknown;
|
|
872
|
+
hook_event_name?: unknown;
|
|
873
|
+
// Created-already-done must not project OPEN. Absent -> old behaviour.
|
|
874
|
+
status?: unknown;
|
|
875
|
+
};
|
|
876
|
+
const leg = classifyTaskHookEvent(createdEvt);
|
|
877
|
+
if (leg === "completed") {
|
|
878
|
+
const task3 = await resolveLaneTask(prisma, session);
|
|
879
|
+
if (!task3) return discardApplied("no-task-bound", session, { source: "TaskCompleted" });
|
|
880
|
+
const completedId = String(createdEvt.task_id ?? "");
|
|
881
|
+
const subject =
|
|
882
|
+
typeof createdEvt.task_subject === "string"
|
|
883
|
+
? sanitizeTaskSubject(createdEvt.task_subject)
|
|
884
|
+
: "";
|
|
885
|
+
// Match on the mark stamped at creation; fall back to the title so a
|
|
886
|
+
// subtask created before marks were recorded still closes.
|
|
887
|
+
const siblings = await prisma.work_items.findMany({
|
|
888
|
+
where: { parent_id: task3.id, kind: "subtask" },
|
|
889
|
+
});
|
|
890
|
+
const target =
|
|
891
|
+
siblings.find(
|
|
892
|
+
(s) =>
|
|
893
|
+
completedId &&
|
|
894
|
+
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark ===
|
|
895
|
+
completedId,
|
|
896
|
+
) ?? siblings.find((s) => subject && s.title === subject);
|
|
897
|
+
if (!target) {
|
|
898
|
+
return discardApplied("no-matching-subtask", session, {
|
|
899
|
+
source: "TaskCompleted",
|
|
900
|
+
taskId: completedId,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
await prisma.work_items.update({
|
|
904
|
+
where: { id: target.id },
|
|
905
|
+
data: { status: "done", completed: true },
|
|
906
|
+
});
|
|
907
|
+
log.info("[task-sync] TaskCompleted: closed subtask", {
|
|
908
|
+
session,
|
|
909
|
+
taskId: completedId,
|
|
910
|
+
subject,
|
|
911
|
+
});
|
|
912
|
+
return { applied: true, op: "complete", taskId: completedId };
|
|
913
|
+
}
|
|
914
|
+
if (leg === "created") {
|
|
915
|
+
// Sanitized on BOTH legs: the completion leg matches by title, so a
|
|
916
|
+
// raw subject here and a sanitized one at creation would never match.
|
|
917
|
+
const subject = sanitizeTaskSubject(String(createdEvt.task_subject ?? ""));
|
|
918
|
+
if (!subject) return discardApplied("no-subject", session, { source: "TaskCreated" });
|
|
919
|
+
// Bind on demand rather than discard: a lane must always be able to
|
|
920
|
+
// record the task it was just told to create.
|
|
921
|
+
const task2 = await ensureLaneTask(prisma, session, headerPane);
|
|
922
|
+
if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
|
|
923
|
+
const createdId = String(createdEvt.task_id ?? "");
|
|
924
|
+
const createdStatus =
|
|
925
|
+
STATUS_TO_WORK_ITEM[createdEvt.status as TaskListItem["status"]] ?? "todo";
|
|
926
|
+
const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
|
|
927
|
+
// IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
|
|
928
|
+
// --retry-connrefused`, so one TaskCreate can arrive twice (observed
|
|
929
|
+
// 2026-07-25: four tasks each duplicated ~4s apart), and this path used
|
|
930
|
+
// `existing` only to compute a position — never to check whether the
|
|
931
|
+
// subtask was already written. A projection that duplicates on retry is
|
|
932
|
+
// not a projection (task-projection-converges); the operator sees a list
|
|
933
|
+
// that only grows. Match on the stored mark, falling back to title for
|
|
934
|
+
// rows written before marks were recorded.
|
|
935
|
+
const already = existing.find((e) => {
|
|
936
|
+
const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
937
|
+
return createdId && mark ? mark === createdId : e.title === subject;
|
|
938
|
+
});
|
|
939
|
+
if (already) {
|
|
940
|
+
return discardApplied("duplicate-create", session, {
|
|
941
|
+
source: "TaskCreated",
|
|
942
|
+
taskId: createdId,
|
|
943
|
+
subtaskId: already.id,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
await prisma.work_items.create({
|
|
948
|
+
data: {
|
|
949
|
+
kind: "subtask",
|
|
950
|
+
parent_id: task2.id,
|
|
951
|
+
organisation_id: task2.organisation_id,
|
|
952
|
+
title: subject,
|
|
953
|
+
status: createdStatus,
|
|
954
|
+
completed: createdStatus === "done",
|
|
955
|
+
position: String(existing.length),
|
|
956
|
+
kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
} catch (err) {
|
|
960
|
+
// P2002 = unique violation on work_items_subtask_task_sync_mark_uniq.
|
|
961
|
+
// The `already` check above is a READ-THEN-WRITE and therefore loses
|
|
962
|
+
// a race by construction: two retries of one TaskCreate both read
|
|
963
|
+
// `existing` before either writes, both see nothing, and both insert.
|
|
964
|
+
// That is why cleaning duplicates by hand kept "fixing" a class that
|
|
965
|
+
// came straight back (31 rows cleaned earlier on 2026-07-25, 13 more
|
|
966
|
+
// by that evening). The index is what makes the second insert
|
|
967
|
+
// impossible; this catch turns losing the race into the CORRECT
|
|
968
|
+
// idempotent outcome instead of a 500 back to the hook.
|
|
969
|
+
if ((err as { code?: string })?.code !== "P2002") throw err;
|
|
970
|
+
return discardApplied("duplicate-create-race", session, {
|
|
971
|
+
source: "TaskCreated",
|
|
972
|
+
taskId: createdId,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
log.info("[task-sync] TaskCreated: created subtask", { session, taskId: createdId, subject });
|
|
976
|
+
return { applied: true, op: "create", taskId: createdId };
|
|
977
|
+
}
|
|
978
|
+
if (toolName !== "TaskCreate" && toolName !== "TaskUpdate" && toolName !== "TaskDelete") {
|
|
979
|
+
return discardApplied("unhandled-tool", session, { toolName });
|
|
980
|
+
}
|
|
981
|
+
const task = await ensureLaneTask(prisma, session, headerPane);
|
|
982
|
+
if (!task) return discardApplied("no-task-bound", session, { toolName });
|
|
983
|
+
|
|
984
|
+
if (toolName === "TaskCreate") {
|
|
985
|
+
const subject = typeof toolInput.subject === "string" ? toolInput.subject.trim() : "";
|
|
986
|
+
if (!subject) return discardApplied("no-subject", session, { toolName });
|
|
987
|
+
const taskResp = toolResponse.task as Record<string, unknown> | undefined;
|
|
988
|
+
const taskId = String(toolResponse.id ?? toolResponse.taskId ?? taskResp?.id ?? toolInput.taskId ?? "");
|
|
989
|
+
const existing = await prisma.work_items.findMany({ where: { parent_id: task.id, kind: "subtask" } });
|
|
990
|
+
// IDEMPOTENT on the CLI's own task id. The hook curls with --retry, so one
|
|
991
|
+
// TaskCreate can arrive twice, and this (the LIVE PostToolUse path) used to
|
|
992
|
+
// fetch `existing` only for position — never to check whether the subtask
|
|
993
|
+
// was already written — so every retry minted a duplicate
|
|
994
|
+
// (task-projection-converges: a projection that duplicates is not a
|
|
995
|
+
// projection). Same mark-then-title match as the TaskCreated event leg.
|
|
996
|
+
const already = existing.find((e) => {
|
|
997
|
+
const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
998
|
+
return taskId && mark ? mark === taskId : e.title === subject;
|
|
999
|
+
});
|
|
1000
|
+
if (already) {
|
|
1001
|
+
return discardApplied("duplicate-create", session, { toolName, taskId, subtaskId: already.id });
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
await prisma.work_items.create({
|
|
1005
|
+
data: {
|
|
1006
|
+
kind: "subtask",
|
|
1007
|
+
parent_id: task.id,
|
|
1008
|
+
organisation_id: task.organisation_id,
|
|
1009
|
+
title: subject,
|
|
1010
|
+
status: "todo",
|
|
1011
|
+
completed: false,
|
|
1012
|
+
position: String(existing.length),
|
|
1013
|
+
kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
// P2002 = unique violation on work_items_subtask_task_sync_mark_uniq
|
|
1018
|
+
// (read-then-write race: two retries both read `existing` before either
|
|
1019
|
+
// writes). Losing the race is the correct idempotent outcome, not a 500.
|
|
1020
|
+
if ((err as { code?: string })?.code !== "P2002") throw err;
|
|
1021
|
+
return discardApplied("duplicate-create-race", session, { toolName, taskId });
|
|
1022
|
+
}
|
|
1023
|
+
log.info("[task-sync] upsertFromTaskHook: created subtask", { session, taskId, subject });
|
|
1024
|
+
return { applied: true, op: "create", taskId };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const taskId = String(toolInput.taskId ?? toolInput.id ?? "");
|
|
1028
|
+
if (!taskId) return discardApplied("no-taskId", session, { toolName });
|
|
1029
|
+
// The handler only EXECUTES; the matching rule lives in the pure
|
|
1030
|
+
// resolveTaskUpdateTarget so it is unit-testable (same split as planReconcile).
|
|
1031
|
+
const siblings = await prisma.work_items.findMany({
|
|
1032
|
+
where: { parent_id: task.id, kind: "subtask" },
|
|
1033
|
+
});
|
|
1034
|
+
const subjectRaw = typeof toolInput.subject === "string" ? toolInput.subject : undefined;
|
|
1035
|
+
const decision = resolveTaskUpdateTarget(siblings, { taskId, subject: subjectRaw });
|
|
1036
|
+
if (decision.op === "heal" && toolName === "TaskUpdate") {
|
|
1037
|
+
await prisma.work_items.create({
|
|
1038
|
+
data: {
|
|
1039
|
+
kind: "subtask",
|
|
1040
|
+
parent_id: task.id,
|
|
1041
|
+
organisation_id: task.organisation_id,
|
|
1042
|
+
title: decision.title,
|
|
1043
|
+
status: STATUS_TO_WORK_ITEM[toolInput.status as TaskListItem["status"]] ?? "todo",
|
|
1044
|
+
completed: toolInput.status === "completed",
|
|
1045
|
+
position: String(siblings.length),
|
|
1046
|
+
kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
log.warn("[task-sync] TaskUpdate healed a missing subtask", {
|
|
1050
|
+
session,
|
|
1051
|
+
taskId,
|
|
1052
|
+
subject: decision.title,
|
|
1053
|
+
});
|
|
1054
|
+
return { applied: true, op: "create", taskId };
|
|
1055
|
+
}
|
|
1056
|
+
const subtask = decision.op === "update" ? siblings.find((s) => s.id === decision.id) : null;
|
|
1057
|
+
if (!subtask) return discardApplied("no-matching-subtask", session, { taskId });
|
|
1058
|
+
if (toolName === "TaskDelete") {
|
|
1059
|
+
// Canonical Prisma form (the same as reconcileTaskList's deleteMany):
|
|
1060
|
+
// `id: { in: [...] }`, NOT `id: [...]` — a bare array on the scalar id is a
|
|
1061
|
+
// Prisma validation error that 500'd every TaskDelete, so deletion never
|
|
1062
|
+
// projected even once the hook was wired.
|
|
1063
|
+
await prisma.work_items.deleteMany({ where: { id: { in: [subtask.id] } } });
|
|
1064
|
+
return { applied: true, op: "delete", taskId };
|
|
1065
|
+
}
|
|
1066
|
+
// TaskUpdate
|
|
1067
|
+
const data: {
|
|
1068
|
+
status?: string;
|
|
1069
|
+
completed?: boolean;
|
|
1070
|
+
title?: string;
|
|
1071
|
+
kind_data?: Record<string, unknown>;
|
|
1072
|
+
} = {};
|
|
1073
|
+
const statusRaw = typeof toolInput.status === "string" ? toolInput.status : "";
|
|
1074
|
+
if (statusRaw) {
|
|
1075
|
+
data.status = STATUS_TO_WORK_ITEM[statusRaw as TaskListItem["status"]] ?? "todo";
|
|
1076
|
+
data.completed = statusRaw === "completed";
|
|
1077
|
+
}
|
|
1078
|
+
if (typeof toolInput.subject === "string") data.title = toolInput.subject;
|
|
1079
|
+
// STAMP THE MARK WHEN A ROW IS MATCHED BY TITLE. The first title-match a row
|
|
1080
|
+
// ever gets makes it mark-addressable, so every later rename updates it in
|
|
1081
|
+
// place (task-projection-converges — a row it can no longer address expresses
|
|
1082
|
+
// nothing). The heal path stamps on create; this is the update half.
|
|
1083
|
+
const alreadyMarked =
|
|
1084
|
+
(subtask.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
1085
|
+
if (taskId && !alreadyMarked) {
|
|
1086
|
+
const priorKindData = (subtask.kind_data ?? {}) as Record<string, unknown>;
|
|
1087
|
+
const priorSync = (priorKindData.task_sync ?? {}) as Record<string, unknown>;
|
|
1088
|
+
data.kind_data = {
|
|
1089
|
+
...priorKindData,
|
|
1090
|
+
task_sync: { ...priorSync, mark: taskId, source: "claude-code" },
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
if (Object.keys(data).length === 0) return discardApplied("no-change", session, { taskId });
|
|
1094
|
+
await prisma.work_items.update({ where: { id: subtask.id }, data });
|
|
1095
|
+
log.info("[task-sync] upsertFromTaskHook: updated subtask", { session, taskId, ...data });
|
|
1096
|
+
return { applied: true, op: "update", taskId };
|
|
1097
|
+
}
|
|
@@ -19,14 +19,20 @@ import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
|
19
19
|
import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
|
|
20
20
|
import type { Context, Hono } from "hono";
|
|
21
21
|
import { getConnInfo } from "hono/bun";
|
|
22
|
+
import { CALLER_HEADERS } from "@teamscala/os/contracts/mcp";
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// From CALLER_HEADERS, never a literal: this name is one end of a contract the
|
|
25
|
+
// gateway's caller-resolution reads, and a literal stores it twice. The
|
|
26
|
+
// 2026-08-02 outage was that exact drift on a sibling header (reader on
|
|
27
|
+
// `x-user-id`, writer on `x-caller-user-id`), and it took every tool on the
|
|
28
|
+
// cloud MCP down once the branch masking it was closed.
|
|
29
|
+
const REMOTE_PORT_HEADER = CALLER_HEADERS.REMOTE_PORT;
|
|
24
30
|
// Verified socket-peer address (unforgeable transport origin). Sanitized
|
|
25
31
|
// (client-supplied value deleted) then set from getConnInfo so handlers that
|
|
26
32
|
// receive only a Request (e.g. mcp-tool-runtime/mount.ts) can gate on the
|
|
27
33
|
// genuine transport origin per no-client-controlled-trust-signals — an external
|
|
28
34
|
// attacker cannot make their TCP connection appear on the server's loopback.
|
|
29
|
-
const REMOTE_ADDR_HEADER =
|
|
35
|
+
const REMOTE_ADDR_HEADER = CALLER_HEADERS.REMOTE_ADDRESS;
|
|
30
36
|
|
|
31
37
|
export function registerApiRoutes(
|
|
32
38
|
app: Hono,
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* identity — a clone can never upgrade).
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { CALLER_HEADERS } from "@teamscala/os/contracts/mcp";
|
|
29
30
|
import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
|
|
30
31
|
|
|
31
32
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
@@ -53,15 +54,15 @@ export function stampTransportIdentity(
|
|
|
53
54
|
const port = peer?.port;
|
|
54
55
|
const address = peer?.address ?? null;
|
|
55
56
|
const needsPort =
|
|
56
|
-
typeof port === "number" && port > 0 && !req.headers.has(
|
|
57
|
+
typeof port === "number" && port > 0 && !req.headers.has(CALLER_HEADERS.REMOTE_PORT);
|
|
57
58
|
// Nothing to stamp when there is no peer address and no port to add.
|
|
58
59
|
if (!needsPort && address === null) return req;
|
|
59
60
|
|
|
60
61
|
const headers = new Headers(req.headers);
|
|
61
|
-
if (needsPort) headers.set(
|
|
62
|
+
if (needsPort) headers.set(CALLER_HEADERS.REMOTE_PORT, String(port));
|
|
62
63
|
if (address) {
|
|
63
|
-
headers.delete(
|
|
64
|
-
headers.set(
|
|
64
|
+
headers.delete(CALLER_HEADERS.REMOTE_ADDRESS);
|
|
65
|
+
headers.set(CALLER_HEADERS.REMOTE_ADDRESS, String(address));
|
|
65
66
|
}
|
|
66
67
|
// Local-caller attribution: loopback peer + no forwarding header => a
|
|
67
68
|
// direct local fetch whose owning process we can name via /proc.
|
|
@@ -75,9 +76,9 @@ export function stampTransportIdentity(
|
|
|
75
76
|
) {
|
|
76
77
|
const owner = resolveSocketPeer(port);
|
|
77
78
|
if (owner) {
|
|
78
|
-
headers.set(
|
|
79
|
+
headers.set(CALLER_HEADERS.LOCAL_COMM, owner.comm);
|
|
79
80
|
const cmd = owner.cmdline.join(" ").trim();
|
|
80
|
-
if (cmd) headers.set(
|
|
81
|
+
if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd.slice(0, 160));
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
return new Request(req.url, {
|