@teamscala/service-boot 0.23.294 → 0.23.296
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,58 @@
|
|
|
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
|
+
});
|
|
@@ -290,7 +290,7 @@ async function ensureLaneTask(
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
async function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null> {
|
|
293
|
-
|
|
293
|
+
const active = await prisma.work_items.findFirst({
|
|
294
294
|
where: {
|
|
295
295
|
kind: "task",
|
|
296
296
|
status: "active",
|
|
@@ -305,6 +305,31 @@ async function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkIte
|
|
|
305
305
|
// unwritable; this makes the read stable regardless.
|
|
306
306
|
orderBy: { created_at: "asc" },
|
|
307
307
|
});
|
|
308
|
+
if (active) return active;
|
|
309
|
+
|
|
310
|
+
// FALL BACK TO AN ARCHIVED PARENT FOR THE SAME SESSION.
|
|
311
|
+
//
|
|
312
|
+
// Closing a run archives the lane's parent (close-run.ts) specifically to
|
|
313
|
+
// drop it from the active match set above. That is correct when the LANE is
|
|
314
|
+
// gone, and wrong whenever the run record closes while the lane keeps
|
|
315
|
+
// running — which happens on a tmux-server restart (runs are reaped, panes
|
|
316
|
+
// are restored) and whenever an orchestrator closes a run on a stale read.
|
|
317
|
+
// The lane then has a full task list that this function cannot see, so
|
|
318
|
+
// callers mint a FRESH parent, the old list is orphaned, and the compliance
|
|
319
|
+
// watchdog reports "working with NO task list" at a lane holding 85 tasks.
|
|
320
|
+
// That is the "KNOWN ADJACENT DEFECT" recorded on the lane-working-has-task-list
|
|
321
|
+
// guard row, observed live 2026-07-31.
|
|
322
|
+
//
|
|
323
|
+
// Safe because a session id is minted per lane spawn and never reused, so an
|
|
324
|
+
// archived parent under this session belongs to THIS lane. Newest-first.
|
|
325
|
+
return prisma.work_items.findFirst({
|
|
326
|
+
where: {
|
|
327
|
+
kind: "task",
|
|
328
|
+
status: "archived",
|
|
329
|
+
kind_data: { path: ["lane", "session"], equals: session },
|
|
330
|
+
},
|
|
331
|
+
orderBy: { created_at: "desc" },
|
|
332
|
+
});
|
|
308
333
|
}
|
|
309
334
|
|
|
310
335
|
/** Match key for a subtask: stable mark when the CLI provides one, else content, else title. Uses || (not ??) so an empty-string content (passed for existing rows that key by title) falls through. */
|
|
@@ -671,6 +696,8 @@ export const taskSyncRouter = {
|
|
|
671
696
|
task_subject?: unknown;
|
|
672
697
|
task_id?: unknown;
|
|
673
698
|
hook_event_name?: unknown;
|
|
699
|
+
// Created-already-done must not project OPEN. Absent -> old behaviour.
|
|
700
|
+
status?: unknown;
|
|
674
701
|
};
|
|
675
702
|
// TaskCompleted BEFORE the TaskCreated branch. Both dedicated events carry
|
|
676
703
|
// `task_subject`, so the subject-presence test below cannot tell them apart
|
|
@@ -727,6 +754,8 @@ export const taskSyncRouter = {
|
|
|
727
754
|
const task2 = await ensureLaneTask(prisma, session, headerPane);
|
|
728
755
|
if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
|
|
729
756
|
const createdId = String(createdEvt.task_id ?? "");
|
|
757
|
+
const createdStatus =
|
|
758
|
+
STATUS_TO_WORK_ITEM[createdEvt.status as TaskListItem["status"]] ?? "todo";
|
|
730
759
|
const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
|
|
731
760
|
// IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
|
|
732
761
|
// --retry-connrefused`, so one TaskCreate can arrive twice (observed
|
|
@@ -754,8 +783,8 @@ export const taskSyncRouter = {
|
|
|
754
783
|
parent_id: task2.id,
|
|
755
784
|
organisation_id: task2.organisation_id,
|
|
756
785
|
title: subject,
|
|
757
|
-
status:
|
|
758
|
-
completed:
|
|
786
|
+
status: createdStatus,
|
|
787
|
+
completed: createdStatus === "done",
|
|
759
788
|
position: String(existing.length),
|
|
760
789
|
kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
|
|
761
790
|
},
|
|
@@ -14,6 +14,8 @@ import { join } from "node:path";
|
|
|
14
14
|
import type { Hono } from "hono";
|
|
15
15
|
import { etag } from "hono/etag";
|
|
16
16
|
import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
17
|
+
import { collectHeapReport } from "@teamscala/os/observability/heap-report";
|
|
18
|
+
import { isLoopbackHost } from "@teamscala/os/observability/loopback-host";
|
|
17
19
|
import { handleAssetProxy } from "./asset-proxy";
|
|
18
20
|
import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
|
|
19
21
|
|
|
@@ -37,41 +39,20 @@ export async function registerStaticAssets(
|
|
|
37
39
|
}),
|
|
38
40
|
);
|
|
39
41
|
|
|
40
|
-
// Localhost-gated heap-attribution probe —
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
42
|
+
// Localhost-gated heap-attribution probe — the heapSize-vs-RSS split (JS heap
|
|
43
|
+
// vs native: OTel/Prisma-engine/buffers/compiled code) plus the top object
|
|
44
|
+
// types by count. Read-only, no secrets; gated to a localhost Host so it never
|
|
45
|
+
// answers over the public domain. Registered here (before auth + page routes)
|
|
46
|
+
// so it is reachable on every service kind, like /health
|
|
47
|
+
// (`verify-via-127.0.0.1`, sibling of `runtime-metrics-via-railway-cli`).
|
|
48
|
+
//
|
|
49
|
+
// The payload shape lives in @teamscala/os so standalone `Bun.serve` daemons
|
|
50
|
+
// (scala-guard) answer this probe identically — `scala-tools heap <slug>` must
|
|
51
|
+
// not mean different things depending on which process replied.
|
|
47
52
|
app.get("/_heap", async (c) => {
|
|
48
|
-
|
|
49
|
-
const isLocal =
|
|
50
|
-
host.startsWith("127.0.0.1") || host.startsWith("localhost") || host.startsWith("[::1]");
|
|
51
|
-
if (!isLocal) return c.json({ error: "localhost only" }, 403);
|
|
53
|
+
if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "localhost only" }, 403);
|
|
52
54
|
try {
|
|
53
|
-
|
|
54
|
-
const s = heapStats() as unknown as {
|
|
55
|
-
heapSize: number;
|
|
56
|
-
heapCapacity: number;
|
|
57
|
-
objectCount: number;
|
|
58
|
-
objectTypeCounts?: Record<string, number>;
|
|
59
|
-
};
|
|
60
|
-
const rss = process.memoryUsage.rss();
|
|
61
|
-
const topObjectTypes = Object.fromEntries(
|
|
62
|
-
Object.entries(s.objectTypeCounts ?? {})
|
|
63
|
-
.sort((a, b) => b[1] - a[1])
|
|
64
|
-
.slice(0, 40),
|
|
65
|
-
);
|
|
66
|
-
return c.json({
|
|
67
|
-
service: serviceSlug,
|
|
68
|
-
rss,
|
|
69
|
-
heapSize: s.heapSize,
|
|
70
|
-
heapCapacity: s.heapCapacity,
|
|
71
|
-
objectCount: s.objectCount,
|
|
72
|
-
nativeApprox: Math.max(0, rss - s.heapSize),
|
|
73
|
-
topObjectTypes,
|
|
74
|
-
});
|
|
55
|
+
return c.json(await collectHeapReport(serviceSlug));
|
|
75
56
|
} catch (err) {
|
|
76
57
|
const report = buildErrorReport(err, { service: serviceSlug, url: c.req.url });
|
|
77
58
|
return new Response(JSON.stringify(report.http.body), {
|