@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the pure event-payload builder in task-sync-dispatch. The HTTP
|
|
7
|
+
* POST + registry resolution are side-effectful (covered end-to-end by the
|
|
8
|
+
* DB->AGENT direction demo — a published orchestrator.message with slotName
|
|
9
|
+
* cross-pane delivers to the target pane's <channel> tag); buildTaskChangedEvent
|
|
10
|
+
* is pure and tested here. Satisfies what-must-have-a-test / primitive-api-has-test.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "bun:test";
|
|
13
|
+
import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
|
|
14
|
+
|
|
15
|
+
describe("task-sync buildTaskChangedEvent", () => {
|
|
16
|
+
it("is an orchestrator.message addressed to the owning lane pane", () => {
|
|
17
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
18
|
+
expect(e.type).toBe("orchestrator.message");
|
|
19
|
+
expect(e.orchestratorSessionId).toBe("tmux:%5");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses slotName cross-pane so watchEvents bypasses the fleet-run-existence check", () => {
|
|
23
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
24
|
+
expect(e.slotName).toBe("cross-pane");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("carries the task id in the message so the recipient can re-read just it", () => {
|
|
28
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
29
|
+
expect(e.message).toContain("task-7");
|
|
30
|
+
expect(e.message).toContain("readTaskList");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("produces a single-line message (the <channel> tag renders it inline)", () => {
|
|
34
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
35
|
+
expect(e.message.includes("\n")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("stamps the system sender + a deterministic runId + the provided epoch-ms ts", () => {
|
|
39
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
40
|
+
expect(e.fromOrchestratorId).toBe("task-sync");
|
|
41
|
+
expect(e.runId).toBe("task-sync-task-7-1700000000000");
|
|
42
|
+
expect(e.ts).toBe(1700000000000);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* DB→AGENT dispatch for task-sync — notifies a lane's live pane that its
|
|
7
|
+
* work_items task list changed out-of-band (operator toggled a subtask in the
|
|
8
|
+
* projects UI, another lane edited a shared task). The pane's adapter then
|
|
9
|
+
* re-reads (fn.taskSync.readTaskList) and updates its native list.
|
|
10
|
+
*
|
|
11
|
+
* MECHANISM — reuses the existing CLI-agnostic channel delivery, no new
|
|
12
|
+
* transport: a POST to scala-channel's fleet-events publish entry
|
|
13
|
+
* (POST /api/fleet/events — @teamscala/channel/fleet-events-publish) carrying an
|
|
14
|
+
* `orchestrator.message` FleetEvent with slotName "cross-pane". That slot is
|
|
15
|
+
* the watchEvents bypass for events NOT tied to a fleet_agent_runs row
|
|
16
|
+
* (FleetEventOrchestratorMessageSchema comment) — exactly the system-originated
|
|
17
|
+
* case. scala-channel re-emits onto its eventBus and streamFleetEvents delivers
|
|
18
|
+
* to the subscriber filtered by orchestratorSessionId (the target lane pane).
|
|
19
|
+
* Both the Claude Code relay and the OpenCode scala-hooks plugin subscribe to
|
|
20
|
+
* that SSE stream for their own pane identity and inject each event natively —
|
|
21
|
+
* so ONE publish reaches every CLI that has a live projection, no per-CLI
|
|
22
|
+
* dispatch code here.
|
|
23
|
+
*
|
|
24
|
+
* NOT the dispatch entry (POST /api/channel/send): that path requires a real
|
|
25
|
+
* sender pane identity (channel-sender-identity-validated anti-spoof) and so
|
|
26
|
+
* cannot carry a system-originated notification. The publish path is
|
|
27
|
+
* internal-trusted (localhost super-admin) and accepts a synthetic
|
|
28
|
+
* fromOrchestratorId — the correct path for a system→pane message.
|
|
29
|
+
*
|
|
30
|
+
* scala-channel's address is resolved from its service_routing row (local_port)
|
|
31
|
+
* — never hardcoded (service-ports-from-registry). Fire-and-forget: a dispatch
|
|
32
|
+
* failure (scala-channel down, port unresolvable) is logged and swallowed; it
|
|
33
|
+
* must never block the work_items write that triggered it, and the lane's
|
|
34
|
+
* SessionStart/UserPromptSubmit backstop picks up the change on its next turn
|
|
35
|
+
* regardless.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
39
|
+
import { ServiceRoutingConfigSchema } from "@teamscala/db-validation/registry-schemas/service-routing";
|
|
40
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
41
|
+
|
|
42
|
+
let cachedChannelBaseUrl: string | null | undefined;
|
|
43
|
+
|
|
44
|
+
async function resolveChannelBaseUrl(): Promise<string | null> {
|
|
45
|
+
if (cachedChannelBaseUrl !== undefined) return cachedChannelBaseUrl;
|
|
46
|
+
try {
|
|
47
|
+
const row = await loadRegistryConfig("service_routing", "scala-channel", ServiceRoutingConfigSchema);
|
|
48
|
+
const port = row?.local_port;
|
|
49
|
+
cachedChannelBaseUrl = port ? `http://127.0.0.1:${port}` : null;
|
|
50
|
+
} catch {
|
|
51
|
+
cachedChannelBaseUrl = null;
|
|
52
|
+
}
|
|
53
|
+
return cachedChannelBaseUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The system sender identity stamped on fromOrchestratorId. */
|
|
57
|
+
const FROM_ORCHESTRATOR_ID = "task-sync";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The `orchestrator.message` FleetEvent payload that notifies a lane its task
|
|
61
|
+
* list changed. Pure (no I/O) so it is unit-testable; notifyLaneOfTaskChange
|
|
62
|
+
* posts it. slotName "cross-pane" is REQUIRED — it is the watchEvents bypass for
|
|
63
|
+
* events not tied to a fleet_agent_runs row. orchestratorSessionId is the TARGET
|
|
64
|
+
* lane pane (the SSE subscriber filter). The message is single-line (the
|
|
65
|
+
* <channel> tag renders it inline).
|
|
66
|
+
*/
|
|
67
|
+
export interface TaskChangedEvent {
|
|
68
|
+
type: "orchestrator.message";
|
|
69
|
+
message: string;
|
|
70
|
+
fromOrchestratorId: string;
|
|
71
|
+
runId: string;
|
|
72
|
+
slotName: "cross-pane";
|
|
73
|
+
ts: number;
|
|
74
|
+
orchestratorSessionId: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildTaskChangedEvent(lanePane: string, taskId: string, now: number = Date.now()): TaskChangedEvent {
|
|
78
|
+
return {
|
|
79
|
+
type: "orchestrator.message",
|
|
80
|
+
message: `TaskSync: your work_items task list changed externally (task ${taskId}). Re-read via fn.taskSync.readTaskList and update your todo list.`,
|
|
81
|
+
fromOrchestratorId: FROM_ORCHESTRATOR_ID,
|
|
82
|
+
runId: `task-sync-${taskId}-${now}`,
|
|
83
|
+
slotName: "cross-pane",
|
|
84
|
+
ts: now,
|
|
85
|
+
orchestratorSessionId: lanePane,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Publish a "your task list changed" notification to the owning lane's pane.
|
|
91
|
+
* The pane identity is the stable tmux id ("tmux:%42") every CLI relay/plugin
|
|
92
|
+
* receives for. `taskId` is carried in the message so the recipient can re-read
|
|
93
|
+
* just that task. Fire-and-forget.
|
|
94
|
+
*/
|
|
95
|
+
export async function notifyLaneOfTaskChange(
|
|
96
|
+
lanePane: string,
|
|
97
|
+
taskId: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const log = getAppLogger();
|
|
100
|
+
if (!lanePane) return;
|
|
101
|
+
const base = await resolveChannelBaseUrl();
|
|
102
|
+
if (!base) {
|
|
103
|
+
log.warn("[task-sync] notifyLaneOfTaskChange: scala-channel address unresolvable", { taskId });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const payload = buildTaskChangedEvent(lanePane, taskId);
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(`${base}/api/fleet/events`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify(payload),
|
|
112
|
+
signal: AbortSignal.timeout(3000),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
log.warn("[task-sync] notifyLaneOfTaskChange: non-2xx from scala-channel", {
|
|
116
|
+
taskId, status: res.status,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log.warn("[task-sync] notifyLaneOfTaskChange: POST failed", {
|
|
121
|
+
taskId, error: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the pure task-sync reconciliation logic (planReconcile +
|
|
7
|
+
* status mapping). The ORPC handlers only EXECUTE the plan against prisma; the
|
|
8
|
+
* matching / status / positioning decisions live here so they are unit-testable
|
|
9
|
+
* without a DB. Covers `what-must-have-a-test` for the public API.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
resolveTaskUpdateTarget,
|
|
14
|
+
planReconcile,
|
|
15
|
+
normalizeStatus,
|
|
16
|
+
subtaskKey,
|
|
17
|
+
STATUS_TO_WORK_ITEM,
|
|
18
|
+
type TaskListItem,
|
|
19
|
+
} from "./task-sync-router.ts";
|
|
20
|
+
|
|
21
|
+
interface WorkItemLike {
|
|
22
|
+
id: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
parent_id: string | null;
|
|
25
|
+
organisation_id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
status: string | null;
|
|
28
|
+
position: string | null;
|
|
29
|
+
completed: boolean | null;
|
|
30
|
+
kind_data: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function subtask(opts: Partial<WorkItemLike> & { id: string; title: string }): WorkItemLike {
|
|
34
|
+
return {
|
|
35
|
+
kind: "subtask",
|
|
36
|
+
parent_id: "task-1",
|
|
37
|
+
organisation_id: "org-1",
|
|
38
|
+
status: "todo",
|
|
39
|
+
position: null,
|
|
40
|
+
completed: false,
|
|
41
|
+
kind_data: {},
|
|
42
|
+
...opts,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const item = (content: string, status: TaskListItem["status"] = "pending", mark?: string): TaskListItem => ({
|
|
47
|
+
content,
|
|
48
|
+
status,
|
|
49
|
+
...(mark !== undefined ? { mark } : {}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("task-sync status mapping", () => {
|
|
53
|
+
it("maps normalized status to the work_items DB enum (hyphenated in-progress)", () => {
|
|
54
|
+
expect(STATUS_TO_WORK_ITEM.pending).toBe("todo");
|
|
55
|
+
expect(STATUS_TO_WORK_ITEM.in_progress).toBe("in-progress");
|
|
56
|
+
expect(STATUS_TO_WORK_ITEM.completed).toBe("done");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("normalizes work_items status back to the CLI form", () => {
|
|
60
|
+
expect(normalizeStatus("todo")).toBe("pending");
|
|
61
|
+
expect(normalizeStatus("in-progress")).toBe("in_progress");
|
|
62
|
+
expect(normalizeStatus("done")).toBe("completed");
|
|
63
|
+
expect(normalizeStatus("active")).toBe("in_progress");
|
|
64
|
+
expect(normalizeStatus(null)).toBe("pending");
|
|
65
|
+
expect(normalizeStatus("unknown-state")).toBe("pending");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("task-sync subtaskKey", () => {
|
|
70
|
+
it("prefers a stable mark over content when present", () => {
|
|
71
|
+
expect(subtaskKey({ mark: "m1", content: "ignored", title: "ignored" })).toBe("m1");
|
|
72
|
+
});
|
|
73
|
+
it("falls back to content when no mark", () => {
|
|
74
|
+
expect(subtaskKey({ content: "Build it", title: "Build it" })).toBe("Build it");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("task-sync planReconcile", () => {
|
|
79
|
+
it("creates every incoming item when there are no existing subtasks", () => {
|
|
80
|
+
const plan = planReconcile([], [item("A"), item("B", "in_progress")]);
|
|
81
|
+
expect(plan.toCreate).toHaveLength(2);
|
|
82
|
+
expect(plan.toCreate[0]).toMatchObject({ content: "A", status: "todo", position: "0", completed: false });
|
|
83
|
+
expect(plan.toCreate[1]).toMatchObject({ content: "B", status: "in-progress", position: "1", completed: false });
|
|
84
|
+
expect(plan.toUpdate).toHaveLength(0);
|
|
85
|
+
expect(plan.toDelete).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("emits no ops when existing already matches incoming (idempotent)", () => {
|
|
89
|
+
const existing = [
|
|
90
|
+
subtask({ id: "1", title: "A", status: "todo", position: "0", completed: false }),
|
|
91
|
+
];
|
|
92
|
+
const plan = planReconcile(existing, [item("A")]);
|
|
93
|
+
expect(plan.toCreate).toHaveLength(0);
|
|
94
|
+
expect(plan.toUpdate).toHaveLength(0);
|
|
95
|
+
expect(plan.toDelete).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("updates status + completed when a matched item changed", () => {
|
|
99
|
+
const existing = [subtask({ id: "1", title: "A", status: "todo", position: "0", completed: false })];
|
|
100
|
+
const plan = planReconcile(existing, [item("A", "completed")]);
|
|
101
|
+
expect(plan.toUpdate).toEqual([
|
|
102
|
+
expect.objectContaining({ id: "1", status: "done", completed: true, position: "0" }),
|
|
103
|
+
]);
|
|
104
|
+
expect(plan.toCreate).toHaveLength(0);
|
|
105
|
+
expect(plan.toDelete).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("updates position on reorder (same content, different index)", () => {
|
|
109
|
+
const existing = [
|
|
110
|
+
subtask({ id: "1", title: "A", status: "todo", position: "0" }),
|
|
111
|
+
subtask({ id: "2", title: "B", status: "todo", position: "1" }),
|
|
112
|
+
];
|
|
113
|
+
const plan = planReconcile(existing, [item("B"), item("A")]);
|
|
114
|
+
expect(plan.toUpdate.map((u) => [u.id, u.position])).toEqual([
|
|
115
|
+
["2", "0"],
|
|
116
|
+
["1", "1"],
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("deletes existing subtasks absent from the incoming list", () => {
|
|
121
|
+
const existing = [
|
|
122
|
+
subtask({ id: "1", title: "A", position: "0" }),
|
|
123
|
+
subtask({ id: "2", title: "B", position: "1" }),
|
|
124
|
+
];
|
|
125
|
+
const plan = planReconcile(existing, [item("A")]);
|
|
126
|
+
expect(plan.toDelete).toEqual(["2"]);
|
|
127
|
+
expect(plan.toUpdate).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("matches by stable mark across a content rename, updating content", () => {
|
|
131
|
+
const existing = [
|
|
132
|
+
subtask({ id: "1", title: "Old text", status: "todo", position: "0", kind_data: { task_sync: { mark: "m1" } } }),
|
|
133
|
+
];
|
|
134
|
+
const plan = planReconcile(existing, [item("New text", "pending", "m1")]);
|
|
135
|
+
expect(plan.toUpdate).toEqual([
|
|
136
|
+
expect.objectContaining({ id: "1", content: "New text" }),
|
|
137
|
+
]);
|
|
138
|
+
expect(plan.toCreate).toHaveLength(0);
|
|
139
|
+
expect(plan.toDelete).toHaveLength(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("ignores blank incoming content", () => {
|
|
143
|
+
const plan = planReconcile([], [item(" "), item("Real")]);
|
|
144
|
+
expect(plan.toCreate).toHaveLength(1);
|
|
145
|
+
expect(plan.toCreate[0].content).toBe("Real");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("carries source into kind_data.task_sync for traceability", () => {
|
|
149
|
+
const plan = planReconcile([], [item("A")], "claude-code");
|
|
150
|
+
expect(plan.toCreate[0].kind_data).toEqual({ task_sync: { mark: undefined, source: "claude-code" } });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// --- resolveTaskUpdateTarget (added 2026-07-28) ----------------------------
|
|
155
|
+
// The gap these close: the update path matched ONLY on kind_data.task_sync.mark,
|
|
156
|
+
// so a subtask whose mark was never stamped — or a TaskCreate that silently
|
|
157
|
+
// produced no row at all — was unaddressable forever. Every later update for it
|
|
158
|
+
// was dropped as `no-matching-subtask`: 26 of 30 tasksync discards in six hours,
|
|
159
|
+
// across seven distinct lane sessions, all one reason. One lane had marks "1"
|
|
160
|
+
// and "3" stamped correctly while "2" was missing entirely, so a whole work item
|
|
161
|
+
// left no trace in the fleet's record.
|
|
162
|
+
describe("task-sync resolveTaskUpdateTarget", () => {
|
|
163
|
+
const marked = (id: string, title: string, mark?: string) => ({
|
|
164
|
+
id,
|
|
165
|
+
title,
|
|
166
|
+
kind_data: mark ? { task_sync: { mark } } : {},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("matches by mark when stamped", () => {
|
|
170
|
+
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "1" });
|
|
171
|
+
expect(r).toEqual({ op: "update", id: "a" });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("falls back to exact title when the mark was never stamped", () => {
|
|
175
|
+
// The unmarked-row case: mark lookup misses, title still identifies it.
|
|
176
|
+
const r = resolveTaskUpdateTarget([marked("a", "Alpha")], {
|
|
177
|
+
taskId: "1",
|
|
178
|
+
subject: "Alpha",
|
|
179
|
+
});
|
|
180
|
+
expect(r).toEqual({ op: "update", id: "a" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does not cross-match a different task's mark", () => {
|
|
184
|
+
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "2" });
|
|
185
|
+
expect(r).toEqual({ op: "discard" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("heals a missed create when the event carries a subject", () => {
|
|
189
|
+
// The mark-"2"-never-written case: nothing to match, but the subject is
|
|
190
|
+
// enough to rebuild the row instead of orphaning the task forever.
|
|
191
|
+
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], {
|
|
192
|
+
taskId: "2",
|
|
193
|
+
subject: "Bravo",
|
|
194
|
+
});
|
|
195
|
+
expect(r).toEqual({ op: "heal", title: "Bravo" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("discards only when there is no subject to rebuild a title from", () => {
|
|
199
|
+
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "2" });
|
|
200
|
+
expect(r).toEqual({ op: "discard" });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("prefers the mark over a same-subject title collision", () => {
|
|
204
|
+
const r = resolveTaskUpdateTarget(
|
|
205
|
+
[marked("a", "Same", "1"), marked("b", "Same", "2")],
|
|
206
|
+
{ taskId: "2", subject: "Same" },
|
|
207
|
+
);
|
|
208
|
+
expect(r).toEqual({ op: "update", id: "b" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("ignores a blank subject rather than healing a titleless row", () => {
|
|
212
|
+
const r = resolveTaskUpdateTarget([], { taskId: "9", subject: " " });
|
|
213
|
+
expect(r).toEqual({ op: "discard" });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// --- rename must not orphan an unmarked row (added 2026-07-28) --------------
|
|
218
|
+
describe("a rename cannot orphan an unmarked subtask", () => {
|
|
219
|
+
// Matching is mark-first, then EXACT TITLE. A row that has never been stamped
|
|
220
|
+
// can therefore only ever be found by its title — so the moment a task is
|
|
221
|
+
// RENAMED, neither match succeeds, the event heals into a NEW row, and the
|
|
222
|
+
// pre-rename row is orphaned at whatever status it held. Nothing in the
|
|
223
|
+
// single-event path will ever address it again.
|
|
224
|
+
//
|
|
225
|
+
// Observed on this lane 2026-07-28: three subtasks stranded at `todo` and
|
|
226
|
+
// `in-progress` under their OLD titles while the CLI's own list read empty,
|
|
227
|
+
// which is what made the stop gate refuse a lane that had genuinely finished.
|
|
228
|
+
// `task-projection-converges` requires the projection to express every state a
|
|
229
|
+
// task reaches; a row it can no longer address expresses nothing.
|
|
230
|
+
|
|
231
|
+
it("an unmarked row is still reachable by exact title", () => {
|
|
232
|
+
// The pre-condition for the whole failure: without a mark, TITLE is the
|
|
233
|
+
// only handle, so it works right up until the title changes.
|
|
234
|
+
const existing = [{ id: "row-1", title: "Original title", kind_data: null }];
|
|
235
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Original title" })).toEqual({
|
|
236
|
+
op: "update",
|
|
237
|
+
id: "row-1",
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("renaming an UNMARKED row heals into a new one — the orphan", () => {
|
|
242
|
+
// This is the defect, asserted so the cost of NOT stamping is explicit: the
|
|
243
|
+
// update does not reach row-1, which stays at its old status forever.
|
|
244
|
+
const existing = [{ id: "row-1", title: "Original title", kind_data: null }];
|
|
245
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Renamed title" })).toEqual({
|
|
246
|
+
op: "heal",
|
|
247
|
+
title: "Renamed title",
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("once marked, a rename updates the SAME row", () => {
|
|
252
|
+
// The fix: the first title-match stamps the mark, so every later rename is
|
|
253
|
+
// mark-addressable and lands on the original row. The heal path already
|
|
254
|
+
// stamped on create; the update path was the missing half.
|
|
255
|
+
const existing = [
|
|
256
|
+
{ id: "row-1", title: "Original title", kind_data: { task_sync: { mark: "7" } } },
|
|
257
|
+
];
|
|
258
|
+
expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Renamed title" })).toEqual({
|
|
259
|
+
op: "update",
|
|
260
|
+
id: "row-1",
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("the update path stamps the mark when the row has none", async () => {
|
|
265
|
+
// Asserted against the source because the write is inside an ORPC handler
|
|
266
|
+
// that needs a live prisma context. The regression is the absence of a
|
|
267
|
+
// stamp, which no pure test on resolveTaskUpdateTarget can observe.
|
|
268
|
+
const src = await Bun.file(
|
|
269
|
+
new URL("./task-sync-router.ts", import.meta.url).pathname,
|
|
270
|
+
).text();
|
|
271
|
+
expect(src).toContain("const alreadyMarked");
|
|
272
|
+
expect(src).toContain("if (taskId && !alreadyMarked)");
|
|
273
|
+
// Prior kind_data must be preserved — the lane binding and any other
|
|
274
|
+
// sibling keys live there, and clobbering them would trade this bug for a
|
|
275
|
+
// worse one.
|
|
276
|
+
expect(src).toContain("...priorKindData");
|
|
277
|
+
expect(src).toContain("...priorSync");
|
|
278
|
+
});
|
|
279
|
+
});
|