@sigrun/sigrun 0.2.0 → 0.3.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +145 -7
  2. package/README.md +333 -13
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/validate.d.ts +73 -0
  11. package/dist/cli.js +117 -19
  12. package/dist/config.js +21 -2
  13. package/dist/extension.d.ts +48 -8
  14. package/dist/harness/ambient.js +2 -0
  15. package/dist/harness/blocks/state.js +1 -0
  16. package/dist/harness/boot.js +92 -6
  17. package/dist/harness/builtins.js +1 -1
  18. package/dist/harness/context.js +25 -0
  19. package/dist/harness/event-bus.js +25 -14
  20. package/dist/harness/identity/accounts.js +3 -2
  21. package/dist/harness/identity/index.js +2 -1
  22. package/dist/harness/identity/invites.js +26 -9
  23. package/dist/harness/identity/link-sweep.js +47 -0
  24. package/dist/harness/identity/typed-username.js +15 -0
  25. package/dist/harness/identity/users.js +10 -7
  26. package/dist/harness/interactions.js +2 -0
  27. package/dist/harness/loader.js +57 -1
  28. package/dist/harness/migration-snapshot.js +97 -0
  29. package/dist/harness/reports.js +100 -0
  30. package/dist/harness/seam-host.js +23 -1
  31. package/dist/harness/sections/context-files.js +1 -1
  32. package/dist/harness/settings.js +52 -4
  33. package/dist/jobs/docs.js +3 -0
  34. package/dist/jobs/fire.js +2 -0
  35. package/dist/jobs/parked.js +38 -0
  36. package/dist/jobs/task.js +37 -8
  37. package/dist/sandbox/availability.js +69 -20
  38. package/dist/sandbox/launch.js +11 -0
  39. package/dist/sandbox/policy.js +24 -3
  40. package/dist/sandbox/worker.js +4 -24
  41. package/dist/serve.js +162 -40
  42. package/dist/server/auth/ip.js +9 -4
  43. package/dist/server/auth/own-host.js +115 -0
  44. package/dist/server/auth/pipeline.js +93 -28
  45. package/dist/server/auth/proxy-diagnostic.js +60 -0
  46. package/dist/server/auth/proxy-identity.js +134 -0
  47. package/dist/server/auth/proxy-only.js +28 -0
  48. package/dist/server/images/cache.js +13 -5
  49. package/dist/server/index.js +10 -2
  50. package/dist/server/login/resolve.js +88 -26
  51. package/dist/server/notifications.js +21 -4
  52. package/dist/server/routes/admin.js +97 -0
  53. package/dist/server/routes/images.js +16 -9
  54. package/dist/server/routes/login.js +8 -0
  55. package/dist/server/routes/me.js +1 -1
  56. package/dist/server/routes/meta.js +4 -0
  57. package/dist/server/routes/metrics.js +18 -2
  58. package/dist/server/routes/onboarding.js +97 -37
  59. package/dist/server/session-meta.js +1 -0
  60. package/dist/server/shutdown.js +93 -16
  61. package/dist/settings/registry.js +6 -0
  62. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  63. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  64. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  65. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  66. package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
  67. package/node_modules/@sigrun/shared/package.json +5 -1
  68. package/package.json +16 -15
  69. package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
  70. package/webui/dist/index.html +1 -1
  71. package/CONTEXT.md +0 -163
  72. package/dist/server/login/usernames.js +0 -15
@@ -1,9 +1,85 @@
1
+ import { deriveUsername, withSuffix } from "@sigrun/shared/usernames";
1
2
  import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
2
3
  import { AdmissionRefusedError, } from "../../harness/identity/users.js";
3
4
  import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
4
5
  import { hashToken } from "../../harness/identity/tokens.js";
5
6
  import { withClaimLock } from "../auth/claim-lock.js";
