@teamscala/service-boot 0.23.317 → 0.23.319
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 +526 -526
- package/src/agreement-gate.ts +12 -0
- package/src/auth-gate.test.ts +70 -0
- package/src/auth-gate.ts +68 -6
- package/src/build-client-assets.test.ts +1 -1
- package/src/build-client-assets.ts +1 -1
- package/src/discover-blocks.ts +1 -1
- package/src/fn-routers/index.ts +2 -0
- package/src/fn-routers/task-sync-assign.test.ts +123 -8
- package/src/fn-routers/task-sync-assign.ts +105 -18
- package/src/fn-routers/task-sync-bind.test.ts +18 -18
- package/src/fn-routers/task-sync-brief.test.ts +65 -22
- package/src/fn-routers/task-sync-brief.ts +77 -23
- package/src/fn-routers/task-sync-close.test.ts +44 -0
- package/src/fn-routers/task-sync-close.ts +82 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
- package/src/fn-routers/task-sync-hook-lifecycle.test.ts +204 -0
- package/src/fn-routers/task-sync-lane.test.ts +36 -0
- package/src/fn-routers/task-sync-projection.test.ts +26 -0
- package/src/fn-routers/task-sync-projection.ts +51 -0
- package/src/fn-routers/task-sync-rename.test.ts +53 -0
- package/src/fn-routers/task-sync-rename.ts +111 -0
- package/src/fn-routers/task-sync-router.test.ts +14 -2
- package/src/handlers/singleton-tools-currency-watch.ts +114 -11
- package/src/handlers/sweep-host-cache.test.ts +133 -0
- package/src/handlers/sweep-host-cache.ts +143 -26
- package/src/install-logging.ts +3 -0
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-api-routes.ts +8 -2
- package/src/register-page-routes.ts +0 -3
- package/src/resolve-brand-favicon.ts +1 -1
- package/src/resource-not-found.ts +10 -0
- package/src/serve/stamp-transport-identity.test.ts +24 -0
- package/src/serve/stamp-transport-identity.ts +20 -7
- package/src/two-phase-boot.ts +85 -17
- package/src/two-phase-health-gate-client-only.test.ts +77 -0
- package/src/two-phase-health-gate.test.ts +13 -2
- package/src/agreement-gate.d.ts +0 -19
- package/src/asset-cache-key.d.ts +0 -11
- package/src/asset-optimize.d.ts +0 -17
- package/src/asset-proxy.d.ts +0 -11
- package/src/asset-social-card.d.ts +0 -17
- package/src/attach-agreement-magic.d.ts +0 -20
- package/src/attach-portal-members.d.ts +0 -23
- package/src/attach-proposal-accept.d.ts +0 -26
- package/src/attach-proposal-magic.d.ts +0 -18
- package/src/attach-transfers-upload.d.ts +0 -27
- package/src/auth-gate.d.ts +0 -28
- package/src/bind-page-handler.d.ts +0 -18
- package/src/build-client-assets/input-hashing.d.ts +0 -25
- package/src/build-client-assets/source-candidates.d.ts +0 -27
- package/src/build-client-assets.d.ts +0 -50
- package/src/compose-page-title.d.ts +0 -41
- package/src/content-gate/access.d.ts +0 -15
- package/src/content-gate/config-types.d.ts +0 -99
- package/src/content-gate/gate.d.ts +0 -13
- package/src/content-gate/parse-cookie.d.ts +0 -9
- package/src/content-gate/token.d.ts +0 -48
- package/src/content-page-floor.d.ts +0 -39
- package/src/discover-blocks.d.ts +0 -28
- package/src/expand-roster.d.ts +0 -34
- package/src/fn-routers/agreement-completion-email.d.ts +0 -14
- package/src/fn-routers/agreement-send-router.d.ts +0 -82
- package/src/fn-routers/ai-health-router.d.ts +0 -35
- package/src/fn-routers/auth-router.d.ts +0 -47
- package/src/fn-routers/cache-admin-router.d.ts +0 -57
- package/src/fn-routers/chat-session-router.d.ts +0 -219
- package/src/fn-routers/email-defaults.d.ts +0 -1
- package/src/fn-routers/email-link-ttl.d.ts +0 -1
- package/src/fn-routers/index.d.ts +0 -18
- package/src/fn-routers/panel-data-router.d.ts +0 -19
- package/src/fn-routers/persist-chat-messages.d.ts +0 -36
- package/src/fn-routers/plans-router.d.ts +0 -25
- package/src/fn-routers/proposal-send-router.d.ts +0 -58
- package/src/fn-routers/public-agreement-router.d.ts +0 -289
- package/src/fn-routers/public-assessment-router.d.ts +0 -68
- package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
- package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
- package/src/fn-routers/task-sync-assign.d.ts +0 -276
- package/src/fn-routers/task-sync-brief.d.ts +0 -69
- package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
- package/src/fn-routers/task-sync-dispatch.ts +0 -124
- package/src/fn-routers/task-sync-lane.d.ts +0 -28
- package/src/fn-routers/task-sync-lane.ts +0 -91
- package/src/fn-routers/task-sync-projection.d.ts +0 -121
- package/src/fn-routers/task-sync-router.d.ts +0 -375
- package/src/fn-routers/task-sync-router.ts +0 -1011
- package/src/fn-routers/tasks-router.d.ts +0 -25
- package/src/fn-routers/user-preferences-router.d.ts +0 -77
- package/src/fn-routers/work-items-router.d.ts +0 -102
- package/src/gate-ttls.d.ts +0 -6
- package/src/gpt-gate.d.ts +0 -25
- package/src/handlers/bun-stable-watch.d.ts +0 -1
- package/src/handlers/pane-outbox-drain.d.ts +0 -1
- package/src/handlers/projects-digest.d.ts +0 -26
- package/src/handlers/prune-stale-data.d.ts +0 -30
- package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
- package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
- package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
- package/src/handlers/sweep-event-log.d.ts +0 -1
- package/src/handlers/sweep-host-cache.d.ts +0 -1
- package/src/handlers/upstream-issue-sync.d.ts +0 -1
- package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
- package/src/handlers/whatsapp-liveness.d.ts +0 -4
- package/src/hono-error-handler.d.ts +0 -25
- package/src/install-harness.d.ts +0 -45
- package/src/install-logging.d.ts +0 -34
- package/src/load-service-install-row.d.ts +0 -24
- package/src/load-service-prisma-client.d.ts +0 -18
- package/src/mount-orpc-router.d.ts +0 -15
- package/src/mount-server-packages.d.ts +0 -29
- package/src/mount.d.ts +0 -9
- package/src/mutable-app-ref.d.ts +0 -44
- package/src/page-route-shell-types.d.ts +0 -12
- package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
- package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
- package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
- package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
- package/src/page-server-orpc/server-client.d.ts +0 -16
- package/src/page-server-orpc/templates-router.d.ts +0 -34
- package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
- package/src/pg-boss-handler-registry.d.ts +0 -27
- package/src/pg-boss-mount.d.ts +0 -42
- package/src/proposal-authorized-emails.d.ts +0 -28
- package/src/proposal-chat-inject.d.ts +0 -17
- package/src/proposal-gate.d.ts +0 -18
- package/src/read-html-template.d.ts +0 -38
- package/src/read-viewport-from-request.d.ts +0 -18
- package/src/readiness-gate.d.ts +0 -16
- package/src/register-api-routes.d.ts +0 -18
- package/src/register-crash-handlers.d.ts +0 -15
- package/src/register-page-routes.d.ts +0 -27
- package/src/register-page-server-client.d.ts +0 -11
- package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
- package/src/register-registry-page-route/graph-cache.d.ts +0 -30
- package/src/register-registry-page-route/meta-title.d.ts +0 -2
- package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
- package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
- package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
- package/src/register-registry-page-route.d.ts +0 -27
- package/src/register-static-assets.d.ts +0 -13
- package/src/register-ws-routes.d.ts +0 -28
- package/src/resolve-app-base-url.d.ts +0 -13
- package/src/resolve-brand-favicon.d.ts +0 -28
- package/src/resolve-from-loader-config.d.ts +0 -32
- package/src/resolve-service-context.d.ts +0 -17
- package/src/resolve-service-port.d.ts +0 -75
- package/src/resolve-theme-mode.d.ts +0 -42
- package/src/resolvers/fetch-org-brand.d.ts +0 -2
- package/src/resolvers/portal-route-paths.d.ts +0 -39
- package/src/resolvers/website-by-host-map.d.ts +0 -12
- package/src/resource-chat-inject.d.ts +0 -46
- package/src/resource-not-found.d.ts +0 -60
- package/src/run-boot-hook-list.d.ts +0 -19
- package/src/run-boot-hooks.d.ts +0 -31
- package/src/scala-tools-currency-watchdog.d.ts +0 -10
- package/src/security-headers.d.ts +0 -12
- package/src/serve/serve.d.ts +0 -46
- package/src/serve/stamp-transport-identity.d.ts +0 -32
- package/src/server-error-page.d.ts +0 -29
- package/src/start-scheduled-jobs.d.ts +0 -18
- package/src/two-phase-boot.d.ts +0 -136
- package/src/types.d.ts +0 -46
- package/src/ui-service.d.ts +0 -46
- package/src/ws-boot-self-test.d.ts +0 -19
package/src/agreement-gate.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
19
|
import { queryRows } from "@teamscala/db/query-rows";
|
|
20
20
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
21
|
+
import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
|
|
21
22
|
import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
|
|
22
23
|
import {
|
|
23
24
|
signAgreementAccessToken,
|
|
@@ -885,3 +886,14 @@ export const agreementAccessConfig: AccessConfig = {
|
|
|
885
886
|
return signAgreementAccessToken(resourceId, email, nowMs);
|
|
886
887
|
},
|
|
887
888
|
};
|
|
889
|
+
|
|
890
|
+
// Event-driven invalidation via LISTEN on registry_config_changed
|
|
891
|
+
{
|
|
892
|
+
const _url = getDirectDatabaseUrl();
|
|
893
|
+
if (_url) {
|
|
894
|
+
createNotifyListener(_url).listen("registry_config_changed", () => {
|
|
895
|
+
pageAgreementCache.clear();
|
|
896
|
+
orgMemberCache.clear();
|
|
897
|
+
}).catch((e: unknown) => console.error("[cache-invalidation] subscribe failed: " + e));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The signed-out screen must be able to render its own branding.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { isStaticAssetPath } from "./auth-gate.ts";
|
|
11
|
+
|
|
12
|
+
describe("static assets are never behind the login gate", () => {
|
|
13
|
+
/**
|
|
14
|
+
* The regression this file exists for.
|
|
15
|
+
*
|
|
16
|
+
* Public paths were an ENUMERATED allowlist: `/assets/` was on it, `/logos/`
|
|
17
|
+
* never was. So `/logos/cropped.png` matched no prefix, fell through to the
|
|
18
|
+
* session gate, and answered 302 → /sign-in. A browser cannot act on a
|
|
19
|
+
* redirect it received for an <img>, so it painted nothing — a blank logo
|
|
20
|
+
* pulsing under a "starting up" splash, on the ONE screen guaranteed to need
|
|
21
|
+
* branding, for a user who by definition has no session.
|
|
22
|
+
*
|
|
23
|
+
* It kept coming back because nothing was broken by any change: the list
|
|
24
|
+
* simply never covered the path, and any build that moves images re-creates
|
|
25
|
+
* the gap. Enumerating what is public makes every new asset private by
|
|
26
|
+
* default; a predicate makes that unrepresentable.
|
|
27
|
+
*/
|
|
28
|
+
test("the logo that broke the signed-out screen is public", () => {
|
|
29
|
+
expect(isStaticAssetPath("/logos/cropped.png")).toBe(true);
|
|
30
|
+
expect(isStaticAssetPath("/logos/companyLogo.png")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("every asset kind a browser fetches without a session is public", () => {
|
|
34
|
+
for (const p of [
|
|
35
|
+
"/logos/dark.svg",
|
|
36
|
+
"/img/hero.webp",
|
|
37
|
+
"/brand/mark.avif",
|
|
38
|
+
"/fonts/inter.woff2",
|
|
39
|
+
"/styles/main.css",
|
|
40
|
+
"/assets/chunk-4efmn1eh.js",
|
|
41
|
+
"/favicon.ico",
|
|
42
|
+
]) {
|
|
43
|
+
expect(isStaticAssetPath(p)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("case does not decide whether an image is reachable", () => {
|
|
48
|
+
expect(isStaticAssetPath("/logos/Company.PNG")).toBe(true);
|
|
49
|
+
expect(isStaticAssetPath("/logos/Mark.SVG")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The predicate widens what is PUBLIC, so its limit is the security-relevant
|
|
54
|
+
* half: pages and APIs must keep failing closed. A path is an asset because
|
|
55
|
+
* of its EXTENSION, never because it merely contains an asset-looking word.
|
|
56
|
+
*/
|
|
57
|
+
test("pages and APIs are still gated", () => {
|
|
58
|
+
for (const p of [
|
|
59
|
+
"/",
|
|
60
|
+
"/dashboard",
|
|
61
|
+
"/api/rpc/fn/workItems/list",
|
|
62
|
+
"/settings/logos",
|
|
63
|
+
"/logos",
|
|
64
|
+
"/png",
|
|
65
|
+
"/reports/quarterly.pdf",
|
|
66
|
+
]) {
|
|
67
|
+
expect(isStaticAssetPath(p)).toBe(false);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/auth-gate.ts
CHANGED
|
@@ -43,6 +43,61 @@ const SIGN_IN_PATHS = new Set([
|
|
|
43
43
|
"/magic-link",
|
|
44
44
|
]);
|
|
45
45
|
|
|
46
|
+
/// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION
|
|
47
|
+
/// rather than by remembering to list it.
|
|
48
|
+
///
|
|
49
|
+
/// The allowlist below enumerates PREFIXES, and every path outside it is gated.
|
|
50
|
+
/// That makes a new public asset directory private BY DEFAULT, and the failure
|
|
51
|
+
/// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one
|
|
52
|
+
/// page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
|
|
53
|
+
/// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a
|
|
54
|
+
/// blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
|
|
55
|
+
/// A TIME, which is the same defect already visible.
|
|
56
|
+
///
|
|
57
|
+
/// This is why the operator reports it "keeps regressing": nothing is broken by
|
|
58
|
+
/// a code change, the allowlist simply never covered the path, and any build that
|
|
59
|
+
/// moves images re-creates it. Enumerating what is public is `prevention-over-
|
|
60
|
+
/// detection` inverted — the cure is a predicate, not a longer list.
|
|
61
|
+
///
|
|
62
|
+
/// Redirecting an <img> to a login page is never useful behaviour either: the
|
|
63
|
+
/// browser cannot act on the redirect, so it silently paints nothing. Failing
|
|
64
|
+
/// closed on a page is protection; failing closed on an image is just a blank.
|
|
65
|
+
const STATIC_ASSET_EXTENSIONS = [
|
|
66
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
|
|
67
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
68
|
+
".css", ".js", ".mjs", ".map",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Point the event-log primitive at the process's Prisma singleton.
|
|
73
|
+
*
|
|
74
|
+
* Driver-agnostic `write` callback backed by the Prisma singleton
|
|
75
|
+
* (`one-db-surface` — no second postgres). Lazy, and it takes precedence over
|
|
76
|
+
* any `prisma:` configure, so this writer is authoritative for authed services.
|
|
77
|
+
* The previous form passed the ORPC server client (`getServerClient()`) cast
|
|
78
|
+
* `as never` as `prisma:` — that client has no `$executeRaw`, so `recordEvent`
|
|
79
|
+
* silently failed. Routing it through ORPC would reintroduce exactly that.
|
|
80
|
+
* Retention is owned by the event-log-sweep pg-boss job.
|
|
81
|
+
*
|
|
82
|
+
* EXTRACTED so its name states what it is. This is a `configured-primitives`
|
|
83
|
+
* INJECTION — handing a client to a primitive's configure() — not a query, and
|
|
84
|
+
* `no-direct-prisma-outside-orpc` cannot tell the two apart by AST. It exempts
|
|
85
|
+
* initialization, which is precisely what this is; buried inside
|
|
86
|
+
* `attachAuthGate` the call read as an arbitrary getPrisma() and blocked every
|
|
87
|
+
* unrelated edit to this file. Naming the boot step honestly is the fix. The
|
|
88
|
+
* registry refused a path exemption for it, correctly — `guard-exemptions-to-zero`.
|
|
89
|
+
*/
|
|
90
|
+
function initGateDependencies(): { prisma: ReturnType<typeof getPrisma> } {
|
|
91
|
+
const prisma = getPrisma();
|
|
92
|
+
configureEventLog({ prisma });
|
|
93
|
+
return { prisma };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isStaticAssetPath(pathname: string): boolean {
|
|
97
|
+
const lower = pathname.toLowerCase();
|
|
98
|
+
return STATIC_ASSET_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
const PUBLIC_INFRA_PREFIXES = [
|
|
47
102
|
"/assets/",
|
|
48
103
|
"/api/assets/",
|
|
@@ -114,12 +169,11 @@ const PUBLIC_INFRA_PATHS = new Set([
|
|
|
114
169
|
// the other dev-host infra reads.
|
|
115
170
|
"/api/fleet/claude-sessions",
|
|
116
171
|
// /api/fleet/ingest-event receives parsed lane-action events
|
|
117
|
-
// (message.part.updated etc.) from the cli-session
|
|
172
|
+
// (message.part.updated etc.) from the cli-session transcript-tail — a
|
|
118
173
|
// dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
|
|
119
174
|
// like the lane-event hook. Without this exemption the page auth-gate 302s
|
|
120
175
|
// every POST and the lane's tool/text action stream never reaches the
|
|
121
|
-
// ingestEvent pipeline
|
|
122
|
-
// shows only lifecycle lines, never the per-tab action feed). A first-class
|
|
176
|
+
// ingestEvent pipeline. A first-class
|
|
123
177
|
// api_route (sibling to lane-event); security is the handler's loopback gate
|
|
124
178
|
// on the platform's x-caller-remote-address (set by register-api-routes).
|
|
125
179
|
"/api/fleet/ingest-event",
|
|
@@ -164,7 +218,10 @@ export async function attachAuthGate(
|
|
|
164
218
|
// The previous form passed the ORPC server client (`getServerClient()`) cast
|
|
165
219
|
// `as never` as `prisma:` — no `$executeRaw`, so recordEvent silently failed.
|
|
166
220
|
// Retention is owned by the event-log-sweep pg-boss job.
|
|
167
|
-
|
|
221
|
+
// Resolved ONCE at boot and closed over by the request path below.
|
|
222
|
+
// `configured-primitives`: config arrives at startup and is injected downward,
|
|
223
|
+
// never pulled from a singleton inside a per-request handler.
|
|
224
|
+
const { prisma: gatePrisma } = initGateDependencies();
|
|
168
225
|
const { registerAuthFactory, getAuth } = await import(
|
|
169
226
|
"@teamscala/auth/auth-registry.ts"
|
|
170
227
|
);
|
|
@@ -296,7 +353,12 @@ export async function attachAuthGate(
|
|
|
296
353
|
const url = new URL(c.req.url);
|
|
297
354
|
const isInfra =
|
|
298
355
|
PUBLIC_INFRA_PATHS.has(url.pathname) ||
|
|
299
|
-
PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p))
|
|
356
|
+
PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p)) ||
|
|
357
|
+
// Decided by EXTENSION so a new asset directory cannot be private by
|
|
358
|
+
// default. The enumerated prefix list is what left /logos/*.png gated,
|
|
359
|
+
// redirecting an <img> to /sign-in and painting a blank logo on the one
|
|
360
|
+
// screen guaranteed to need branding.
|
|
361
|
+
isStaticAssetPath(url.pathname);
|
|
300
362
|
if (isInfra) return next();
|
|
301
363
|
|
|
302
364
|
// NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
|
|
@@ -360,7 +422,7 @@ export async function attachAuthGate(
|
|
|
360
422
|
// caller of the single permission decision point (unified permission system
|
|
361
423
|
// foundation, work_items 019f7d6b) — replaces the bespoke inline query.
|
|
362
424
|
const decision = await resolveAccess({
|
|
363
|
-
prisma:
|
|
425
|
+
prisma: gatePrisma as never,
|
|
364
426
|
userId: session.user.id,
|
|
365
427
|
allowedOrganisationIds: allowed,
|
|
366
428
|
});
|
|
@@ -167,7 +167,7 @@ describe("computeInputFingerprint", () => {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
// Regression guard for the 2026-07-12 scala-agents stale-bundle bug. A boot
|
|
170
|
-
// that builds MID-INSTALL (
|
|
170
|
+
// that builds MID-INSTALL (dep-sync restarts after writing the new
|
|
171
171
|
// bun.lock but before node_modules extraction finishes) bakes OLD @teamscala/*
|
|
172
172
|
// JS while the lock-derived fingerprint records "current" — so every later
|
|
173
173
|
// restart recomputes the same fingerprint, skips the rebuild, and serves the
|
|
@@ -84,7 +84,7 @@ export async function readClientBuildStatus(
|
|
|
84
84
|
|
|
85
85
|
// Single shared host lock serialising the heavy boot-time client build across
|
|
86
86
|
// ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
|
|
87
|
-
// widely-consumed package makes the
|
|
87
|
+
// widely-consumed package makes the dep-sync restart every consumer at
|
|
88
88
|
// once; without this lock each one rebuilds its hydrate bundle + Tailwind CSS
|
|
89
89
|
// simultaneously and N concurrent builds saturate the host (load 36, each ~15s
|
|
90
90
|
// build starved to ~10min, services offline — 2026-06-13). The build sibling of
|
package/src/discover-blocks.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
|
|
26
|
-
import { getServerClient } from "
|
|
26
|
+
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
27
27
|
import * as v from "valibot";
|
|
28
28
|
import type { RendererComponentEntry } from "./types.ts";
|
|
29
29
|
|
package/src/fn-routers/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { tasksRouter } from "./tasks-router.ts";
|
|
|
37
37
|
import { userPreferencesRouter } from "./user-preferences-router.ts";
|
|
38
38
|
import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
|
|
39
39
|
import { workItemsRouter } from "./work-items-router.ts";
|
|
40
|
+
import { orchestratorRegistrationRouter } from "./orchestrator-registration-router.ts";
|
|
40
41
|
|
|
41
42
|
export function registerFnRouters(): void {
|
|
42
43
|
registerCustomRouter("fn", {
|
|
@@ -53,6 +54,7 @@ export function registerFnRouters(): void {
|
|
|
53
54
|
tasks: tasksRouter,
|
|
54
55
|
taskSync: taskSyncRouter,
|
|
55
56
|
workItems: workItemsRouter,
|
|
57
|
+
orchestratorRegistration: orchestratorRegistrationRouter,
|
|
56
58
|
userPreferences: userPreferencesRouter,
|
|
57
59
|
portalPreferences: portalPreferencesRouter,
|
|
58
60
|
plans: plansRouter,
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
bindTaskToKnownSession,
|
|
21
21
|
buildAssignmentSpawnPayload,
|
|
22
22
|
resolveAssignOrg,
|
|
23
|
+
resolveBindAction,
|
|
23
24
|
} from "./task-sync-assign.ts";
|
|
25
|
+
import type { WorkItem } from "./task-sync-router.ts";
|
|
24
26
|
|
|
25
27
|
/** A minimal fake prisma exposing only $transaction + the two creates the fn uses. */
|
|
26
28
|
function fakePrisma(overrides: { platformOrg?: string } = {}) {
|
|
@@ -47,7 +49,7 @@ function fakePrisma(overrides: { platformOrg?: string } = {}) {
|
|
|
47
49
|
comments: {
|
|
48
50
|
create: async (a: { data: Record<string, unknown> }) => {
|
|
49
51
|
calls.push({ workItem: null, comment: a.data });
|
|
50
|
-
return a.data;
|
|
52
|
+
return { ...a.data, id: "comment-new-1" };
|
|
51
53
|
},
|
|
52
54
|
},
|
|
53
55
|
};
|
|
@@ -90,6 +92,10 @@ describe("assignTaskInput (incomplete task is unconstructible)", () => {
|
|
|
90
92
|
});
|
|
91
93
|
expect(out.success).toBe(true);
|
|
92
94
|
});
|
|
95
|
+
|
|
96
|
+
it("REJECTS the generic 'Lane' title (assignment mint — same gate as createBriefTask)", () => {
|
|
97
|
+
expect(v.safeParse(assignTaskInput, { name: "Lane", description: "d" }).success).toBe(false);
|
|
98
|
+
});
|
|
93
99
|
});
|
|
94
100
|
|
|
95
101
|
describe("createBriefTaskInput (the spawner's mint entry — incomplete task is unconstructible)", () => {
|
|
@@ -121,6 +127,31 @@ describe("createBriefTaskInput (the spawner's mint entry — incomplete task is
|
|
|
121
127
|
const out = v.safeParse(createBriefTaskInput, { name: "Explore", description: "d" });
|
|
122
128
|
expect(out.success).toBe(true);
|
|
123
129
|
});
|
|
130
|
+
|
|
131
|
+
// "Lane" is the generic sentinel a spawn that forgot to name its lane falls
|
|
132
|
+
// back to. A row titled "Lane" is spawn debris an orchestrator cannot
|
|
133
|
+
// attribute — rejected at the mint chokepoint so the malformed spawn is
|
|
134
|
+
// refused, never written (lanes-named-by-the-spawner).
|
|
135
|
+
it("REJECTS the generic 'Lane' title — spawn debris is unconstructible", () => {
|
|
136
|
+
expect(
|
|
137
|
+
v.safeParse(createBriefTaskInput, { name: "Lane", description: "d" }).success,
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects 'Lane' case- and whitespace-insensitively", () => {
|
|
142
|
+
expect(
|
|
143
|
+
v.safeParse(createBriefTaskInput, { name: " lane ", description: "d" }).success,
|
|
144
|
+
).toBe(false);
|
|
145
|
+
expect(
|
|
146
|
+
v.safeParse(createBriefTaskInput, { name: "LANE", description: "d" }).success,
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("accepts a real work name (e.g. the lane's slot_name)", () => {
|
|
151
|
+
expect(
|
|
152
|
+
v.safeParse(createBriefTaskInput, { name: "Bus handlers", description: "d" }).success,
|
|
153
|
+
).toBe(true);
|
|
154
|
+
});
|
|
124
155
|
});
|
|
125
156
|
|
|
126
157
|
describe("createTaskWithBriefComment (atomic task + first comment = brief)", () => {
|
|
@@ -187,6 +218,7 @@ describe("createTaskWithBriefComment (atomic task + first comment = brief)", ()
|
|
|
187
218
|
describe("adoptAssignmentTask (SessionStart adopts the pre-created task + ownership)", () => {
|
|
188
219
|
function fakeAdoptPrisma(existing: Record<string, unknown> | null) {
|
|
189
220
|
const updates: Array<{ id: string } & Record<string, unknown>> = [];
|
|
221
|
+
const updateManyCalls: unknown[] = [];
|
|
190
222
|
const prisma = {
|
|
191
223
|
work_items: {
|
|
192
224
|
findFirst: async () => existing,
|
|
@@ -195,9 +227,13 @@ describe("adoptAssignmentTask (SessionStart adopts the pre-created task + owners
|
|
|
195
227
|
updates.push(row);
|
|
196
228
|
return row;
|
|
197
229
|
},
|
|
230
|
+
updateMany: async (a: unknown) => {
|
|
231
|
+
updateManyCalls.push(a);
|
|
232
|
+
return { count: 0 };
|
|
233
|
+
},
|
|
198
234
|
},
|
|
199
235
|
};
|
|
200
|
-
return { prisma, updates };
|
|
236
|
+
return { prisma, updates, updateManyCalls };
|
|
201
237
|
}
|
|
202
238
|
|
|
203
239
|
it("writes the lane session onto the pre-created task AND sets assignee_id (ownership)", async () => {
|
|
@@ -224,12 +260,22 @@ describe("adoptAssignmentTask (SessionStart adopts the pre-created task + owners
|
|
|
224
260
|
expect(kd.lane.session).toBe("s");
|
|
225
261
|
});
|
|
226
262
|
|
|
227
|
-
it("
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
263
|
+
it("RE-BINDS a task a stale session owns to the fresh lane (pane slot recovery)", async () => {
|
|
264
|
+
// Migration 109 makes (kind_data->'lane'->>'pane') unique WHERE active, so a
|
|
265
|
+
// re-spawn on a pane whose prior task is still active under a dead session
|
|
266
|
+
// cannot create a new task (P2002). adopt re-binds the assignment task to
|
|
267
|
+
// the fresh session + releases the stale pane slot first. A stale session
|
|
268
|
+
// is by definition dead, so the task belongs to whichever lane was
|
|
269
|
+
// dispatched to it — re-binding is correct recovery, not theft.
|
|
270
|
+
const task = { id: "t", kind: "task", kind_data: { lane: { session: "someone-else", pane: "tmux:%9" } } };
|
|
271
|
+
const { prisma, updates, updateManyCalls } = fakeAdoptPrisma(task);
|
|
272
|
+
const out = await adoptAssignmentTask(prisma as never, "t", { session: "fresh", pane: "tmux:%9" });
|
|
273
|
+
expect(out).not.toBeNull();
|
|
274
|
+
expect(updates).toHaveLength(1);
|
|
275
|
+
expect((updates[0].kind_data as { lane: { session: string } }).lane.session).toBe("fresh");
|
|
276
|
+
expect(updates[0].assignee_id).toBe("fresh");
|
|
277
|
+
// The stale pane slot was released before the re-bind (STEAL-before-CLAIM).
|
|
278
|
+
expect(updateManyCalls).toHaveLength(1);
|
|
233
279
|
});
|
|
234
280
|
|
|
235
281
|
it("re-adopts a task already bound to THIS session (idempotent re-bind)", async () => {
|
|
@@ -351,6 +397,7 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
|
|
|
351
397
|
const out = buildAssignmentSpawnPayload(task, { slug: "cc-glm52" });
|
|
352
398
|
expect("initialPrompt" in out.payload).toBe(false);
|
|
353
399
|
expect(out.payload).not.toHaveProperty("initialPrompt");
|
|
400
|
+
expect("cliSessionId" in out.payload).toBe(false);
|
|
354
401
|
});
|
|
355
402
|
|
|
356
403
|
it("omits optional spawn fields when not supplied (no undefined keys)", () => {
|
|
@@ -366,3 +413,71 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
|
|
|
366
413
|
expect(out.payload.idempotencyKey).toBe("k-1");
|
|
367
414
|
});
|
|
368
415
|
});
|
|
416
|
+
|
|
417
|
+
describe("resolveBindAction (a spawn that cannot bind fails loudly, never orphans)", () => {
|
|
418
|
+
// The `absent-input-reads-as-success` failure this closes: a spawn carried a
|
|
419
|
+
// workItemId the lane should adopt, but no task with that id exists. The old
|
|
420
|
+
// path silently created a NEW task, abandoning the spawn's referenced brief
|
|
421
|
+
// and binding the lane to an empty task it was never meant to own — an orphan
|
|
422
|
+
// an orchestrator cannot tell from real unowned work. bindLane now REFUSES on
|
|
423
|
+
// this outcome; this is the pure, unit-testable decision behind that refusal.
|
|
424
|
+
function taskRow(id: string): WorkItem {
|
|
425
|
+
return {
|
|
426
|
+
id,
|
|
427
|
+
kind: "task",
|
|
428
|
+
parent_id: null,
|
|
429
|
+
organisation_id: "org",
|
|
430
|
+
title: "T",
|
|
431
|
+
status: "active",
|
|
432
|
+
position: null,
|
|
433
|
+
completed: null,
|
|
434
|
+
kind_data: {},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
it("uses the adopted task when adopt succeeds", () => {
|
|
439
|
+
const t = taskRow("t1");
|
|
440
|
+
const out = resolveBindAction({ adopted: t, workItemId: "t1", referencedKind: "task" });
|
|
441
|
+
expect(out.action).toBe("use");
|
|
442
|
+
if (out.action === "use") expect(out.task.id).toBe("t1");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("creates for an exploratory lane (no workItemId supplied)", () => {
|
|
446
|
+
const out = resolveBindAction({ adopted: null, workItemId: null, referencedKind: null });
|
|
447
|
+
expect(out.action).toBe("create");
|
|
448
|
+
if (out.action === "create") expect(out.scope).toBe("exploratory");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("creates under a PROJECT parent (workItemId resolves to a project, not a task)", () => {
|
|
452
|
+
const out = resolveBindAction({
|
|
453
|
+
adopted: null,
|
|
454
|
+
workItemId: "proj-1",
|
|
455
|
+
referencedKind: "project",
|
|
456
|
+
});
|
|
457
|
+
expect(out.action).toBe("create");
|
|
458
|
+
if (out.action === "create") expect(out.scope).toBe("project-parent");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("REFUSES when the spawn named a task that does not exist — no orphan created", () => {
|
|
462
|
+
// THE owed test: a spawn carrying a workItemId that resolves to NO task
|
|
463
|
+
// must fail (refuse) instead of silently creating a new orphan task.
|
|
464
|
+
const out = resolveBindAction({
|
|
465
|
+
adopted: null,
|
|
466
|
+
workItemId: "ghost-9",
|
|
467
|
+
referencedKind: null,
|
|
468
|
+
});
|
|
469
|
+
expect(out.action).toBe("refuse");
|
|
470
|
+
if (out.action === "refuse") {
|
|
471
|
+
expect(out.reason).toBe("missing-referenced-task");
|
|
472
|
+
expect(out.workItemId).toBe("ghost-9");
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("prefers a successful adopt even when a (stale) referenced kind is present", () => {
|
|
477
|
+
// adopt already writes the session + assignee_id; once it succeeds the
|
|
478
|
+
// referenced-kind probe is irrelevant.
|
|
479
|
+
const t = taskRow("t1");
|
|
480
|
+
const out = resolveBindAction({ adopted: t, workItemId: "t1", referencedKind: null });
|
|
481
|
+
expect(out.action).toBe("use");
|
|
482
|
+
});
|
|
483
|
+
});
|
|
@@ -73,11 +73,25 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
|
73
73
|
* lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
|
|
74
74
|
* platform org (fleet/internal lane tasks live there).
|
|
75
75
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
/**
|
|
77
|
+
* A lane task's NAME — the lane's real work label, never the generic "Lane"
|
|
78
|
+
* sentinel. A row titled "Lane" is spawn debris: it tells the operator nothing
|
|
79
|
+
* about the work, so an orchestrator cannot tell real unowned work from it (the
|
|
80
|
+
* signal the fleet steers by). Rejected at the ORPC input chokepoint so a spawn
|
|
81
|
+
* that forgot to name its lane is REFUSED at the mint, never written
|
|
82
|
+
* (lanes-named-by-the-spawner — the spawner sets the work name at creation).
|
|
83
|
+
*/
|
|
84
|
+
const laneWorkName = v.pipe(
|
|
85
|
+
v.string(),
|
|
86
|
+
v.minLength(1, "name is required — the lane's real work label"),
|
|
87
|
+
v.check(
|
|
88
|
+
(n) => n.trim().toLowerCase() !== "lane",
|
|
89
|
+
"name must carry the lane's real work name, not the generic sentinel 'Lane'",
|
|
80
90
|
),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export const assignTaskInput = v.object({
|
|
94
|
+
name: laneWorkName,
|
|
81
95
|
description: v.pipe(
|
|
82
96
|
v.string(),
|
|
83
97
|
v.minLength(1, "description is required — it is the brief (first comment)"),
|
|
@@ -167,7 +181,7 @@ export async function createTaskWithBriefComment(
|
|
|
167
181
|
}).$transaction(async (tx) => {
|
|
168
182
|
const t = tx as {
|
|
169
183
|
work_items: { create: (a: unknown) => Promise<WorkItem> };
|
|
170
|
-
comments: { create: (a: unknown) => Promise<
|
|
184
|
+
comments: { create: (a: unknown) => Promise<{ id: string }> };
|
|
171
185
|
};
|
|
172
186
|
const task = await t.work_items.create({
|
|
173
187
|
data: {
|
|
@@ -189,14 +203,14 @@ export async function createTaskWithBriefComment(
|
|
|
189
203
|
},
|
|
190
204
|
});
|
|
191
205
|
// The description IS the first comment IS the brief — one write, one source.
|
|
192
|
-
await t.comments.create({
|
|
206
|
+
const firstComment = await t.comments.create({
|
|
193
207
|
data: {
|
|
194
208
|
work_item_id: task.id,
|
|
195
209
|
content: input.description,
|
|
196
210
|
user_id: userId,
|
|
197
211
|
},
|
|
198
212
|
});
|
|
199
|
-
return task;
|
|
213
|
+
return { ...task, first_comment_id: firstComment.id } as WorkItem & { first_comment_id: string };
|
|
200
214
|
});
|
|
201
215
|
}
|
|
202
216
|
|
|
@@ -215,11 +229,18 @@ export async function createTaskWithBriefComment(
|
|
|
215
229
|
* implement`). "Is the caller the assignee" becomes a simple equality on the
|
|
216
230
|
* session.
|
|
217
231
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
232
|
+
* RE-BINDS a task another session already owns. Migration 109
|
|
233
|
+
* (`work_items_one_active_task_per_lane`) makes `(kind_data->'lane'->>'pane')`
|
|
234
|
+
* unique WHERE active, so when a lane is re-spawned on a pane whose prior task is
|
|
235
|
+
* still active under a STALE session (the lane died, the row survived),
|
|
236
|
+
* `resolveLaneTask(newSession)` finds nothing and `create` P2002s — measured
|
|
237
|
+
* fleet-wide 2026-08-01: every real lane's bindLane 500'd on this constraint.
|
|
238
|
+
* Refusing to steal (the original design) routes every re-spawn into that
|
|
239
|
+
* dead-end. An assignment task belongs to whichever lane was dispatched to it,
|
|
240
|
+
* and a stale session is by definition dead, so re-binding to the fresh session
|
|
241
|
+
* is the correct recovery: the pane's unique slot moves to the live lane and
|
|
242
|
+
* the brief follows. Returns null only when `maybeTaskId` is absent or not a
|
|
243
|
+
* task — caller falls through to create for an exploratory lane (no workItemId).
|
|
223
244
|
*/
|
|
224
245
|
export async function adoptAssignmentTask(
|
|
225
246
|
prisma: Prisma,
|
|
@@ -234,10 +255,6 @@ export async function adoptAssignmentTask(
|
|
|
234
255
|
// (which a spawn also carries as workItemId) must never be adopted, only
|
|
235
256
|
// created-under. Verified, not assumed (`a-component-may-not-report-a-state...`).
|
|
236
257
|
if (row.kind !== "task") return null;
|
|
237
|
-
const existingSession = (
|
|
238
|
-
(row.kind_data as { lane?: { session?: string } } | null) ?? {}
|
|
239
|
-
).lane?.session;
|
|
240
|
-
if (existingSession && existingSession !== lane.session) return null;
|
|
241
258
|
const laneData = {
|
|
242
259
|
pane: lane.pane,
|
|
243
260
|
session: lane.session,
|
|
@@ -249,6 +266,37 @@ export async function adoptAssignmentTask(
|
|
|
249
266
|
lane: laneData,
|
|
250
267
|
assignee_type: "agent",
|
|
251
268
|
};
|
|
269
|
+
// RELEASE any OTHER active task holding this pane's unique lane slot before
|
|
270
|
+
// writing — migration 109's unique index would otherwise P2002 on the update
|
|
271
|
+
// when a stale task (prior session, same pane) is still active. Same logic
|
|
272
|
+
// as the run-link STEAL-before-CLAIM: a pane's lane slot is singly-owned, so
|
|
273
|
+
// every other row holding it is this lane's own stale prior (or a duplicate the
|
|
274
|
+
// race created) and is released to null-pane so this update can land. The
|
|
275
|
+
// assignment task itself is then re-bound to the fresh session.
|
|
276
|
+
try {
|
|
277
|
+
if (lane.pane) {
|
|
278
|
+
const others = (
|
|
279
|
+
prisma as unknown as {
|
|
280
|
+
work_items?: {
|
|
281
|
+
updateMany: (a: unknown) => Promise<{ count: number }>;
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
).work_items;
|
|
285
|
+
await others?.updateMany({
|
|
286
|
+
where: {
|
|
287
|
+
kind: "task",
|
|
288
|
+
status: "active",
|
|
289
|
+
id: { not: row.id },
|
|
290
|
+
kind_data: { path: ["lane", "pane"], equals: lane.pane },
|
|
291
|
+
},
|
|
292
|
+
data: { kind_data: { lane: { pane: null } } },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// Best-effort: a release failure does not block the adopt — the update below
|
|
297
|
+
// will throw its own P2002 and the handler falls through, converging on the
|
|
298
|
+
// next bind. Logged by the caller's run-link region, not here.
|
|
299
|
+
}
|
|
252
300
|
try {
|
|
253
301
|
return await prisma.work_items.update({
|
|
254
302
|
where: { id: row.id },
|
|
@@ -259,6 +307,45 @@ export async function adoptAssignmentTask(
|
|
|
259
307
|
}
|
|
260
308
|
}
|
|
261
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The bind decision AFTER attempting to adopt the spawn's referenced task. PURE
|
|
312
|
+
* over the adopt result + whether the referenced row exists, so the
|
|
313
|
+
* "a spawn that cannot bind fails loudly rather than orphaning" invariant
|
|
314
|
+
* (`absent-input-reads-as-success`) is unit-testable without standing up the
|
|
315
|
+
* ORPC handler. `bindLane` calls `adoptAssignmentTask` then this, then acts.
|
|
316
|
+
*
|
|
317
|
+
* `use` — adopt succeeded; bind the lane to that task.
|
|
318
|
+
* `create` — no task to adopt: an exploratory lane (no workItemId) or the
|
|
319
|
+
* referenced id is a PROJECT the task is created under.
|
|
320
|
+
* `refuse` — the spawn named a workItemId the lane should adopt, but NO task
|
|
321
|
+
* exists with that id. Silently creating a NEW task here would
|
|
322
|
+
* abandon the spawn's referenced brief and bind the lane to an
|
|
323
|
+
* empty task it was never meant to own — exactly the orphan
|
|
324
|
+
* `absent-input-reads-as-success` forbids (a caller that stated
|
|
325
|
+
* adopt-intent reads "success" while the referenced row rots).
|
|
326
|
+
* Failing loudly surfaces the malformed spawn instead.
|
|
327
|
+
*/
|
|
328
|
+
export type BindAction =
|
|
329
|
+
| { action: "use"; task: WorkItem }
|
|
330
|
+
| { action: "create"; scope: "exploratory" | "project-parent" }
|
|
331
|
+
| { action: "refuse"; reason: "missing-referenced-task"; workItemId: string };
|
|
332
|
+
|
|
333
|
+
export function resolveBindAction(args: {
|
|
334
|
+
adopted: WorkItem | null;
|
|
335
|
+
workItemId: string | null;
|
|
336
|
+
/** The kind of the row at workItemId, or null when no such row exists. */
|
|
337
|
+
referencedKind: string | null;
|
|
338
|
+
}): BindAction {
|
|
339
|
+
if (args.adopted) return { action: "use", task: args.adopted };
|
|
340
|
+
if (!args.workItemId) return { action: "create", scope: "exploratory" };
|
|
341
|
+
// A workItemId was provided but adopt returned null: either it resolves to a
|
|
342
|
+
// non-task (a PROJECT — create a task under it) or to nothing (refuse).
|
|
343
|
+
if (args.referencedKind === null) {
|
|
344
|
+
return { action: "refuse", reason: "missing-referenced-task", workItemId: args.workItemId };
|
|
345
|
+
}
|
|
346
|
+
return { action: "create", scope: "project-parent" };
|
|
347
|
+
}
|
|
348
|
+
|
|
262
349
|
/**
|
|
263
350
|
* Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
|
|
264
351
|
* CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
|
|
@@ -342,7 +429,7 @@ export interface AssignmentResult {
|
|
|
342
429
|
* idempotencyKey), which belong on the spawn emission, not the mint.
|
|
343
430
|
*/
|
|
344
431
|
export const createBriefTaskInput = v.object({
|
|
345
|
-
name:
|
|
432
|
+
name: laneWorkName,
|
|
346
433
|
description: v.pipe(
|
|
347
434
|
v.string(),
|
|
348
435
|
v.minLength(1, "description is required — it is the brief (first comment)"),
|
|
@@ -384,7 +471,7 @@ export const createBriefTaskHandler = authed.handler(async ({ input, context })
|
|
|
384
471
|
},
|
|
385
472
|
userId,
|
|
386
473
|
);
|
|
387
|
-
return { work_item_id: task.id };
|
|
474
|
+
return { work_item_id: task.id, comment_id: (task as WorkItem & { first_comment_id: string }).first_comment_id };
|
|
388
475
|
} catch (err) {
|
|
389
476
|
// oRPC's default swallows a handler throw into a generic 500 with no stack;
|
|
390
477
|
// emit it so a mint failure is diagnosable, not a black box
|