@teamscala/service-boot 0.23.291 → 0.23.293
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 +10 -2
- package/src/asset-proxy.test.ts +32 -0
- package/src/asset-proxy.ts +1 -1
- package/src/attach-transfers-upload.d.ts +1 -1
- package/src/attach-transfers-upload.test.ts +25 -0
- package/src/attach-transfers-upload.ts +2 -2
- package/src/fn-routers/cache-admin-router.d.ts +2 -2
- package/src/fn-routers/proposal-send-router.d.ts +2 -2
- package/src/fn-routers/public-agreement-router.d.ts +40 -32
- package/src/fn-routers/public-gpt-session-router.d.ts +20 -2
- package/src/fn-routers/public-gpt-session-router.ts +1 -1
- package/src/fn-routers/sign-in-flow-router.d.ts +41 -9
- package/src/fn-routers/task-sync-projection.d.ts +120 -0
- package/src/fn-routers/task-sync-projection.test.ts +123 -0
- package/src/fn-routers/task-sync-projection.ts +201 -0
- package/src/fn-routers/task-sync-router.d.ts +1 -70
- package/src/fn-routers/task-sync-router.test.ts +41 -3
- package/src/fn-routers/task-sync-router.ts +46 -139
- package/src/pg-boss-handler-registry.d.ts +1 -1
- package/src/register-registry-page-route/graph-cache.d.ts +2 -2
- package/src/resource-not-found.test.ts +47 -0
- package/src/resource-not-found.ts +1 -1
|
@@ -41,6 +41,15 @@ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
|
41
41
|
import { authed, type Context } from "./auth-router.ts";
|
|
42
42
|
import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
|
|
43
43
|
import * as v from "valibot";
|
|
44
|
+
import {
|
|
45
|
+
STATUS_TO_WORK_ITEM,
|
|
46
|
+
normalizeStatus,
|
|
47
|
+
resolveProjectedStatus,
|
|
48
|
+
subtaskKey,
|
|
49
|
+
resolveTaskUpdateTarget,
|
|
50
|
+
planReconcile,
|
|
51
|
+
type ReconcilePlan,
|
|
52
|
+
} from "./task-sync-projection.ts";
|
|
44
53
|
|
|
45
54
|
/** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
|
|
46
55
|
export interface TaskListItem {
|
|
@@ -92,7 +101,7 @@ export interface LaneIdentity {
|
|
|
92
101
|
organisation_id?: string;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
|
-
interface WorkItem {
|
|
104
|
+
export interface WorkItem {
|
|
96
105
|
id: string;
|
|
97
106
|
kind: string;
|
|
98
107
|
parent_id: string | null;
|
|
@@ -127,26 +136,6 @@ export function untrackedRunForPane(pane: string) {
|
|
|
127
136
|
};
|
|
128
137
|
}
|
|
129
138
|
|
|
130
|
-
// TodoWrite-style status (the normalized form) → work_items.status (DB enum).
|
|
131
|
-
// The DB enum uses a hyphen for the active state ("in-progress"); see
|
|
132
|
-
// COLUMN_ENUMS["work_items.status"] in db-validation.
|
|
133
|
-
export const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
|
|
134
|
-
pending: "todo",
|
|
135
|
-
in_progress: "in-progress",
|
|
136
|
-
completed: "done",
|
|
137
|
-
};
|
|
138
|
-
// work_items.status → normalized. Unknown/internal statuses collapse to pending.
|
|
139
|
-
const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
|
|
140
|
-
todo: "pending",
|
|
141
|
-
"in-progress": "in_progress",
|
|
142
|
-
active: "in_progress",
|
|
143
|
-
done: "completed",
|
|
144
|
-
completed: "completed",
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export function normalizeStatus(s: string | null): TaskListItem["status"] {
|
|
148
|
-
return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
|
|
149
|
-
}
|
|
150
139
|
|
|
151
140
|
/**
|
|
152
141
|
* Resolve the org a lane's task is created under. Explicit when the lane knows
|
|
@@ -372,121 +361,6 @@ export function classifyTaskHookEvent(evt: {
|
|
|
372
361
|
return "tool";
|
|
373
362
|
}
|
|
374
363
|
|
|
375
|
-
export function subtaskKey(item: { mark?: string; content: string; title: string }): string {
|
|
376
|
-
return item.mark || item.content || item.title;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
|
|
381
|
-
* event address, and what should happen when none does. Extracted for the same
|
|
382
|
-
* reason planReconcile was — the handler should only EXECUTE, so the matching
|
|
383
|
-
* rule is unit-testable.
|
|
384
|
-
*
|
|
385
|
-
* Three resolutions:
|
|
386
|
-
* - `update` — matched by mark, or (mark absent/unstamped) by exact title.
|
|
387
|
-
* - `heal` — nothing matched but the event carries a subject, so the row can
|
|
388
|
-
* be reconstructed. Covers a TaskCreate that silently produced no
|
|
389
|
-
* subtask, which previously orphaned the task forever.
|
|
390
|
-
* - `discard` — nothing matched and there is no subject to rebuild a title from.
|
|
391
|
-
*
|
|
392
|
-
* Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
|
|
393
|
-
* "2" was never written at all, and every later update for it was dropped as
|
|
394
|
-
* `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
|
|
395
|
-
* distinct lane sessions, were that one reason.
|
|
396
|
-
*/
|
|
397
|
-
export function resolveTaskUpdateTarget(
|
|
398
|
-
existing: Array<{ id: string; title: string; kind_data?: Record<string, unknown> | null }>,
|
|
399
|
-
evt: { taskId: string; subject?: string },
|
|
400
|
-
): { op: "update"; id: string } | { op: "heal"; title: string } | { op: "discard" } {
|
|
401
|
-
const byMark = existing.find(
|
|
402
|
-
(s) =>
|
|
403
|
-
evt.taskId &&
|
|
404
|
-
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark === evt.taskId,
|
|
405
|
-
);
|
|
406
|
-
if (byMark) return { op: "update", id: byMark.id };
|
|
407
|
-
const subject = (evt.subject ?? "").trim();
|
|
408
|
-
if (subject) {
|
|
409
|
-
const byTitle = existing.find((s) => s.title === subject);
|
|
410
|
-
if (byTitle) return { op: "update", id: byTitle.id };
|
|
411
|
-
return { op: "heal", title: subject };
|
|
412
|
-
}
|
|
413
|
-
return { op: "discard" };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
|
|
418
|
-
* (current) task list, compute the create/update/delete ops that make the
|
|
419
|
-
* subtask set match the list — WITHOUT touching prisma. Extracted so the
|
|
420
|
-
* matching/status/positioning logic is unit-testable (the handler only executes
|
|
421
|
-
* the plan). Matching is by stable mark when the CLI provides one, else by
|
|
422
|
-
* content. Position follows incoming index, so a reorder is an update.
|
|
423
|
-
*/
|
|
424
|
-
export interface ReconcilePlan {
|
|
425
|
-
toCreate: Array<{
|
|
426
|
-
content: string;
|
|
427
|
-
status: string;
|
|
428
|
-
completed: boolean;
|
|
429
|
-
position: string;
|
|
430
|
-
kind_data: Record<string, unknown>;
|
|
431
|
-
}>;
|
|
432
|
-
toUpdate: Array<{
|
|
433
|
-
id: string;
|
|
434
|
-
content: string;
|
|
435
|
-
status: string;
|
|
436
|
-
completed: boolean;
|
|
437
|
-
position: string;
|
|
438
|
-
kind_data: Record<string, unknown>;
|
|
439
|
-
}>;
|
|
440
|
-
toDelete: string[];
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
export function planReconcile(
|
|
444
|
-
existing: WorkItem[],
|
|
445
|
-
incoming: TaskListItem[],
|
|
446
|
-
source?: string,
|
|
447
|
-
): ReconcilePlan {
|
|
448
|
-
const byKey = new Map<string, WorkItem>();
|
|
449
|
-
for (const s of existing) {
|
|
450
|
-
const mark = (s.kind_data?.task_sync as { mark?: string } | undefined)?.mark;
|
|
451
|
-
byKey.set(subtaskKey({ mark, content: "", title: s.title }), s);
|
|
452
|
-
}
|
|
453
|
-
const toCreate: ReconcilePlan["toCreate"] = [];
|
|
454
|
-
const toUpdate: ReconcilePlan["toUpdate"] = [];
|
|
455
|
-
const seen = new Set<string>();
|
|
456
|
-
for (let i = 0; i < incoming.length; i++) {
|
|
457
|
-
const item = incoming[i];
|
|
458
|
-
const content = (item.content ?? "").trim();
|
|
459
|
-
if (!content) continue;
|
|
460
|
-
const key = subtaskKey({ mark: item.mark, content, title: content });
|
|
461
|
-
const status = STATUS_TO_WORK_ITEM[item.status] ?? "todo";
|
|
462
|
-
const completed = item.status === "completed";
|
|
463
|
-
const position = String(i);
|
|
464
|
-
const taskSync = { task_sync: { mark: item.mark, source } };
|
|
465
|
-
const match = byKey.get(key);
|
|
466
|
-
if (match) {
|
|
467
|
-
seen.add(match.id);
|
|
468
|
-
const changed =
|
|
469
|
-
match.title !== content ||
|
|
470
|
-
match.status !== status ||
|
|
471
|
-
(match.completed ?? false) !== completed ||
|
|
472
|
-
match.position !== position;
|
|
473
|
-
if (changed) {
|
|
474
|
-
toUpdate.push({
|
|
475
|
-
id: match.id,
|
|
476
|
-
content,
|
|
477
|
-
status,
|
|
478
|
-
completed,
|
|
479
|
-
position,
|
|
480
|
-
kind_data: { ...match.kind_data, ...taskSync },
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
} else {
|
|
484
|
-
toCreate.push({ content, status, completed, position, kind_data: taskSync });
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
const toDelete = existing.filter((s) => !seen.has(s.id)).map((s) => s.id);
|
|
488
|
-
return { toCreate, toUpdate, toDelete };
|
|
489
|
-
}
|
|
490
364
|
|
|
491
365
|
/**
|
|
492
366
|
* Execute a reconcile against prisma for a lane's task — the shared write path
|
|
@@ -501,10 +375,31 @@ async function reconcileTaskList(
|
|
|
501
375
|
laneSession: string,
|
|
502
376
|
list: TaskListItem[],
|
|
503
377
|
source?: string,
|
|
378
|
+
pane?: string | null,
|
|
504
379
|
): Promise<{ task_id: string; created: number; updated: number; deleted: number; total: number }> {
|
|
505
|
-
|
|
380
|
+
// BIND ON DEMAND — this path used to `resolveLaneTask` and THROW, which is the
|
|
381
|
+
// exact failure `ensureLaneTask` was written to make impossible (see its
|
|
382
|
+
// comment above, and the operator ruling it cites: "ALL lanes can comply,
|
|
383
|
+
// there is no reason why a tab cant comply"). The backstop existed and this
|
|
384
|
+
// caller did not use it, so a lane whose SessionStart bindLane never ran — or
|
|
385
|
+
// ran while the endpoint was failing — could never sync a task list again.
|
|
386
|
+
// Not degraded: permanently, with a 404 on every attempt and no path to
|
|
387
|
+
// self-recover.
|
|
388
|
+
//
|
|
389
|
+
// Measured 2026-07-30: a live lane reported ~9 consecutive failures over
|
|
390
|
+
// hours and correctly diagnosed its own lane-compliance flag as a false
|
|
391
|
+
// positive it was structurally unable to clear. The gate was telling it to
|
|
392
|
+
// keep a task list while the only endpoint that records one refused it.
|
|
393
|
+
//
|
|
394
|
+
// A lane calling this HAS everything the bind needs — its session, its pane,
|
|
395
|
+
// its list — so requiring a prior call is a sequencing demand with no
|
|
396
|
+
// informational content. `friction-is-a-stop-condition`: the compliant path
|
|
397
|
+
// must exist, or the rule is unenforceable against the lane it binds.
|
|
398
|
+
const task = await ensureLaneTask(prisma, laneSession, pane ?? null);
|
|
506
399
|
if (!task) {
|
|
507
|
-
throw new Error(
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Could not bind a task for session ${laneSession} — the create failed and no existing task was found.`,
|
|
402
|
+
);
|
|
508
403
|
}
|
|
509
404
|
const existing = await prisma.work_items.findMany({
|
|
510
405
|
where: { parent_id: task.id, kind: "subtask" },
|
|
@@ -701,8 +596,13 @@ export const taskSyncRouter = {
|
|
|
701
596
|
const { ORPCError } = await import("@orpc/server");
|
|
702
597
|
throw new ORPCError("BAD_REQUEST", { message: "upsertFromTaskList requires lane_session" });
|
|
703
598
|
}
|
|
599
|
+
// The pane rides in the header the callers already send (`x-lane-pane`),
|
|
600
|
+
// and it is what lets the on-demand bind link the new task to the lane's
|
|
601
|
+
// run. Absent, the bind still succeeds — it just cannot link the run — so
|
|
602
|
+
// this is passed opportunistically rather than required.
|
|
603
|
+
const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
|
|
704
604
|
try {
|
|
705
|
-
const res = await reconcileTaskList(prisma, session, list, body.source);
|
|
605
|
+
const res = await reconcileTaskList(prisma, session, list, body.source, headerPane);
|
|
706
606
|
log.info("[task-sync] upsertFromTaskList reconciled", { session, ...res });
|
|
707
607
|
return res;
|
|
708
608
|
} catch (err) {
|
|
@@ -710,6 +610,13 @@ export const taskSyncRouter = {
|
|
|
710
610
|
const { ORPCError } = await import("@orpc/server");
|
|
711
611
|
throw new ORPCError("NOT_FOUND", { message: err.message });
|
|
712
612
|
}
|
|
613
|
+
// The 500 body is masked by oRPC (INTERNAL_SERVER_ERROR), so without
|
|
614
|
+
// this the cause is invisible — measured 2026-07-30:
|
|
615
|
+
// upsertFromTaskList 500ing consistently with no surfaced error,
|
|
616
|
+
// blocking task-list projection + the watchdog for every lane. Log
|
|
617
|
+
// the actual error before re-throwing (protective-layers-fail-
|
|
618
|
+
// explicitly: a failure with no observability is the worst shape).
|
|
619
|
+
log.error("[task-sync] upsertFromTaskList FAILED", { session, err: err instanceof Error ? `${err.name}: ${err.message}` : String(err) });
|
|
713
620
|
throw err;
|
|
714
621
|
}
|
|
715
622
|
}),
|
|
@@ -17,7 +17,7 @@ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schem
|
|
|
17
17
|
export type PgBossJobHandler = (ctx: {
|
|
18
18
|
config: ScheduledJobConfig;
|
|
19
19
|
}) => Promise<void> | void;
|
|
20
|
-
export declare const pgBossHandlerRegistry:
|
|
20
|
+
export declare const pgBossHandlerRegistry: import("@teamscala/generic-registry/create-registry").Registry<PgBossJobHandler>;
|
|
21
21
|
/**
|
|
22
22
|
* Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
|
|
23
23
|
* (createRegistry.register overwrites silently — last registration wins).
|
|
@@ -7,8 +7,8 @@ type GraphCache = {
|
|
|
7
7
|
ttlMs?: number;
|
|
8
8
|
}): void;
|
|
9
9
|
};
|
|
10
|
-
export declare const staticShellGraphCache:
|
|
11
|
-
export declare const paramRouteGraphCache:
|
|
10
|
+
export declare const staticShellGraphCache: import("@teamscala/cache/cache").ManagedCache<unknown>;
|
|
11
|
+
export declare const paramRouteGraphCache: import("@teamscala/cache/cache").ManagedCache<unknown>;
|
|
12
12
|
/**
|
|
13
13
|
* Stale-while-revalidate read of a cached loader graph.
|
|
14
14
|
*
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Co-located tests for the resource-not-found public API: the error class +
|
|
7
|
+
* the response builder are pure (no S3), so the behaviour is exercised directly.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { PrimaryResourceMissingError, buildResourceNotFoundResponse } from "./resource-not-found.ts";
|
|
11
|
+
|
|
12
|
+
describe("PrimaryResourceMissingError", () => {
|
|
13
|
+
it("is an Error carrying resourceType + httpStatus", () => {
|
|
14
|
+
const err = new PrimaryResourceMissingError("project", 404);
|
|
15
|
+
expect(err).toBeInstanceOf(Error);
|
|
16
|
+
expect(err.resourceType).toBe("project");
|
|
17
|
+
expect(err.httpStatus).toBe(404);
|
|
18
|
+
expect(err.empty).toBe(false);
|
|
19
|
+
expect(err.message).toContain("project");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("defaults httpStatus to 404 + supports the empty variant", () => {
|
|
23
|
+
const dead = new PrimaryResourceMissingError("roadmap");
|
|
24
|
+
expect(dead.httpStatus).toBe(404);
|
|
25
|
+
const empty = new PrimaryResourceMissingError("roadmap", 200, { empty: true });
|
|
26
|
+
expect(empty.httpStatus).toBe(200);
|
|
27
|
+
expect(empty.empty).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("buildResourceNotFoundResponse", () => {
|
|
32
|
+
it("returns a 404 Response for a dead link", () => {
|
|
33
|
+
const res = buildResourceNotFoundResponse("project");
|
|
34
|
+
expect(res).toBeInstanceOf(Response);
|
|
35
|
+
expect(res.status).toBe(404);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns a 200 Response for the bare root (no resource selected)", () => {
|
|
39
|
+
const res = buildResourceNotFoundResponse("project", undefined, 200);
|
|
40
|
+
expect(res.status).toBe(200);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders the empty-page surface when opts.empty", () => {
|
|
44
|
+
const res = buildResourceNotFoundResponse("project", undefined, 200, { empty: true });
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* floor that must always exist.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { getClient } from "@teamscala/
|
|
31
|
+
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
32
32
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
33
33
|
import { getServerClient } from "@teamscala/orpc/server-client-registry";
|
|
34
34
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|