6
- import { assignUsername } from "./usernames.js";
7
+ /** The first usable profile claim wins; the external subject remains exact. */
8
+ export function loginUsernameCandidate(profile, subject) {
9
+ for (const candidate of [profile.username, profile.email, subject]) {
10
+ if (candidate === undefined)
11
+ continue;
12
+ const derived = deriveUsername(candidate);
13
+ if (derived !== undefined)
14
+ return derived;
15
+ }
16
+ return "user";
17
+ }
18
+ function isAddConflict(error) {
19
+ return error instanceof Error && /^user ".*" already exists$/.test(error.message);
20
+ }
21
+ function isLinkConflict(error) {
22
+ return error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:"));
23
+ }
24
+ async function sessionForLinkedUser(input, deps) {
25
+ const existing = deps.accounts.lookup(input.provider, input.result.subject);
26
+ if (deps.users.get(existing.user) === undefined) {
27
+ return { kind: "refused", error: "not_allowed" };
28
+ }
29
+ await deps.users.setGroups(existing.user, input.provider, input.result.groups ?? []);
30
+ return { kind: "session", user: existing.user };
31
+ }
32
+ /** Lock-free provisioning body; add/link conflicts become retry signals. */
33
+ async function provisionLogin(input, deps) {
34
+ if (deps.accounts.lookup(input.provider, input.result.subject) !== undefined) {
35
+ return sessionForLinkedUser(input, deps);
36
+ }
37
+ // Admission is checked before redemption so an M1 refusal burns no invite.
38
+ deps.users.checkAdmission();
39
+ const base = loginUsernameCandidate(input.result.profile, input.result.subject);
40
+ const autoProvision = deps.policy.autoProvision === true;
41
+ const user = autoProvision
42
+ ? withSuffix(base, (name) => deps.users.get(name) !== undefined)
43
+ : base;
44
+ if (!autoProvision && deps.users.get(user) !== undefined) {
45
+ return { kind: "refused", error: "not_allowed" };
46
+ }
47
+ const grant = autoProvision
48
+ ? { admin: false }
49
+ : await deps.invites.redeem(input.code, user);
50
+ if (grant === undefined)
51
+ return { kind: "refused", error: "not_allowed" };
52
+ try {
53
+ const record = await deps.users.add({
54
+ user,
55
+ ...profileFields(input.result.profile),
56
+ admin: grant.admin,
57
+ source: "login",
58
+ });
59
+ try {
60
+ await deps.accounts.link({
61
+ provider: input.provider,
62
+ subject: input.result.subject,
63
+ user,
64
+ via: "login",
65
+ });
66
+ }
67
+ catch (error) {
68
+ if (isLinkConflict(error)) {
69
+ await deps.users.remove(record.user);
70
+ return { kind: "retry", error: error };
71
+ }
72
+ throw error;
73
+ }
74
+ await deps.users.setGroups(user, input.provider, input.result.groups ?? []);
75
+ return { kind: "session", user: record.user };
76
+ }
77
+ catch (error) {
78
+ if (isAddConflict(error))
79
+ return { kind: "retry", error: error };
80
+ throw error;
81
+ }
82
+ }
7
83
  function allowed(result, policy) {
8
84
  if (policy.allowedGroups !== undefined &&
9
85
  !policy.allowedGroups.some((group) => result.groups?.includes(group) === true))
@@ -42,13 +118,8 @@ export async function resolveLogin(input, deps) {
42
118
  return { kind: "linked", user: input.linkUser };
43
119
  }
44
120
  const existing = deps.accounts.lookup(provider, result.subject);
45
- if (existing !== undefined) {
46
- if (deps.users.get(existing.user) === undefined) {
47
- return { kind: "refused", error: "not_allowed" };
48
- }
49
- await deps.users.setGroups(existing.user, provider, result.groups ?? []);
50
- return { kind: "session", user: existing.user };
51
- }
121
+ if (existing !== undefined)
122
+ return sessionForLinkedUser(input, deps);
52
123
  const setupResolution = await withClaimLock(async () => {
53
124
  const validSetupCode = input.code !== undefined && setupCodeMatches(input.code, deps.setupCode());
54
125
  if (deps.users.isClaimed()) {
@@ -69,7 +140,7 @@ export async function resolveLogin(input, deps) {
69
140
  if (!validSetupCode)
70
141
  return { kind: "needs_code", reason: "setup" };
71
142
  const claim = deps.setupClaims.matches(provider, result.subject, input.code);
72
- const user = claim?.user ?? assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
143
+ const user = claim?.user ?? withSuffix(loginUsernameCandidate(result.profile, result.subject), (name) => deps.users.get(name) !== undefined);
73
144
  // Persist the recovery breadcrumb before creating the user. On a
74
145
  // retry, only this exact subject may finish the claim; if the user
75
146
  // write did not happen, it is retried under the recorded name.
@@ -99,23 +170,9 @@ export async function resolveLogin(input, deps) {
99
170
  if (deps.policy.autoProvision !== true && input.code === undefined) {
100
171
  return { kind: "needs_code", reason: "invite" };
101
172
  }
102
- // Admission is checked before redemption so an M1 refusal burns no invite.
103
- deps.users.checkAdmission();
104
- const user = assignUsername(result.profile, result.subject, (name) => deps.users.get(name) !== undefined);
105
- const grant = deps.policy.autoProvision === true
106
- ? { admin: false }
107
- : await deps.invites.redeem(input.code, user);
108
- if (grant === undefined)
109
- return { kind: "refused", error: "not_allowed" };
110
- const record = await deps.users.add({
111
- user,
112
- ...profileFields(result.profile),
113
- admin: grant.admin,
114
- source: "login",
115
- });
116
- await deps.accounts.link({ provider, subject: result.subject, user, via: "login" });
117
- await deps.users.setGroups(user, provider, result.groups ?? []);
118
- return { kind: "session", user: record.user };
173
+ const provisioned = await withClaimLock(() => provisionLogin(input, deps));
174
+ if (provisioned.kind !== "retry")
175
+ return provisioned;
119
176
  }
120
177
  catch (error) {
121
178
  if (error instanceof AccountLinkConflictError || (error instanceof Error && error.message.startsWith("account_link_conflict:")))
@@ -130,4 +187,9 @@ export async function resolveLogin(input, deps) {
130
187
  }
131
188
  throw error;
132
189
  }
190
+ // Conflicts from another writer retry once, only after the section returns.
191
+ const second = await withClaimLock(() => provisionLogin(input, deps));
192
+ if (second.kind === "retry")
193
+ throw second.error;
194
+ return second;
133
195
  }
@@ -78,6 +78,10 @@ export function startNotifications(ctx) {
78
78
  title: base.title,
79
79
  body: base.body,
80
80
  ...(url === undefined ? {} : { url }),
81
+ // The settled outcome rides ONLY run.settled events — the
82
+ // other variants never gain the field (the type is the union;
83
+ // the conditional spread keeps the wire shape exact).
84
+ ...(base.outcome === undefined ? {} : { outcome: base.outcome }),
81
85
  };
82
86
  for (const target of targets) {
83
87
  // Fire-and-forget per transport call: one notifier's failure or
@@ -123,7 +127,7 @@ export function startNotifications(ctx) {
123
127
  }, recipients);
124
128
  };
125
129
  /** run.settled → the owner, gated by the live `notifications.runSettled` mode. */
126
- const handleRunSettled = async (conversationId) => {
130
+ const handleRunSettled = async (conversationId, outcome, reason) => {
127
131
  const mode = opened.settings.read().notifications?.runSettled ?? "unwatched";
128
132
  if (mode === "never")
129
133
  return;
@@ -134,13 +138,26 @@ export function startNotifications(ctx) {
134
138
  (watchers.get(conversationId) ?? 0) > 0) {
135
139
  return;
136
140
  }
141
+ // Translate the EXISTING wire outcome/reason straight across (no
142
+ // second answer read, no inferred status). An unanswered run never
143
+ // titles itself "Reply ready": the reason is the run's own, with
144
+ // the bare status as the fallback for a reason-less settle.
145
+ const settledOutcome = outcome === "answered"
146
+ ? { status: "answered" }
147
+ : { status: "unanswered", reason: reason ?? "unanswered" };
148
+ const settledReason = reason ?? "unanswered";
137
149
  deliver({
138
150
  type: "run.settled",
139
151
  conversationId,
140
152
  jobId: undefined,
141
153
  callId: undefined,
142
- title: "Reply ready",
143
- body: displayName(meta, conversationId),
154
+ title: outcome === "answered"
155
+ ? "Reply ready"
156
+ : "Run ended without a reply",
157
+ body: outcome === "answered"
158
+ ? displayName(meta, conversationId)
159
+ : `${displayName(meta, conversationId)} — ${settledReason}`,
160
+ outcome: settledOutcome,
144
161
  }, [meta?.owner]);
145
162
  };
146
163
  /**
@@ -177,7 +194,7 @@ export function startNotifications(ctx) {
177
194
  void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId, event.callId));
178
195
  }
179
196
  else if (event.type === "run.settled") {
180
- void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId));
197
+ void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId, event.outcome, event.reason));
181
198
  }
182
199
  else if (event.type === "job.changed") {
183
200
  void bus.runBounded("notifications job.changed", () => {
@@ -18,12 +18,24 @@
18
18
  * graceful-shutdown + `process.exit(0)`) owns the exit — the route
19
19
  * never calls exit itself.
20
20
  *
21
+ * D5 parked-task recovery (pi-durable 1.4.0, Task B6):
22
+ * `GET /v1/admin/tasks?state=blocked` lists blocked tasks from
23
+ * `harness.inspect()` (id, kind, reason, conversationId, owner from the
24
+ * conversation's SessionMeta when present); `POST /v1/admin/tasks/:id/retry`
25
+ * returns 404 unknown_task / 409 not_parked unless the task is blocked
26
+ * with reason restart_limit, else calls `harness.retryTask(id)` → 204.
27
+ * The inspect-first check is load-bearing: retryTask rejects an unknown
28
+ * id but SILENTLY no-ops for a task that isn't parked. Both endpoints
29
+ * are admin-only (403 forbidden).
30
+ *
21
31
  * Durability (Review Focus 4): the restart rides the SAME ordered
22
32
  * shutdown as the SIGTERM path — the event bus drains pending settles
23
33
  * and the harness close seals storage, so a run in flight at restart
24
34
  * has its submission durable before exit; pi-durable resumes it after
25
35
  * the supervisor brings the process back.
26
36
  */
37
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
38
+ import { SessionMetaDoc } from "../session-meta.js";
27
39
  /** Register the `/v1/admin` routes. */
28
40
  export function registerAdminRoutes(app, ctx) {
29
41
  app.post("/v1/admin/restart", async (request, reply) => {
@@ -55,4 +67,89 @@ export function registerAdminRoutes(app, ctx) {
55
67
  });
56
68
  return await reply.code(202).send({ restarting: true });
57
69
  });
70
+ // D5 parked-task recovery. `GET /v1/admin/tasks?state=blocked` lists
71
+ // blocked tasks from `harness.inspect()`; `POST /v1/admin/tasks/:id/retry`
72
+ // re-runs a restart-parked task. Both admin-only (403 before any
73
+ // inspection, so a non-admin learns nothing about the task graph).
74
+ app.get("/v1/admin/tasks", async (request, reply) => {
75
+ if (!request.principal.admin) {
76
+ return await reply.code(403).send({ error: "forbidden" });
77
+ }
78
+ const query = request.query;
79
+ // Only the blocked projection exists (the recovery surface); any
80
+ // other state filter is a fail-loud 400, same posture as the
81
+ // conversations list's strict booleans.
82
+ if (query.state !== undefined && query.state !== "blocked") {
83
+ return await reply.code(400).send({ error: "invalid_field", field: "state" });
84
+ }
85
+ const inspection = await ctx.opened.harness.inspect(BACKGROUND_CONTEXT);
86
+ const blocked = inspection.tasks.filter((task) => task.state.kind === "blocked");
87
+ // Owner: the conversation's SessionMeta.owner when it has one.
88
+ // Reads are snapshots (never materialize the doc); a raced read
89
+ // degrades to an omitted owner, not a failed list.
90
+ const owners = new Map();
91
+ for (const task of blocked) {
92
+ const conversationId = Number(task.record.conversationId);
93
+ if (owners.has(conversationId))
94
+ continue;
95
+ try {
96
+ const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, task.record.conversationId, BACKGROUND_CONTEXT);
97
+ owners.set(conversationId, meta?.owner);
98
+ }
99
+ catch (error) {
100
+ console.error("admin tasks: SessionMeta snapshot failed:", error);
101
+ owners.set(conversationId, undefined);
102
+ }
103
+ }
104
+ // Extension: task names are unique across installed extensions, so
105
+ // ONE registry snapshot resolves the owning extension. Built-in
106
+ // generation/tool/compaction tasks have no installed extension and
107
+ // correctly omit the field.
108
+ const extensionsByTask = new Map();
109
+ for (const extension of ctx.opened.registry.snapshot().installed()) {
110
+ for (const task of extension.tasks ?? []) {
111
+ extensionsByTask.set(task.definition.name, extension.name);
112
+ }
113
+ }
114
+ const tasks = blocked.map((task) => {
115
+ const conversationId = Number(task.record.conversationId);
116
+ const owner = owners.get(conversationId);
117
+ const extension = extensionsByTask.get(task.record.kind);
118
+ return {
119
+ id: Number(task.record.id),
120
+ kind: task.record.kind,
121
+ reason: task.state.reason,
122
+ conversationId,
123
+ ...(owner === undefined ? {} : { owner }),
124
+ ...(extension === undefined ? {} : { extension }),
125
+ };
126
+ });
127
+ return await reply.send({ tasks });
128
+ });
129
+ app.post("/v1/admin/tasks/:id/retry", async (request, reply) => {
130
+ if (!request.principal.admin) {
131
+ return await reply.code(403).send({ error: "forbidden" });
132
+ }
133
+ const { id } = request.params;
134
+ const taskId = Number(id);
135
+ if (!Number.isInteger(taskId) || taskId < 0 || String(taskId) !== id) {
136
+ return await reply.code(404).send({ error: "unknown_task" });
137
+ }
138
+ // Check inspect() FIRST: pi-durable's retryTask rejects an unknown
139
+ // id but SILENTLY no-ops for a task that isn't parked — the 409
140
+ // must come from the inspection, not from retryTask.
141
+ const inspection = await ctx.opened.harness.inspect(BACKGROUND_CONTEXT);
142
+ const task = inspection.tasks.find((candidate) => String(candidate.record.id) === id);
143
+ if (task === undefined) {
144
+ return await reply.code(404).send({ error: "unknown_task" });
145
+ }
146
+ if (task.state.kind !== "blocked" || task.state.reason !== "restart_limit") {
147
+ return await reply.code(409).send({ error: "not_parked" });
148
+ }
149
+ // For a schedule task the job's status fields return to `waiting`
150
+ // on the task's next committed state (the task's own phases patch
151
+ // the record) — the route deliberately does NOT hand-patch the job.
152
+ await ctx.opened.harness.retryTask(task.record.id, BACKGROUND_CONTEXT);
153
+ return await reply.code(204).send();
154
+ });
58
155
  }
@@ -17,9 +17,10 @@
17
17
  * POSTed as the raw text body; the route's `bodyLimit` is the §11.1
18
18
  * 192 KiB rule (an over-limit body is Fastify's 413 `too_large`).
19
19
  *
20
- * Fetches go through the on-disk LRU cache (`<cacheDir>/images/`,
21
- * `images/cache.ts`): a hit re-serves without an upstream call, and
22
- * concurrent requests for one URL share ONE in-flight upstream fetch
20
+ * Fetches go through the on-disk LRU cache: a hit fresher than its
21
+ * clamped max-age re-serves without an upstream call; an older hit
22
+ * revalidates, and a refusal is never replaced by stale bytes.
23
+ * Concurrent requests for one URL share ONE in-flight upstream fetch
23
24
  * (Review Focus 4). Failures are never cached. Responses carry the
24
25
  * sniffed Content-Type, `X-Content-Type-Options: nosniff`, and
25
26
  * `Cache-Control: private, max-age=<upstream hint clamped to 1 h–7 d>`.
@@ -114,13 +115,17 @@ export function registerImageRoutes(app, ctx) {
114
115
  const inflight = new Map();
115
116
  /** Fetch one URL through the cache, sharing in-flight work. */
116
117
  async function fetchThroughCache(u, variant) {
117
- const hit = await cache.get(u, variant);
118
- if (hit !== undefined) {
118
+ const now = deps.now();
119
+ const hit = await cache.get(u, variant, now);
120
+ if (hit !== undefined && now - hit.addedAt < hit.maxAge * 1000) {
119
121
  const chunks = [];
120
122
  for await (const chunk of hit.stream)
121
123
  chunks.push(chunk);
122
- return { bytes: Buffer.concat(chunks), type: hit.type };
124
+ return { bytes: Buffer.concat(chunks), type: hit.type, maxAge: hit.maxAge };
123
125
  }
126
+ // A stale hit is never served: close its pin and revalidate.
127
+ if (hit !== undefined)
128
+ hit.stream.destroy();
124
129
  const inflightKey = `${variant}${u}`;
125
130
  const existing = inflight.get(inflightKey);
126
131
  if (existing !== undefined)
@@ -128,11 +133,13 @@ export function registerImageRoutes(app, ctx) {
128
133
  const work = (async () => {
129
134
  const fetched = await fetchImage(u, deps);
130
135
  // Cache the original variant first — `as=png` requests cache
131
- // BOTH variants (the conversion reads the fetched bytes).
132
- await cache.put(u, "orig", fetched.type, fetched.bytes);
136
+ // BOTH variants (the conversion reads the fetched bytes). The
137
+ // clamped hint is BOTH the response header and the cache TTL.
138
+ const maxAge = clampedMaxAge(fetched.maxAge);
139
+ await cache.put(u, "orig", fetched.type, fetched.bytes, deps.now(), maxAge);
133
140
  if (variant === "png") {
134
141
  const converted = await toPng(fetched.bytes);
135
- await cache.put(u, "png", "png", converted);
142
+ await cache.put(u, "png", "png", converted, deps.now(), maxAge);
136
143
  return {
137
144
  bytes: converted,
138
145
  type: "png",
@@ -1,4 +1,5 @@
1
1
  import { ipAllowed } from "../auth/ip.js";
2
+ import { requiresProxyVouch } from "../auth/proxy-only.js";
2
3
  import { bearerOf, resolveToken, sessionCookieOf } from "../auth/pipeline.js";
3
4
  import { LoginFlows } from "../login/flows.js";
4
5
  import { clearSetupCode, readSetupCode } from "../../harness/identity/setup-code.js";
@@ -191,6 +192,13 @@ export function registerLoginRoutes(app, ctx, options = {}) {
191
192
  // The webui client requests JSON so fetch never follows a redirect
192
193
  // into an HTML document. Existing form callers keep the 302 contract.
193
194
  const asJson = request.headers.accept?.includes("application/json") === true;
195
+ // C2: this route creates an account from a parked external-identity
196
+ // flow plus a setup/invite code — it carries NO proxy identity, so
197
+ // on a proxy-only non-loopback bind it ALWAYS refuses, before any
198
+ // code evaluation or account provisioning.
199
+ if (requiresProxyVouch(ctx.methods, ctx.listenHost)) {
200
+ return browserError(reply, 403, "proxy_identity_required", asJson);
201
+ }
194
202
  const pending = flows.takePending(record.key);
195
203
  if (pending === undefined)
196
204
  return browserError(reply, 400, "login_expired", asJson);
@@ -52,7 +52,7 @@ export function registerMeRoutes(app, ctx) {
52
52
  const settings = body;
53
53
  await stores.settings.set(settings);
54
54
  const { slug } = request.params;
55
- opened.userServices.settingsChanged(slug, request.principal.user);
55
+ ctx.publishSettingsChanged({ extension: slug }, Object.keys(settings), request.principal.user);
56
56
  const response = { settings, pendingRestart: [] };
57
57
  return response;
58
58
  });
@@ -39,6 +39,10 @@ export function registerMetaRoutes(app, ctx) {
39
39
  version: SIGRUN_VERSION,
40
40
  harnessVersion: HARNESS_VERSION,
41
41
  schemaVersion: SCHEMA_VERSION,
42
+ // The retained listener facts (release-readiness Task 1, serving
43
+ // spec §2): measured once at listen, projected verbatim — this
44
+ // route never probes (a pure synchronous projection).
45
+ listener: ctx.listener,
42
46
  extensions: opened.extensionOutcomes.map((outcome) => {
43
47
  const workers = workerStatuses.get(outcome.name);
44
48
  const surfaces = opened.extensionSurfaces.find((binding) => binding.extension === outcome.name);
@@ -55,12 +55,14 @@
55
55
  * type is `text/plain; version=0.0.4; charset=utf-8`.
56
56
  */
57
57
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
58
+ import { REPORT_TYPES } from "../../harness/reports.js";
58
59
  import { readBackupMarker } from "../../backup/marker.js";
59
60
  import { scanJobs } from "../../jobs/docs.js";
60
61
  import { providerAuthStatus } from "../helpers.js";
61
62
  import { checkStorage } from "../readiness.js";
62
63
  /** The exposition content type (spec §6.9's format rule). */
63
64
  const EXPOSITION_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
65
+ const BLOCKED_REASONS = ["missing_task", "task_too_old", "migration_failed", "restart_limit"];
64
66
  /** Escape one label value per the exposition format: `\` → `\\`,
65
67
  * `"` → `\"`, newline → `\n`. */
66
68
  function escapeLabelValue(value) {
@@ -121,18 +123,28 @@ export function registerMetricsRoutes(app, ctx) {
121
123
  // run is live — the alert's `> 1800` must not key on absence).
122
124
  const oldestMs = opened.eventBus.oldestLiveAdmissionMs();
123
125
  family("sigrun_inflight_submission_oldest_seconds", "Age of the oldest in-flight submission's admission (0 when none; in-process clock — a pre-restart run is invisible until it settles)", [{ labels: {}, value: Math.round((oldestMs ?? 0) / 1000) }]);
124
- // Live tasks by scheduler state kind. The full five-kind
125
- // vocabulary is emitted (0s included) for label stability.
126
+ // Live tasks by scheduler state kind. The full six-kind
127
+ // vocabulary is emitted (0s included) for label stability
128
+ // (1.4.0 adds `sleeping` — the durable-sleep state).
126
129
  const taskStatuses = {
127
130
  running: 0,
128
131
  ready: 0,
129
132
  waiting: 0,
133
+ sleeping: 0,
130
134
  completing: 0,
131
135
  blocked: 0,
132
136
  };
137
+ const blockedReasons = Object.fromEntries(BLOCKED_REASONS.map((reason) => [reason, 0]));
133
138
  for (const task of inspection.tasks) {
134
139
  taskStatuses[task.state.kind] = (taskStatuses[task.state.kind] ?? 0) + 1;
140
+ if (task.state.kind === "blocked") {
141
+ blockedReasons[task.state.reason] = (blockedReasons[task.state.reason] ?? 0) + 1;
142
+ }
135
143
  }
144
+ family("sigrun_tasks_blocked", "Live tasks blocked by scheduler reason", Object.entries(blockedReasons).map(([reason, count]) => ({
145
+ labels: { reason },
146
+ value: count,
147
+ })));
136
148
  family("sigrun_tasks_live", "Live tasks by scheduler state", Object.entries(taskStatuses).map(([status, count]) => ({
137
149
  labels: { status },
138
150
  value: count,
@@ -173,6 +185,10 @@ export function registerMetricsRoutes(app, ctx) {
173
185
  // failure even while inspection (and the whole scrape) stays green.
174
186
  const storage = await checkStorage(ctx);
175
187
  family("sigrun_ready", "1 when the readiness storage check (the /readyz root-conversation read) succeeds", [{ labels: {}, value: storage === "ok" ? 1 : 0 }]);
188
+ // D5: process-local durable failure reports. Every known type is
189
+ // emitted, including zeros, for scrape-stable alert expressions.
190
+ const reportCounts = opened.reports.counts();
191
+ family("sigrun_durable_reports_total", "Durable failure reports since process start", REPORT_TYPES.map((type) => ({ labels: { type }, value: reportCounts[type] })), "counter");
176
192
  // Provider readiness: the shared derivation (`GET /v1/auth/check`'s
177
193
  // exact chain), one 1-valued sample per (provider, current status).
178
194
  const producersByName = new Map(opened.providerHealth.map((producer) => [producer.name, producer]));