@teamscala/service-boot 0.23.430 → 0.23.432
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 +12 -19
- package/src/agreement-gate/signing-shell.ts +19 -15
- package/src/attach-proposal-accept.ts +5 -2
- package/src/auth-gate/public-paths.ts +7 -16
- package/src/auth-gate.ts +54 -42
- package/src/bind-page-handler.ts +20 -32
- package/src/build-client-assets/build-lock.ts +15 -6
- package/src/build-client-assets/hashed-output.ts +3 -1
- package/src/build-client-assets/hydrate-bundle.ts +59 -57
- package/src/build-client-assets/input-fingerprint.ts +50 -57
- package/src/build-client-assets/input-hashing.ts +2 -4
- package/src/build-client-assets/manifest.ts +8 -11
- package/src/build-client-assets/source-candidates.ts +8 -16
- package/src/build-client-assets/sourcemaps-upload.ts +17 -10
- package/src/build-client-assets/styles-build.ts +17 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +16 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +26 -18
- package/src/content-gate/gate.ts +7 -3
- package/src/discover-blocks.ts +5 -7
- package/src/fn-routers/agreement-certificate.ts +1 -1
- package/src/fn-routers/agreement-send-router.ts +5 -4
- package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
- package/src/fn-routers/assessment-report-email.ts +3 -2
- package/src/fn-routers/auth-router.ts +20 -21
- package/src/fn-routers/chat-session-router-shared.ts +12 -3
- package/src/fn-routers/chat-session-router.ts +8 -6
- package/src/fn-routers/conversation-ownership.ts +3 -2
- package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
- package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
- package/src/fn-routers/persist-chat-messages.ts +6 -4
- package/src/fn-routers/proposal-send-router.ts +4 -3
- package/src/fn-routers/public-agreement-router.ts +5 -4
- package/src/fn-routers/public-assessment-router.ts +5 -4
- package/src/fn-routers/public-gpt-session-router.ts +17 -12
- package/src/fn-routers/user-preferences-router.ts +6 -15
- package/src/fn-routers/work-items-router.ts +21 -17
- package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
- package/src/handlers/platform-fault-escalation-logic.ts +26 -5
- package/src/handlers/platform-fault-escalation.test.ts +67 -15
- package/src/handlers/platform-fault-escalation.ts +16 -0
- package/src/handlers/projects-digest.ts +1 -1
- package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
- package/src/handlers/scala-tools-currency-watch.ts +23 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +27 -19
- package/src/handlers/upstream-issue-sync.ts +11 -4
- package/src/handlers/whatsapp-liveness.ts +14 -4
- package/src/install-harness.ts +1 -1
- package/src/install-logging.ts +1 -1
- package/src/mount-orpc-router.test.ts +56 -10
- package/src/mount-orpc-router.ts +23 -9
- package/src/mount-server-packages.ts +17 -15
- package/src/pg-boss-mount.ts +5 -12
- package/src/proposal-gate.ts +7 -2
- package/src/read-html-template.test.ts +12 -15
- package/src/read-html-template.ts +2 -1
- package/src/readiness-gate.ts +2 -1
- package/src/register-crash-handlers.ts +4 -8
- package/src/register-page-server-client.ts +15 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
- package/src/register-registry-page-route/apply-page-branding.ts +68 -56
- package/src/register-registry-page-route/graph-cache.ts +22 -18
- package/src/register-registry-page-route/meta-title.ts +24 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
- package/src/register-registry-page-route/run-content-gates.ts +2 -4
- package/src/register-registry-page-route.ts +1 -2
- package/src/register-static-assets.ts +6 -5
- package/src/resolve-app-base-url.ts +12 -5
- package/src/resolvers/portal-route-paths.ts +15 -11
- package/src/resource-chat-inject.ts +84 -72
- package/src/resource-not-found.ts +23 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +61 -29
- package/src/two-phase-minimal-app.ts +39 -31
- package/src/two-phase-readiness.ts +50 -38
- package/src/ui-service.ts +50 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- package/src/handlers/prune-stale-data.ts +0 -110
|
@@ -15,6 +15,27 @@
|
|
|
15
15
|
* behaviour the board wants.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Org-scoped member list for the task-comments panel — @mention matching AND
|
|
20
|
+
* comment-sender display (name + avatar). ChatPanel.tsx has no
|
|
21
|
+
* serverLoader/orgId prop (client-only overlay content), so it can't do the
|
|
22
|
+
* SSR raw-SQL org-scope join load-project-list-view.ts uses; this exposes
|
|
23
|
+
* the same result via a custom procedure instead of widening service_schema
|
|
24
|
+
* to the raw better_auth_member table.
|
|
25
|
+
*
|
|
26
|
+
* This is the SINGLE source of member display info for the comments panel:
|
|
27
|
+
* it coalesces BOTH name/email/avatar families on the user model into one
|
|
28
|
+
* shape (the `full_name`/`primary_email`/`avatar_url` family AND the
|
|
29
|
+
* better-auth `name`/`email`/`image` family). Returning avatar here lets the
|
|
30
|
+
* client resolve sender name+avatar from this one list and drop the separate
|
|
31
|
+
* `user.findMany` enrichment fetch it previously ran as a second waterfall
|
|
32
|
+
* hop after `comments.findMany` (loading-shape: one wave-parallel hit, not a
|
|
33
|
+
* dependent client-side round-trip chain).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// Why: Org-scoped member list for the task-comments panel — @mentio…
|
|
37
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
38
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
18
39
|
import { createEmailClient } from "@teamscala/email-client/client";
|
|
19
40
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
20
41
|
import { authed, type Context } from "./auth-router.ts";
|
|
@@ -227,23 +248,6 @@ export const workItemsRouter = {
|
|
|
227
248
|
return { notified: 0 };
|
|
228
249
|
}
|
|
229
250
|
}),
|
|
230
|
-
/**
|
|
231
|
-
* Org-scoped member list for the task-comments panel — @mention matching AND
|
|
232
|
-
* comment-sender display (name + avatar). ChatPanel.tsx has no
|
|
233
|
-
* serverLoader/orgId prop (client-only overlay content), so it can't do the
|
|
234
|
-
* SSR raw-SQL org-scope join load-project-list-view.ts uses; this exposes
|
|
235
|
-
* the same result via a custom procedure instead of widening service_schema
|
|
236
|
-
* to the raw better_auth_member table.
|
|
237
|
-
*
|
|
238
|
-
* This is the SINGLE source of member display info for the comments panel:
|
|
239
|
-
* it coalesces BOTH name/email/avatar families on the user model into one
|
|
240
|
-
* shape (the `full_name`/`primary_email`/`avatar_url` family AND the
|
|
241
|
-
* better-auth `name`/`email`/`image` family). Returning avatar here lets the
|
|
242
|
-
* client resolve sender name+avatar from this one list and drop the separate
|
|
243
|
-
* `user.findMany` enrichment fetch it previously ran as a second waterfall
|
|
244
|
-
* hop after `comments.findMany` (loading-shape: one wave-parallel hit, not a
|
|
245
|
-
* dependent client-side round-trip chain).
|
|
246
|
-
*/
|
|
247
251
|
orgMembers: authed.handler(async ({ input, context }) => {
|
|
248
252
|
const { orgId } = input as { orgId: string };
|
|
249
253
|
const prisma = (context as Context).prisma as {
|
|
@@ -72,6 +72,12 @@ export async function deliverEscalation(
|
|
|
72
72
|
last_seen_at: aggregate.lastSeen.toISOString(),
|
|
73
73
|
event_count: aggregate.count,
|
|
74
74
|
resolved_at: null,
|
|
75
|
+
// CLEARING THIS IS WHAT STOPS AN ESCALATION STORM. A re-armed row
|
|
76
|
+
// keeps resolved_at NULL, so it still loads as open, and
|
|
77
|
+
// `escalate` fires for any episode that is resolved OR re-armed.
|
|
78
|
+
// Leaving rearmed_at set would therefore re-escalate the same kind
|
|
79
|
+
// on EVERY sweep — the firing state must be consumed by the firing.
|
|
80
|
+
rearmed_at: null,
|
|
75
81
|
},
|
|
76
82
|
});
|
|
77
83
|
|
|
@@ -165,6 +171,36 @@ export async function deliverEscalation(
|
|
|
165
171
|
return { messageId, workItemId: config.orchestrator_work_item_id };
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Re-arm an episode whose kind went quiet but whose condition NOBODY CHECKED.
|
|
176
|
+
* The alarm becomes audible again and no claim is made that the fault
|
|
177
|
+
* stopped, so there is deliberately NO operator comment: "Fault stopped" on
|
|
178
|
+
* the strength of silence is the sentence this change exists to stop writing.
|
|
179
|
+
* The event is still recorded — re-arming is a state change. See migration 811.
|
|
180
|
+
*/
|
|
181
|
+
export async function rearmEpisode(
|
|
182
|
+
episode: EscalationEpisode,
|
|
183
|
+
quietMinutes: number,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const now = new Date();
|
|
186
|
+
await (await orpc()).platform_fault_escalations.update({
|
|
187
|
+
where: { kind: episode.kind },
|
|
188
|
+
data: { rearmed_at: now.toISOString(), updated_at: now.toISOString() },
|
|
189
|
+
});
|
|
190
|
+
recordEvent({
|
|
191
|
+
kind: "platform.fault.rearmed",
|
|
192
|
+
payload: {
|
|
193
|
+
event_kind: episode.kind,
|
|
194
|
+
quiet_minutes: Math.round(quietMinutes),
|
|
195
|
+
work_item_id: episode.workItemId,
|
|
196
|
+
// Named so a reader never mistakes this for a resolution: no probe
|
|
197
|
+
// exists for this kind, so nothing verified the condition ended.
|
|
198
|
+
verified: false,
|
|
199
|
+
reason: "no capability probe declared for this kind",
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
export async function resolveEpisode(
|
|
169
205
|
episode: EscalationEpisode,
|
|
170
206
|
quietMinutes: number,
|
|
@@ -11,20 +11,6 @@
|
|
|
11
11
|
* byte-for-byte move; import paths adjusted only.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
15
|
-
import * as v from "valibot";
|
|
16
|
-
|
|
17
|
-
let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
|
|
18
|
-
export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
19
|
-
if (cachedSystemIdentity) return cachedSystemIdentity;
|
|
20
|
-
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
21
|
-
cachedSystemIdentity = {
|
|
22
|
-
userId: row?.system_user_id as string | undefined,
|
|
23
|
-
organisationId: row?.system_org_id as string | undefined,
|
|
24
|
-
};
|
|
25
|
-
return cachedSystemIdentity;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
14
|
/**
|
|
29
15
|
* The system-authed in-process ORPC client, built LAZILY on first use from the
|
|
30
16
|
* SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
|
|
@@ -40,9 +26,42 @@ export async function getSystemIdentity(): Promise<{ userId: string | undefined;
|
|
|
40
26
|
* the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
|
|
41
27
|
* never Date instances (the generated valibot schemas require it).
|
|
42
28
|
*/
|
|
43
|
-
|
|
29
|
+
|
|
30
|
+
// Why: The system-authed in-process ORPC client, built LAZILY on fi…
|
|
31
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
32
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
33
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
34
|
+
import * as v from "valibot";
|
|
35
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
36
|
+
|
|
37
|
+
const systemIdentityCache = createCache<{
|
|
38
|
+
userId: string | undefined;
|
|
39
|
+
organisationId: string | undefined;
|
|
40
|
+
}>("service-boot:fault-escalation-system-identity", {
|
|
41
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
42
|
+
maxSize: 1,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
46
|
+
const hit = systemIdentityCache.get("default");
|
|
47
|
+
if (hit) return hit;
|
|
48
|
+
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
49
|
+
const identity = {
|
|
50
|
+
userId: row?.system_user_id as string | undefined,
|
|
51
|
+
organisationId: row?.system_org_id as string | undefined,
|
|
52
|
+
};
|
|
53
|
+
systemIdentityCache.set("default", identity);
|
|
54
|
+
return identity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const orpcSurfaceCache = createCache<OrpcSurface>(
|
|
58
|
+
"service-boot:fault-escalation-orpc",
|
|
59
|
+
{ ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
|
|
60
|
+
);
|
|
61
|
+
|
|
44
62
|
export async function orpc(): Promise<OrpcSurface> {
|
|
45
|
-
|
|
63
|
+
const hit = orpcSurfaceCache.get("default");
|
|
64
|
+
if (hit) return hit;
|
|
46
65
|
const { createRouterClient } = await import("@orpc/server");
|
|
47
66
|
const { getAppRouter } = await import("@teamscala/orpc/app-router");
|
|
48
67
|
const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
|
|
@@ -61,7 +80,7 @@ export async function orpc(): Promise<OrpcSurface> {
|
|
|
61
80
|
"platform-identity",
|
|
62
81
|
PlatformIdentitySchema,
|
|
63
82
|
);
|
|
64
|
-
|
|
83
|
+
const surface = createRouterClient(
|
|
65
84
|
{ ...appRouter } as never,
|
|
66
85
|
{
|
|
67
86
|
context: {
|
|
@@ -74,7 +93,8 @@ export async function orpc(): Promise<OrpcSurface> {
|
|
|
74
93
|
},
|
|
75
94
|
} as never,
|
|
76
95
|
) as unknown as OrpcSurface;
|
|
77
|
-
|
|
96
|
+
orpcSurfaceCache.set("default", surface);
|
|
97
|
+
return surface;
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
/** The router subset this handler uses — mirrors the generated procedure args. */
|
|
@@ -26,7 +26,13 @@ export interface EscalationEpisode {
|
|
|
26
26
|
eventCount: number;
|
|
27
27
|
escalatedAt: Date | null;
|
|
28
28
|
workItemId: string | null;
|
|
29
|
+
/** VERIFIED over — a capability probe answered OK, or a declared recovery
|
|
30
|
+
* transition arrived. Never written from silence. */
|
|
29
31
|
resolvedAt: Date | null;
|
|
32
|
+
/** The kind went quiet and the alarm is ARMED AGAIN. This is all that
|
|
33
|
+
* quiet ever honestly supported, and it is deliberately weaker than
|
|
34
|
+
* `resolvedAt`: it claims nothing about the condition. */
|
|
35
|
+
rearmedAt: Date | null;
|
|
30
36
|
lastUpdatedAt: Date | null;
|
|
31
37
|
deliveredCommentId?: string | null;
|
|
32
38
|
}
|
|
@@ -51,8 +57,18 @@ export interface KindThresholdOverride {
|
|
|
51
57
|
export interface SweepDecision {
|
|
52
58
|
/** Sustained faults needing a FIRST delivery (new episode, or a claim whose send failed). */
|
|
53
59
|
escalate: Array<{ aggregate: FaultAggregate; episode: EscalationEpisode | null }>;
|
|
54
|
-
/** Open episodes whose kind went quiet past the resolve window — close them.
|
|
60
|
+
/** Open episodes whose kind went quiet past the resolve window — close them.
|
|
61
|
+
* ONLY reachable from the probe gate: quiet alone never populates this. */
|
|
55
62
|
resolve: Array<{ episode: EscalationEpisode; quietMinutes: number; clearZeroVolumeFlag: boolean }>;
|
|
63
|
+
/** Open episodes quiet past the resolve window whose kind declares NO
|
|
64
|
+
* capability probe: the alarm is RE-ARMED (the next occurrence escalates
|
|
65
|
+
* again) and nothing is claimed about the condition. Quiet used to write
|
|
66
|
+
* `resolved_at` and post "Fault stopped", which is the unsound close
|
|
67
|
+
* `emit-transitions-not-conditions` names. It is re-armed rather than
|
|
68
|
+
* merely held open because `escalate` fires only for an absent, resolved
|
|
69
|
+
* or re-armed episode — holding it open would silence the alarm for good.
|
|
70
|
+
* Rationale + measurements: migration 811. */
|
|
71
|
+
rearm: Array<{ episode: EscalationEpisode; quietMinutes: number; clearZeroVolumeFlag: boolean }>;
|
|
56
72
|
/** Open episodes quiet past the resolve window whose producer ALSO went
|
|
57
73
|
* silent: zero failure rows AND zero call volume that would produce them.
|
|
58
74
|
* Quiet is NOT evidence of health here (measured 2026-08-27: llm-router
|
|
@@ -150,14 +166,17 @@ export function decideSweep(
|
|
|
150
166
|
const byKind = new Map(episodes.map((e) => [e.kind, e]));
|
|
151
167
|
const aggregateByKind = new Map(aggregates.map((a) => [a.kind, a]));
|
|
152
168
|
const decision: SweepDecision =
|
|
153
|
-
{ escalate: [], resolve: [], resolveProbeGate: [], zeroVolumeHold: [] };
|
|
169
|
+
{ escalate: [], resolve: [], rearm: [], resolveProbeGate: [], zeroVolumeHold: [] };
|
|
154
170
|
|
|
155
171
|
for (const aggregate of aggregates) {
|
|
156
172
|
const episode = byKind.get(aggregate.kind);
|
|
157
173
|
if (!isSustained(aggregate, policy, kindThresholds[aggregate.kind], now)) continue;
|
|
158
174
|
// No episode, or the previous episode closed (quiet past resolve window):
|
|
159
175
|
// a NEW episode begins and must be delivered.
|
|
160
|
-
|
|
176
|
+
// A RE-ARMED episode starts a new one exactly as a resolved one does.
|
|
177
|
+
// This is the half that keeps the alarm audible: without it, dropping
|
|
178
|
+
// the quiet-close would leave every probe-less kind permanently silent.
|
|
179
|
+
if (!episode || episode.resolvedAt || episode.rearmedAt) {
|
|
161
180
|
decision.escalate.push({ aggregate, episode: episode ?? null });
|
|
162
181
|
continue;
|
|
163
182
|
}
|
|
@@ -174,7 +193,7 @@ export function decideSweep(
|
|
|
174
193
|
}
|
|
175
194
|
|
|
176
195
|
for (const episode of episodes) {
|
|
177
|
-
if (episode.resolvedAt) continue;
|
|
196
|
+
if (episode.resolvedAt || episode.rearmedAt) continue;
|
|
178
197
|
const aggregate = aggregateByKind.get(episode.kind);
|
|
179
198
|
// A future-dated aggregate lastSeen (producer clock skew) cannot serve
|
|
180
199
|
// as the quiet reference — it would freeze quietMinutes negative and
|
|
@@ -218,7 +237,9 @@ export function decideSweep(
|
|
|
218
237
|
});
|
|
219
238
|
continue;
|
|
220
239
|
}
|
|
221
|
-
|
|
240
|
+
// NO PROBE DECLARED: re-arm, never resolve. Quiet re-arms the alarm and
|
|
241
|
+
// says nothing about the condition — see the `rearm` field's comment.
|
|
242
|
+
decision.rearm.push({
|
|
222
243
|
episode,
|
|
223
244
|
quietMinutes,
|
|
224
245
|
clearZeroVolumeFlag: Boolean(source && zeroVolume?.flagActiveByKind[episode.kind]),
|
|
@@ -44,6 +44,7 @@ function episode(overrides: Partial<EscalationEpisode> & { kind: string }): Esca
|
|
|
44
44
|
escalatedAt: new Date(NOW.getTime() - 60 * 60_000),
|
|
45
45
|
workItemId: "wi-1",
|
|
46
46
|
resolvedAt: null,
|
|
47
|
+
rearmedAt: null,
|
|
47
48
|
lastUpdatedAt: null,
|
|
48
49
|
...overrides,
|
|
49
50
|
};
|
|
@@ -117,6 +118,7 @@ describe("decideSweep — dedup (one escalation per fault, no re-fire)", () => {
|
|
|
117
118
|
expect(decision.resolve).toHaveLength(0);
|
|
118
119
|
expect(Object.keys(decision).sort()).toEqual([
|
|
119
120
|
"escalate",
|
|
121
|
+
"rearm",
|
|
120
122
|
"resolve",
|
|
121
123
|
"resolveProbeGate",
|
|
122
124
|
"zeroVolumeHold",
|
|
@@ -133,17 +135,60 @@ describe("decideSweep — dedup (one escalation per fault, no re-fire)", () => {
|
|
|
133
135
|
});
|
|
134
136
|
|
|
135
137
|
describe("decideSweep — resolution + re-arm", () => {
|
|
136
|
-
|
|
138
|
+
// QUIET RE-ARMS, IT DOES NOT RESOLVE. These two asserted `resolve` until
|
|
139
|
+
// 2026-08-29, which is the unsound close: only a probe can say a condition
|
|
140
|
+
// ended, and no probe is declared for these kinds.
|
|
141
|
+
test("quiet past the resolve window RE-ARMS the episode, and claims nothing", () => {
|
|
137
142
|
const gone = episode({ kind: "wake.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
|
|
138
143
|
const decision = decideSweep([], [gone], POLICY, NOW);
|
|
139
|
-
expect(decision.
|
|
140
|
-
expect(decision.
|
|
144
|
+
expect(decision.rearm).toHaveLength(1);
|
|
145
|
+
expect(decision.rearm[0]!.quietMinutes).toBeGreaterThanOrEqual(30);
|
|
146
|
+
expect(decision.resolve).toHaveLength(0);
|
|
141
147
|
});
|
|
142
148
|
|
|
143
|
-
test("an absent kind
|
|
149
|
+
test("an absent kind re-arms by its episode's last_seen, not the sweep clock", () => {
|
|
144
150
|
const gone = episode({ kind: "listen.connect_failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) });
|
|
145
151
|
const decision = decideSweep([], [gone], POLICY, NOW);
|
|
146
|
-
expect(decision.
|
|
152
|
+
expect(decision.rearm).toHaveLength(1);
|
|
153
|
+
expect(decision.resolve).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// THE INVARIANT THAT MAKES RE-ARMING SAFE. If a re-armed episode did not
|
|
157
|
+
// escalate again, dropping the quiet-close would have left every
|
|
158
|
+
// probe-less alarm permanently silent — worse than the false close.
|
|
159
|
+
test("a RE-ARMED episode escalates again when its fault recurs", () => {
|
|
160
|
+
const recurring = agg("guard.fail.open", 50, 40, 0);
|
|
161
|
+
const rearmed = episode({
|
|
162
|
+
kind: "guard.fail.open",
|
|
163
|
+
rearmedAt: new Date(NOW.getTime() - 35 * 60_000),
|
|
164
|
+
});
|
|
165
|
+
const decision = decideSweep([recurring], [rearmed], POLICY, NOW);
|
|
166
|
+
expect(decision.escalate).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// A re-armed episode is already armed; re-arming it every sweep would emit
|
|
170
|
+
// a transition per sweep, which is the repeating-condition shape itself.
|
|
171
|
+
test("an already re-armed episode is not re-armed again while it stays quiet", () => {
|
|
172
|
+
const rearmed = episode({
|
|
173
|
+
kind: "guard.fail.open",
|
|
174
|
+
lastSeenAt: new Date(NOW.getTime() - 60 * 60_000),
|
|
175
|
+
rearmedAt: new Date(NOW.getTime() - 35 * 60_000),
|
|
176
|
+
});
|
|
177
|
+
const decision = decideSweep([], [rearmed], POLICY, NOW);
|
|
178
|
+
expect(decision.rearm).toHaveLength(0);
|
|
179
|
+
expect(decision.resolve).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// A kind WITH a probe keeps the stronger path: it is never re-armed on
|
|
183
|
+
// quiet, because something can actually be asked about it.
|
|
184
|
+
test("a kind declaring a capability probe goes to the probe gate, never to re-arm", () => {
|
|
185
|
+
const gone = episode({ kind: "llm.provider.call.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
|
|
186
|
+
const decision = decideSweep([], [gone], POLICY, NOW, {}, undefined, {
|
|
187
|
+
"llm.provider.call.failed": { url: "http://127.0.0.1:9/health" },
|
|
188
|
+
});
|
|
189
|
+
expect(decision.resolveProbeGate).toHaveLength(1);
|
|
190
|
+
expect(decision.rearm).toHaveLength(0);
|
|
191
|
+
expect(decision.resolve).toHaveLength(0);
|
|
147
192
|
});
|
|
148
193
|
|
|
149
194
|
test("after resolution the same fault escalates again (new episode)", () => {
|
|
@@ -161,6 +206,7 @@ describe("decideSweep — resolution + re-arm", () => {
|
|
|
161
206
|
const open = episode({ kind: "slow.error", lastSeenAt: active.lastSeen });
|
|
162
207
|
const decision = decideSweep([active], [open], POLICY, NOW);
|
|
163
208
|
expect(decision.resolve).toHaveLength(0);
|
|
209
|
+
expect(decision.rearm).toHaveLength(0);
|
|
164
210
|
});
|
|
165
211
|
});
|
|
166
212
|
|
|
@@ -188,7 +234,9 @@ describe("decideSweep — a future-dated lastSeen is not recurrence evidence", (
|
|
|
188
234
|
const phantom = agg("guard.fail.open", 40, 720, -44);
|
|
189
235
|
const decision = decideSweep([phantom], [quietEpisode], POLICY, NOW);
|
|
190
236
|
expect(decision.escalate).toHaveLength(0);
|
|
191
|
-
|
|
237
|
+
// Re-armed rather than resolved: no probe is declared for this kind.
|
|
238
|
+
expect(decision.rearm).toHaveLength(1);
|
|
239
|
+
expect(decision.resolve).toHaveLength(0);
|
|
192
240
|
});
|
|
193
241
|
});
|
|
194
242
|
|
|
@@ -354,19 +402,21 @@ describe("decideSweep — zero-volume gate (quiet vs dead producer)", () => {
|
|
|
354
402
|
expect(decision.zeroVolumeHold[0].volumeCount).toBe(0);
|
|
355
403
|
});
|
|
356
404
|
|
|
357
|
-
test("quiet WITH healthy call volume
|
|
405
|
+
test("quiet WITH healthy call volume re-arms (the normal path for a probe-less kind)", () => {
|
|
358
406
|
const episodes = [episode({ kind: "llm.provider.call.failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) })];
|
|
359
407
|
const inputs = { ...VOLUME_BACKED, countByKind: { "llm.provider.call.failed": 120 } };
|
|
360
408
|
const decision = decideSweep([], episodes, POLICY, NOW, {}, inputs);
|
|
361
409
|
expect(decision.zeroVolumeHold).toHaveLength(0);
|
|
362
|
-
expect(decision.
|
|
363
|
-
expect(decision.resolve
|
|
410
|
+
expect(decision.rearm).toHaveLength(1);
|
|
411
|
+
expect(decision.resolve).toHaveLength(0);
|
|
412
|
+
expect(decision.rearm[0]!.clearZeroVolumeFlag).toBe(false);
|
|
364
413
|
});
|
|
365
414
|
|
|
366
|
-
test("a sourceless kind
|
|
415
|
+
test("a sourceless kind re-arms on quiet", () => {
|
|
367
416
|
const episodes = [episode({ kind: "guard.fail.open", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) })];
|
|
368
417
|
const decision = decideSweep([], episodes, POLICY, NOW, {}, { ...VOLUME_BACKED, countByKind: {} });
|
|
369
|
-
expect(decision.
|
|
418
|
+
expect(decision.rearm).toHaveLength(1);
|
|
419
|
+
expect(decision.resolve).toHaveLength(0);
|
|
370
420
|
expect(decision.zeroVolumeHold).toHaveLength(0);
|
|
371
421
|
});
|
|
372
422
|
|
|
@@ -378,13 +428,14 @@ describe("decideSweep — zero-volume gate (quiet vs dead producer)", () => {
|
|
|
378
428
|
flagActiveByKind: { "llm.provider.call.failed": true },
|
|
379
429
|
};
|
|
380
430
|
const decision = decideSweep([], episodes, POLICY, NOW, {}, inputs);
|
|
381
|
-
expect(decision.
|
|
431
|
+
expect(decision.rearm[0]!.clearZeroVolumeFlag).toBe(true);
|
|
382
432
|
});
|
|
383
433
|
|
|
384
434
|
test("without a sixth argument the legacy shape is untouched", () => {
|
|
385
435
|
const episodes = [episode({ kind: "llm.provider.call.failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) })];
|
|
386
436
|
const decision = decideSweep([], episodes, POLICY, NOW);
|
|
387
|
-
expect(decision.
|
|
437
|
+
expect(decision.rearm).toHaveLength(1);
|
|
438
|
+
expect(decision.resolve).toHaveLength(0);
|
|
388
439
|
});
|
|
389
440
|
});
|
|
390
441
|
|
|
@@ -422,10 +473,11 @@ describe("decideSweep — probe-gated resolution (operator ruling 2026-08-28)",
|
|
|
422
473
|
expect(decision.resolveProbeGate[0]!.quietMinutes).toBeGreaterThanOrEqual(30);
|
|
423
474
|
});
|
|
424
475
|
|
|
425
|
-
test("a kind without a declared probe
|
|
476
|
+
test("a kind without a declared probe RE-ARMS on quiet — it is never closed unverified", () => {
|
|
426
477
|
const gone = episode({ kind: "wake.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
|
|
427
478
|
const decision = decideSweep([agg("wake.failed", 10, 700, 60)], [gone], POLICY, NOW, {}, undefined, PROBE_SOURCES);
|
|
428
|
-
expect(decision.
|
|
479
|
+
expect(decision.rearm).toHaveLength(1);
|
|
480
|
+
expect(decision.resolve).toHaveLength(0);
|
|
429
481
|
expect(decision.resolveProbeGate).toHaveLength(0);
|
|
430
482
|
});
|
|
431
483
|
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
deliverEscalation,
|
|
40
40
|
deliverZeroVolumeHold,
|
|
41
41
|
refreshOpenEpisode,
|
|
42
|
+
rearmEpisode,
|
|
42
43
|
resolveEpisode,
|
|
43
44
|
} from "./platform-fault-escalation/delivery.ts";
|
|
44
45
|
|
|
@@ -61,6 +62,7 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
|
|
|
61
62
|
escalated_at: string | Date | null;
|
|
62
63
|
work_item_id: string | null;
|
|
63
64
|
resolved_at: string | Date | null;
|
|
65
|
+
rearmed_at: string | Date | null;
|
|
64
66
|
last_updated_at: string | Date | null;
|
|
65
67
|
}>;
|
|
66
68
|
const episodes: EscalationEpisode[] = openEpisodes.map((row) => ({
|
|
@@ -71,6 +73,7 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
|
|
|
71
73
|
escalatedAt: row.escalated_at ? new Date(row.escalated_at) : null,
|
|
72
74
|
workItemId: row.work_item_id,
|
|
73
75
|
resolvedAt: row.resolved_at ? new Date(row.resolved_at) : null,
|
|
76
|
+
rearmedAt: row.rearmed_at ? new Date(row.rearmed_at) : null,
|
|
74
77
|
lastUpdatedAt: row.last_updated_at ? new Date(row.last_updated_at) : null,
|
|
75
78
|
}));
|
|
76
79
|
|
|
@@ -204,6 +207,19 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
|
|
|
204
207
|
log.error(`[platform-fault-escalation] resolve for '${episode.kind}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
205
208
|
}
|
|
206
209
|
}
|
|
210
|
+
// RE-ARM, NOT RESOLVE (2026-08-29). A kind with no declared capability
|
|
211
|
+
// probe went quiet: the alarm is armed for the next occurrence and NOTHING
|
|
212
|
+
// is claimed about the condition. No operator comment is written here on
|
|
213
|
+
// purpose — see rearmEpisode.
|
|
214
|
+
for (const { episode, quietMinutes, clearZeroVolumeFlag } of decision.rearm) {
|
|
215
|
+
try {
|
|
216
|
+
await rearmEpisode(episode, quietMinutes);
|
|
217
|
+
if (clearZeroVolumeFlag) await clearZeroVolumeHold(episode, zeroVolume.countByKind[episode.kind] ?? 0);
|
|
218
|
+
log.info(`[platform-fault-escalation] RE-ARMED '${episode.kind}' after ${Math.round(quietMinutes)} quiet min — no probe declared, so the condition is UNVERIFIED and no close was claimed`);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
log.error(`[platform-fault-escalation] re-arm for '${episode.kind}' failed (retried next sweep): ${err instanceof Error ? err.message : String(err)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
207
223
|
|
|
208
224
|
// Emitted every run, even when nothing fired — a sweep that only reports
|
|
209
225
|
// when it escalated is indistinguishable from one that stopped running.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The `projects-daily-digest` / `projects-weekly-digest` pg-boss handlers for
|
|
7
|
-
* scala-projects.
|
|
7
|
+
* scala-projects. Fixed-query shape: no row-driven
|
|
8
8
|
* SQL, every query is a literal Prisma call against work_items; the
|
|
9
9
|
* scheduled_job row only carries the cron.
|
|
10
10
|
*
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -20,10 +19,7 @@ import { join } from "node:path";
|
|
|
20
19
|
* the module proves it cannot fire on any path. It also stays honest if someone
|
|
21
20
|
* reintroduces the restart behind a new condition.
|
|
22
21
|
*/
|
|
23
|
-
const SOURCE =
|
|
24
|
-
join(import.meta.dir, "scala-tools-currency-watch.ts"),
|
|
25
|
-
"utf-8",
|
|
26
|
-
);
|
|
22
|
+
const SOURCE = await Bun.file(join(import.meta.dir, "scala-tools-currency-watch.ts")).text();
|
|
27
23
|
|
|
28
24
|
/** Comment prose legitimately discusses the removed restart; code must not do it. */
|
|
29
25
|
const CODE = SOURCE.split("\n")
|
|
@@ -25,6 +25,29 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Registered under slug `@teamscala/service-boot:scala-tools-currency-watch`.
|
|
27
27
|
*/
|
|
28
|
+
// NO RESTART. The reinstall alone is sufficient, and restarting here was actively harmful.
|
|
29
|
+
//
|
|
30
|
+
// The premise this watch was written on — "restart so the fresh scala-tools loads" — does not hold for THIS package. scala-sentinel never
|
|
31
|
+
// imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools` per invocation, so a freshly installed binary is picked up by the next
|
|
32
|
+
// spawn with no process restart at all. Verified 2026-07-27: the package is declared in scala-sentinel/package.json, yet scala-guard/src contains zero
|
|
33
|
+
// import statements resolving to it — it is spawned, never linked. dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
|
|
34
|
+
// maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED sibling map exists for packages a daemon really does load at boot
|
|
35
|
+
// (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
|
|
36
|
+
//
|
|
37
|
+
// The cost was not theoretical. Restarting the guard daemon takes enforcement offline for its warmup, and every shutdown logs "152 guard(s)
|
|
38
|
+
// failed to run (fail-open)" — so each unnecessary restart opened a window in which tool-check, turn-end and pre-push silently did not run. Three
|
|
39
|
+
// scala-tools versions published on 2026-07-26 alone, and this watch polls, so it re-fired for each. That inverts
|
|
40
|
+
// `infrastructure-outranks-application-durability`: a currency convenience was repeatedly disabling the thing it exists to keep current.
|
|
41
|
+
//
|
|
42
|
+
// It also used a bare `pm2 restart`, which is forbidden (`forbid-bare-pm2-restart`) precisely because it bypasses the
|
|
43
|
+
// currency-gated canonical pipeline — the guard blocks agents from that exact call.
|
|
44
|
+
//
|
|
45
|
+
// If a future package genuinely needs a restart to take effect, it does not belong here: add it to dep-sync's PM2_CACHED map, which already owns
|
|
46
|
+
// install-plus-restart and coalesces them.
|
|
47
|
+
|
|
48
|
+
// Why: NO RESTART. The reinstall alone is sufficient, and restartin…
|
|
49
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
50
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
28
51
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
29
52
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
30
53
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -124,38 +147,6 @@ async function scalaToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Pr
|
|
|
124
147
|
payload: { from: installed, to: latest },
|
|
125
148
|
});
|
|
126
149
|
|
|
127
|
-
// NO RESTART. The reinstall alone is sufficient, and restarting here was
|
|
128
|
-
// actively harmful.
|
|
129
|
-
//
|
|
130
|
-
// The premise this watch was written on — "restart so the fresh
|
|
131
|
-
// scala-tools loads" — does not hold for THIS package. scala-sentinel never
|
|
132
|
-
// imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools`
|
|
133
|
-
// per invocation, so a freshly installed binary is picked up by the next
|
|
134
|
-
// spawn with no process restart at all. Verified 2026-07-27: the package is
|
|
135
|
-
// declared in scala-sentinel/package.json, yet scala-guard/src contains zero
|
|
136
|
-
// import statements resolving to it — it is spawned, never linked.
|
|
137
|
-
// dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
|
|
138
|
-
// maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED
|
|
139
|
-
// sibling map exists for packages a daemon really does load at boot
|
|
140
|
-
// (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
|
|
141
|
-
//
|
|
142
|
-
// The cost was not theoretical. Restarting the guard daemon takes
|
|
143
|
-
// enforcement offline for its warmup, and every shutdown logs "152 guard(s)
|
|
144
|
-
// failed to run (fail-open)" — so each unnecessary restart opened a window
|
|
145
|
-
// in which tool-check, turn-end and pre-push silently did not run. Three
|
|
146
|
-
// scala-tools versions published on 2026-07-26 alone, and this watch polls,
|
|
147
|
-
// so it re-fired for each. That inverts
|
|
148
|
-
// `infrastructure-outranks-application-durability`: a currency convenience
|
|
149
|
-
// was repeatedly disabling the thing it exists to keep current.
|
|
150
|
-
//
|
|
151
|
-
// It also used a bare `pm2 restart`, which is forbidden
|
|
152
|
-
// (`forbid-bare-pm2-restart`) precisely because it bypasses the
|
|
153
|
-
// currency-gated canonical pipeline — the guard blocks agents from that
|
|
154
|
-
// exact call.
|
|
155
|
-
//
|
|
156
|
-
// If a future package genuinely needs a restart to take effect, it does not
|
|
157
|
-
// belong here: add it to dep-sync's PM2_CACHED map, which already owns
|
|
158
|
-
// install-plus-restart and coalesces them.
|
|
159
150
|
log.info(
|
|
160
151
|
`[scala-tools-currency-watch] reinstalled ${PKG} ${installed} → ${latest} (no restart: sentinel spawns the binary, it does not import it)`,
|
|
161
152
|
);
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
|
|
5
|
-
const SOURCE =
|
|
6
|
-
join(import.meta.dir, "singleton-tools-currency-watch.ts"),
|
|
7
|
-
"utf-8",
|
|
8
|
-
);
|
|
4
|
+
const SOURCE = await Bun.file(join(import.meta.dir, "singleton-tools-currency-watch.ts")).text();
|
|
9
5
|
|
|
10
6
|
const CODE = SOURCE.split("\n")
|
|
11
7
|
.filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*"))
|
|
@@ -23,14 +23,6 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Registered under slug `@teamscala/service-boot:singleton-tools-currency-watch`.
|
|
25
25
|
*/
|
|
26
|
-
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
27
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
28
|
-
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
29
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
30
|
-
import { spawn } from "@teamscala/os/spawn";
|
|
31
|
-
import { getDistTagVersion } from "@teamscala/publish-registry";
|
|
32
|
-
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
33
|
-
|
|
34
26
|
/**
|
|
35
27
|
* Where a tool's "latest" comes from.
|
|
36
28
|
*
|
|
@@ -47,6 +39,33 @@ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
|
47
39
|
* never shadow a /usr/local/bin singleton wrapper. The gap was currency
|
|
48
40
|
* tracking, never placement.
|
|
49
41
|
*/
|
|
42
|
+
|
|
43
|
+
// Why: Where a tool's "latest" comes from.
|
|
44
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
45
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
46
|
+
// A GitHub-released binary is NOT installed by a package manager, so there
|
|
47
|
+
// is no "reinstall" verb to call — it would have to be downloaded, verified,
|
|
48
|
+
// made executable and swapped atomically over a file that may be RUNNING.
|
|
49
|
+
// That is a real install path with real failure modes (partial download,
|
|
50
|
+
// wrong architecture, replacing a binary mid-execution), and doing it badly
|
|
51
|
+
// is worse than not doing it: a half-written CLI is a broken CLI.
|
|
52
|
+
//
|
|
53
|
+
// So this reports the drift and stops. Detection is live from today; the
|
|
54
|
+
// automated swap is a separate, deliberate change. Reporting a version the
|
|
55
|
+
// watcher cannot actually install is honest — silently claiming currency
|
|
56
|
+
// would not be.
|
|
57
|
+
|
|
58
|
+
// Why: A GitHub-released binary is NOT installed by a package manag…
|
|
59
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
60
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
61
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
62
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
63
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
64
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
65
|
+
import { spawn } from "@teamscala/os/spawn";
|
|
66
|
+
import { getDistTagVersion } from "@teamscala/publish-registry";
|
|
67
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
68
|
+
|
|
50
69
|
type VersionSource =
|
|
51
70
|
| {
|
|
52
71
|
type: "npm_package";
|
|
@@ -210,17 +229,6 @@ async function reinstallTool(tool: SingletonTool, latest: string): Promise<boole
|
|
|
210
229
|
if (!vs) return false;
|
|
211
230
|
|
|
212
231
|
if (vs.type === "github_release") {
|
|
213
|
-
// A GitHub-released binary is NOT installed by a package manager, so there
|
|
214
|
-
// is no "reinstall" verb to call — it would have to be downloaded, verified,
|
|
215
|
-
// made executable and swapped atomically over a file that may be RUNNING.
|
|
216
|
-
// That is a real install path with real failure modes (partial download,
|
|
217
|
-
// wrong architecture, replacing a binary mid-execution), and doing it badly
|
|
218
|
-
// is worse than not doing it: a half-written CLI is a broken CLI.
|
|
219
|
-
//
|
|
220
|
-
// So this reports the drift and stops. Detection is live from today; the
|
|
221
|
-
// automated swap is a separate, deliberate change. Reporting a version the
|
|
222
|
-
// watcher cannot actually install is honest — silently claiming currency
|
|
223
|
-
// would not be.
|
|
224
232
|
log.warn(
|
|
225
233
|
`[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
|
|
226
234
|
);
|