@sigrun/sigrun 0.2.1 → 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.
- package/CHANGELOG.md +50 -0
- package/README.md +234 -1
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +7 -1
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +86 -6
- package/dist/harness/context.js +25 -0
- package/dist/harness/event-bus.js +25 -14
- package/dist/harness/identity/accounts.js +3 -2
- package/dist/harness/identity/index.js +2 -1
- package/dist/harness/identity/invites.js +26 -9
- package/dist/harness/identity/link-sweep.js +47 -0
- package/dist/harness/identity/typed-username.js +15 -0
- package/dist/harness/identity/users.js +10 -7
- package/dist/harness/interactions.js +2 -0
- package/dist/harness/loader.js +57 -1
- package/dist/harness/migration-snapshot.js +97 -0
- package/dist/harness/reports.js +100 -0
- package/dist/harness/seam-host.js +11 -1
- package/dist/harness/settings.js +52 -4
- package/dist/jobs/docs.js +3 -0
- package/dist/jobs/fire.js +2 -0
- package/dist/jobs/parked.js +38 -0
- package/dist/jobs/task.js +37 -8
- package/dist/serve.js +23 -14
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +64 -48
- package/dist/server/auth/proxy-diagnostic.js +60 -0
- package/dist/server/auth/proxy-identity.js +134 -0
- package/dist/server/auth/proxy-only.js +28 -0
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/metrics.js +18 -2
- package/dist/server/routes/onboarding.js +97 -37
- package/dist/server/session-meta.js +1 -0
- package/dist/server/shutdown.js +93 -16
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
- package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +14 -12
- package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/dist/server/login/usernames.js +0 -15
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -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
|
|
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]));
|
|
@@ -17,7 +17,12 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
17
17
|
import { withClaimLock } from "../auth/claim-lock.js";
|
|
18
18
|
import { ipAllowed } from "../auth/ip.js";
|
|
19
19
|
import { isLoopbackHost } from "../auth/loopback.js";
|
|
20
|
+
import { ownHostPeerPermitted } from "../auth/own-host.js";
|
|
21
|
+
import { previewProxyUser, provisionProxyUser } from "../auth/proxy-identity.js";
|
|
22
|
+
import { ProxyRejectReason, warnProxyRejected, } from "../auth/proxy-diagnostic.js";
|
|
23
|
+
import { requiresProxyVouch } from "../auth/proxy-only.js";
|
|
20
24
|
import { readSetupCode, clearSetupCode } from "../../harness/identity/index.js";
|
|
25
|
+
import { normalizeTypedUsername } from "../../harness/identity/typed-username.js";
|
|
21
26
|
import { setupCodeMatches } from "../../harness/identity/setup-code-check.js";
|
|
22
27
|
import { AdmissionRefusedError } from "../../harness/identity/users.js";
|
|
23
28
|
import { stampSessionOwner } from "../helpers.js";
|
|
@@ -28,8 +33,6 @@ function headerOf(request, name) {
|
|
|
28
33
|
const single = Array.isArray(value) ? value[0] : value;
|
|
29
34
|
return single?.trim() || undefined;
|
|
30
35
|
}
|
|
31
|
-
/** The username rule (spec §5.8, shared with the directory's own check). */
|
|
32
|
-
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
33
36
|
/** Constant-time string equality (the pipeline's discipline). */
|
|
34
37
|
function timingSafeStringEqual(a, b) {
|
|
35
38
|
const left = Buffer.from(a, "utf8");
|
|
@@ -52,8 +55,24 @@ function timingSafeStringEqual(a, b) {
|
|
|
52
55
|
function resolveProxyIdentity(request, ctx) {
|
|
53
56
|
if (!ctx.methods.includes("proxy"))
|
|
54
57
|
return undefined;
|
|
58
|
+
// The rejection diagnostic (proxy-trust C1): while the instance is
|
|
59
|
+
// UNCLAIMED, every rejection branch reports WHY via the shared,
|
|
60
|
+
// throttled warn — these routes sit on the public path the pipeline
|
|
61
|
+
// never visits, so without this their proxy rejections are silent.
|
|
62
|
+
// Once claimed the onboarding surface no longer needs the signal.
|
|
63
|
+
const diagnose = !ctx.opened.identity.users.isClaimed();
|
|
55
64
|
const { proxy } = ctx.config.auth;
|
|
56
65
|
if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
|
|
66
|
+
if (diagnose)
|
|
67
|
+
warnProxyRejected(request, ProxyRejectReason.UNTRUSTED);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
// C5: the same own-host discipline as the pipeline — an own-host
|
|
71
|
+
// peer reached through a range without the shared secret never
|
|
72
|
+
// vouches (any local process could claim the first admin).
|
|
73
|
+
if (!ownHostPeerPermitted(request.socket.remoteAddress, proxy.trustedProxies, proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
|
|
74
|
+
if (diagnose)
|
|
75
|
+
warnProxyRejected(request, ProxyRejectReason.OWN_HOST);
|
|
57
76
|
return undefined;
|
|
58
77
|
}
|
|
59
78
|
if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
|
|
@@ -62,16 +81,32 @@ function resolveProxyIdentity(request, ctx) {
|
|
|
62
81
|
delete request.headers[name];
|
|
63
82
|
if (presented === undefined ||
|
|
64
83
|
!timingSafeStringEqual(presented, proxy.secret)) {
|
|
84
|
+
if (diagnose) {
|
|
85
|
+
warnProxyRejected(request, ProxyRejectReason.secretReason(proxy.secretHeader));
|
|
86
|
+
}
|
|
65
87
|
return undefined;
|
|
66
88
|
}
|
|
67
89
|
}
|
|
68
90
|
const user = headerOf(request, proxy.userHeader);
|
|
69
|
-
|
|
91
|
+
// An absent OR EMPTY (headerOf trims to undefined) user header is the
|
|
92
|
+
// missing-header reason; a present-but-unrepresentable claim is the
|
|
93
|
+
// unusable-claim reason. Neither line carries the claimed VALUE.
|
|
94
|
+
if (user === undefined) {
|
|
95
|
+
if (diagnose) {
|
|
96
|
+
warnProxyRejected(request, ProxyRejectReason.missingHeaderReason(proxy.userHeader.toLowerCase()));
|
|
97
|
+
}
|
|
70
98
|
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const preview = previewProxyUser(ctx.opened.identity, user);
|
|
101
|
+
if (preview === undefined) {
|
|
102
|
+
if (diagnose)
|
|
103
|
+
warnProxyRejected(request, ProxyRejectReason.UNUSABLE);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
71
106
|
const name = headerOf(request, proxy.nameHeader);
|
|
72
107
|
const email = headerOf(request, proxy.emailHeader);
|
|
73
108
|
return {
|
|
74
|
-
|
|
109
|
+
...preview,
|
|
75
110
|
...(name === undefined ? {} : { name }),
|
|
76
111
|
...(email === undefined ? {} : { email }),
|
|
77
112
|
};
|
|
@@ -132,6 +167,15 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
132
167
|
}
|
|
133
168
|
const proxyIdentity = resolveProxyIdentity(request, ctx);
|
|
134
169
|
const loopback = isLoopbackHost(ctx.listenHost);
|
|
170
|
+
// C2: a proxy-only non-loopback server creates accounts ONLY
|
|
171
|
+
// through a proxy vouch — refuse BEFORE code evaluation, so even
|
|
172
|
+
// a valid setup code or invite code cannot mint an account
|
|
173
|
+
// unvouched on this posture. Mixed [proxy, token] and loopback
|
|
174
|
+
// binds keep the code paths.
|
|
175
|
+
if (requiresProxyVouch(ctx.methods, ctx.listenHost) &&
|
|
176
|
+
proxyIdentity === undefined) {
|
|
177
|
+
return await reply.code(403).send({ error: "proxy_identity_required" });
|
|
178
|
+
}
|
|
135
179
|
// The code's validity is evaluated ONCE, BEFORE the mutex: the winner
|
|
136
180
|
// clears the setup-code file inside its critical section, so a claim
|
|
137
181
|
// that loses the race must still be recognizable as having held a
|
|
@@ -149,25 +193,29 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
149
193
|
// code ("the first invite" — same shape, admin grant).
|
|
150
194
|
let grant;
|
|
151
195
|
if (proxyIdentity !== undefined) {
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
196
|
+
// Body identity fields are ignored — the proxy vouched. This
|
|
197
|
+
// route already holds the claim lock, so call the lock-free
|
|
198
|
+
// resolver body, never the pipeline entry.
|
|
199
|
+
const resolved = await provisionProxyUser(identity, proxyIdentity.subject, {
|
|
200
|
+
...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
|
|
201
|
+
...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
|
|
202
|
+
}, { provision: true, admin: true });
|
|
203
|
+
if (resolved.kind === "invalid") {
|
|
204
|
+
return { status: 400, error: "invalid_username" };
|
|
205
|
+
}
|
|
206
|
+
if (resolved.kind === "not_provisioned") {
|
|
207
|
+
return { status: 409, error: "already_claimed" };
|
|
208
|
+
}
|
|
209
|
+
grant = resolved.record;
|
|
155
210
|
}
|
|
156
|
-
else if (loopback) {
|
|
211
|
+
else if (loopback || codeWasSetup) {
|
|
157
212
|
if (validated.user === undefined)
|
|
158
213
|
return { status: 400, error: "invalid_body" };
|
|
214
|
+
const typed = normalizeTypedUsername(validated.user, (key) => identity.users.get(key) !== undefined);
|
|
215
|
+
if (!typed.ok)
|
|
216
|
+
return { status: typed.error === "invalid_username" ? 400 : 409, error: typed.error };
|
|
159
217
|
grant = {
|
|
160
|
-
user:
|
|
161
|
-
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
162
|
-
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
163
|
-
source: "cli",
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
else if (codeWasSetup) {
|
|
167
|
-
if (validated.user === undefined)
|
|
168
|
-
return { status: 400, error: "invalid_body" };
|
|
169
|
-
grant = {
|
|
170
|
-
user: validated.user,
|
|
218
|
+
user: typed.key,
|
|
171
219
|
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
172
220
|
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
173
221
|
source: "cli",
|
|
@@ -177,10 +225,8 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
177
225
|
// Wrong or absent code where one is required.
|
|
178
226
|
return { status: 403, error: "invalid_code" };
|
|
179
227
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
const record = await identity.users.add({
|
|
228
|
+
// The proxy resolver already added and linked its record.
|
|
229
|
+
const record = "createdAt" in grant ? grant : await identity.users.add({
|
|
184
230
|
user: grant.user,
|
|
185
231
|
...(grant.name === undefined ? {} : { name: grant.name }),
|
|
186
232
|
...(grant.email === undefined ? {} : { email: grant.email }),
|
|
@@ -207,17 +253,31 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
207
253
|
return { status: 409, error: "already_claimed" };
|
|
208
254
|
}
|
|
209
255
|
if (validated.code !== undefined) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
256
|
+
if (validated.user === undefined) {
|
|
257
|
+
return { status: 403, error: "invalid_code" };
|
|
258
|
+
}
|
|
259
|
+
const typed = normalizeTypedUsername(validated.user, (key) => identity.users.get(key) !== undefined);
|
|
260
|
+
if (!typed.ok && typed.error === "invalid_username") {
|
|
261
|
+
return { status: 400, error: typed.error };
|
|
262
|
+
}
|
|
263
|
+
// Validate before revealing a collision, but never consume
|
|
264
|
+
// the invite until every refusal check has passed.
|
|
265
|
+
const invite = await identity.invites.peek(validated.code);
|
|
213
266
|
if (invite === undefined) {
|
|
214
267
|
return { status: 403, error: "invalid_code" };
|
|
215
268
|
}
|
|
269
|
+
if (!typed.ok)
|
|
270
|
+
return { status: 409, error: typed.error };
|
|
271
|
+
identity.users.checkAdmission();
|
|
272
|
+
const redeemed = await identity.invites.redeem(validated.code, typed.key);
|
|
273
|
+
if (redeemed === undefined) {
|
|
274
|
+
return { status: 403, error: "invalid_code" };
|
|
275
|
+
}
|
|
216
276
|
const record = await identity.users.add({
|
|
217
|
-
user:
|
|
277
|
+
user: typed.key,
|
|
218
278
|
...(validated.name === undefined ? {} : { name: validated.name }),
|
|
219
279
|
...(validated.email === undefined ? {} : { email: validated.email }),
|
|
220
|
-
admin:
|
|
280
|
+
admin: redeemed.admin,
|
|
221
281
|
source: "invite",
|
|
222
282
|
});
|
|
223
283
|
return { status: 200, record };
|
|
@@ -228,17 +288,17 @@ export function registerOnboardingRoutes(app, ctx) {
|
|
|
228
288
|
if (proxyIdentity === undefined) {
|
|
229
289
|
return { status: 409, error: "already_claimed" };
|
|
230
290
|
}
|
|
231
|
-
const
|
|
232
|
-
if (existing !== undefined)
|
|
233
|
-
return { status: 200, record: existing };
|
|
234
|
-
const record = await identity.users.add({
|
|
235
|
-
user: proxyIdentity.user,
|
|
291
|
+
const resolved = await provisionProxyUser(identity, proxyIdentity.subject, {
|
|
236
292
|
...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
|
|
237
293
|
...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
294
|
+
},
|
|
295
|
+
// A codeless onboarding claim has always auto-provisioned the
|
|
296
|
+
// vouched identity, independent of the pipeline's policy.
|
|
297
|
+
{ provision: true, admin: false });
|
|
298
|
+
if (resolved.kind !== "user") {
|
|
299
|
+
return { status: 409, error: "already_claimed" };
|
|
300
|
+
}
|
|
301
|
+
return { status: 200, record: resolved.record };
|
|
242
302
|
}
|
|
243
303
|
catch (error) {
|
|
244
304
|
if (error instanceof AdmissionRefusedError) {
|
|
@@ -49,6 +49,7 @@ export const SessionMetaDoc = defineDoc({
|
|
|
49
49
|
kind: "sigrun.session-meta",
|
|
50
50
|
version: 1,
|
|
51
51
|
scope: "conversation",
|
|
52
|
+
checkpointWhen: () => false,
|
|
52
53
|
history: "latest",
|
|
53
54
|
fork: "current",
|
|
54
55
|
// Defensive copy (the alias-free-ownership pattern): the tracker takes
|
package/dist/server/shutdown.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The ordered shutdown (gateway C2's extraction of serve.ts's inline
|
|
3
3
|
* `shutdown`): eight steps, each in its own try/catch so a failure never
|
|
4
4
|
* skips the remaining steps, with every failure collected into one
|
|
5
|
-
* `AggregateError("sigrun shutdown failed")` after ALL steps ran
|
|
6
|
-
*
|
|
5
|
+
* `AggregateError("sigrun shutdown failed")` after ALL steps ran. Every
|
|
6
|
+
* asynchronous stage has a fixed cap; reaching one abandons only that wait,
|
|
7
|
+
* logs, and continues, so the composed shutdown has a fixed worst case.
|
|
7
8
|
*
|
|
8
9
|
* The order (spec §8.0 — the gateway lifecycle slots between the server
|
|
9
10
|
* drain and the event-bus flush):
|
|
@@ -28,6 +29,82 @@
|
|
|
28
29
|
* Does NOT exit the process — the caller owns exit policy (the deploy
|
|
29
30
|
* entry exits 0/1; the CLI's `run` returns to its exit code).
|
|
30
31
|
*/
|
|
32
|
+
import { SHUTDOWN_SETTLE_WAIT_MS, WORKER_SHUTDOWN_AWAIT_MS, } from "../harness/event-bus.js";
|
|
33
|
+
/** Every ordered stage has a bound; these are the bounds used by the budget. */
|
|
34
|
+
export const END_STREAMS_SHUTDOWN_CAP_MS = 0;
|
|
35
|
+
export const CLOSE_APP_SHUTDOWN_CAP_MS = 10_000;
|
|
36
|
+
export const GATEWAY_STOP_INTAKE_SHUTDOWN_CAP_MS = 10_000;
|
|
37
|
+
export const GATEWAY_STOP_SHUTDOWN_CAP_MS = 10_000;
|
|
38
|
+
export const SANDBOX_WORKERS_SHUTDOWN_CAP_MS = 5_000;
|
|
39
|
+
export const PROVIDER_REFRESH_SHUTDOWN_CAP_MS = 2_000;
|
|
40
|
+
/**
|
|
41
|
+
* Measured against a real 1.4.0 boot over a copy of the durable-v1 fixture
|
|
42
|
+
* in test/shutdown-order.test.ts (0.6ms on the implementation run). The test
|
|
43
|
+
* asserts this cap is at least four times that measurement, so a slow but
|
|
44
|
+
* healthy close never gets converted into a counted restart by a guessed cap.
|
|
45
|
+
*/
|
|
46
|
+
export const CLOSE_HARNESS_SHUTDOWN_CAP_MS = 10_000;
|
|
47
|
+
/**
|
|
48
|
+
* The event bus's bounded stages: worker stop + the initial drain + the
|
|
49
|
+
* live-run settle wait AND the final drain that can start just before its
|
|
50
|
+
* deadline + all extension handlers under one shared deadline. The settle
|
|
51
|
+
* wait is a per-boot test override; the production budget uses its default.
|
|
52
|
+
* This bounds each drain WAIT, not work a caller can ride on an already
|
|
53
|
+
* running side lane: the grace holds when the side lane is idle at shutdown
|
|
54
|
+
* (the quiet admission window). An outer boundedStage wrap or stopping
|
|
55
|
+
* enqueue at shutdownSequence entry is a deferred structural option.
|
|
56
|
+
*/
|
|
57
|
+
export function eventBusShutdownCapMs(config) {
|
|
58
|
+
return (WORKER_SHUTDOWN_AWAIT_MS +
|
|
59
|
+
config.hookDeadlineMs +
|
|
60
|
+
SHUTDOWN_SETTLE_WAIT_MS +
|
|
61
|
+
config.hookDeadlineMs +
|
|
62
|
+
config.hookDeadlineMs);
|
|
63
|
+
}
|
|
64
|
+
/** The fixed worst case of the complete ordered shutdown, in milliseconds. */
|
|
65
|
+
export function shutdownBudgetMs(config) {
|
|
66
|
+
return (END_STREAMS_SHUTDOWN_CAP_MS +
|
|
67
|
+
CLOSE_APP_SHUTDOWN_CAP_MS +
|
|
68
|
+
GATEWAY_STOP_INTAKE_SHUTDOWN_CAP_MS +
|
|
69
|
+
eventBusShutdownCapMs(config) +
|
|
70
|
+
GATEWAY_STOP_SHUTDOWN_CAP_MS +
|
|
71
|
+
SANDBOX_WORKERS_SHUTDOWN_CAP_MS +
|
|
72
|
+
PROVIDER_REFRESH_SHUTDOWN_CAP_MS +
|
|
73
|
+
CLOSE_HARNESS_SHUTDOWN_CAP_MS);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Await one stage without allowing a wedged implementation to outlive its
|
|
77
|
+
* budget share. Abandonment is logged; the next ordered stage still runs.
|
|
78
|
+
*/
|
|
79
|
+
async function boundedStage(name, capMs, run) {
|
|
80
|
+
let timer;
|
|
81
|
+
const deadline = new Promise((resolve) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
console.error(`sigrun: shutdown stage ${name} exceeded ${capMs}ms — abandoning the wait`);
|
|
84
|
+
resolve();
|
|
85
|
+
}, capMs);
|
|
86
|
+
});
|
|
87
|
+
let abandoned = false;
|
|
88
|
+
const stage = Promise.resolve().then(run);
|
|
89
|
+
// The race can abandon a stage that later rejects. Observe that rejection
|
|
90
|
+
// here so it is logged rather than becoming an unhandled rejection. An
|
|
91
|
+
// early rejection already propagated to createShutdown's failure collector,
|
|
92
|
+
// so it must not also be reported as an abandonment.
|
|
93
|
+
stage.catch((error) => {
|
|
94
|
+
if (abandoned) {
|
|
95
|
+
console.error(`sigrun: shutdown stage ${name} rejected after abandonment:`, error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await Promise.race([stage, deadline.then(() => {
|
|
100
|
+
abandoned = true;
|
|
101
|
+
})]);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
if (timer !== undefined)
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
31
108
|
/**
|
|
32
109
|
* Build the ordered shutdown over the eight steps. Failures from ANY step
|
|
33
110
|
* are collected (the remaining steps still run) and thrown together as
|
|
@@ -41,52 +118,52 @@ export function createShutdown(steps) {
|
|
|
41
118
|
steps.endStreams();
|
|
42
119
|
}
|
|
43
120
|
catch (error) {
|
|
44
|
-
failures.push(error);
|
|
121
|
+
failures.push({ stage: "endStreams", error });
|
|
45
122
|
}
|
|
46
123
|
try {
|
|
47
|
-
await steps.closeApp();
|
|
124
|
+
await boundedStage("closeApp", CLOSE_APP_SHUTDOWN_CAP_MS, () => steps.closeApp());
|
|
48
125
|
}
|
|
49
126
|
catch (error) {
|
|
50
|
-
failures.push(error);
|
|
127
|
+
failures.push({ stage: "closeApp", error });
|
|
51
128
|
}
|
|
52
129
|
try {
|
|
53
|
-
await steps.gateway.stopIntake();
|
|
130
|
+
await boundedStage("gateway.stopIntake", GATEWAY_STOP_INTAKE_SHUTDOWN_CAP_MS, () => steps.gateway.stopIntake());
|
|
54
131
|
}
|
|
55
132
|
catch (error) {
|
|
56
|
-
failures.push(error);
|
|
133
|
+
failures.push({ stage: "gateway.stopIntake", error });
|
|
57
134
|
}
|
|
58
135
|
try {
|
|
59
136
|
await steps.eventBusShutdown();
|
|
60
137
|
}
|
|
61
138
|
catch (error) {
|
|
62
|
-
failures.push(error);
|
|
139
|
+
failures.push({ stage: "eventBusShutdown", error });
|
|
63
140
|
}
|
|
64
141
|
try {
|
|
65
|
-
await steps.gateway.stop();
|
|
142
|
+
await boundedStage("gateway.stop", GATEWAY_STOP_SHUTDOWN_CAP_MS, () => steps.gateway.stop());
|
|
66
143
|
}
|
|
67
144
|
catch (error) {
|
|
68
|
-
failures.push(error);
|
|
145
|
+
failures.push({ stage: "gateway.stop", error });
|
|
69
146
|
}
|
|
70
147
|
try {
|
|
71
|
-
await steps.stopSandboxWorkers();
|
|
148
|
+
await boundedStage("stopSandboxWorkers", SANDBOX_WORKERS_SHUTDOWN_CAP_MS, () => steps.stopSandboxWorkers());
|
|
72
149
|
}
|
|
73
150
|
catch (error) {
|
|
74
|
-
failures.push(error);
|
|
151
|
+
failures.push({ stage: "stopSandboxWorkers", error });
|
|
75
152
|
}
|
|
76
153
|
try {
|
|
77
154
|
await steps.stopProviderRefresh();
|
|
78
155
|
}
|
|
79
156
|
catch (error) {
|
|
80
|
-
failures.push(error);
|
|
157
|
+
failures.push({ stage: "stopProviderRefresh", error });
|
|
81
158
|
}
|
|
82
159
|
try {
|
|
83
|
-
await steps.closeHarness();
|
|
160
|
+
await boundedStage("closeHarness", CLOSE_HARNESS_SHUTDOWN_CAP_MS, () => steps.closeHarness());
|
|
84
161
|
}
|
|
85
162
|
catch (error) {
|
|
86
|
-
failures.push(error);
|
|
163
|
+
failures.push({ stage: "closeHarness", error });
|
|
87
164
|
}
|
|
88
165
|
if (failures.length > 0) {
|
|
89
|
-
throw new AggregateError(failures,
|
|
166
|
+
throw new AggregateError(failures.map(({ error }) => error), `sigrun shutdown failed: ${failures.map(({ stage }) => stage).join(", ")}`);
|
|
90
167
|
}
|
|
91
168
|
};
|
|
92
169
|
}
|
|
@@ -290,6 +290,12 @@ export const CORE_SETTING_KEYS = [
|
|
|
290
290
|
section: "advanced",
|
|
291
291
|
reason: "compaction policy resolves into the boot harness settings",
|
|
292
292
|
},
|
|
293
|
+
{
|
|
294
|
+
key: "server.compaction.modelOverrides",
|
|
295
|
+
apply: "restart",
|
|
296
|
+
section: "advanced",
|
|
297
|
+
reason: "compaction policy resolves into the boot harness settings",
|
|
298
|
+
},
|
|
293
299
|
{
|
|
294
300
|
key: "server.memorySeam.hookDeadlineMs",
|
|
295
301
|
apply: "restart",
|
|
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
|
+
### Added
|
|
10
|
+
- The `./usernames` export for shared username derivation and validation.
|
|
11
|
+
|
|
12
|
+
## [0.3.0] - 2026-10-29
|
|
13
|
+
### Added
|
|
14
|
+
- `ServerSettings.compaction.modelOverrides` declares per-model compaction
|
|
15
|
+
policy overrides in the shared settings wire type.
|
|
9
16
|
|
|
10
17
|
## [0.2.1] - 2026-10-28
|
|
11
18
|
### Added
|
|
@@ -136,6 +136,20 @@ export interface ServerSettings {
|
|
|
136
136
|
readonly reserveTokens?: number;
|
|
137
137
|
readonly keepRecentTokens?: number;
|
|
138
138
|
readonly backgroundTokens?: number;
|
|
139
|
+
/**
|
|
140
|
+
* Per-model compaction policy overrides, keyed by the exact
|
|
141
|
+
* `provider/modelId` string pi-durable composes — a chain-shaped
|
|
142
|
+
* model id doubles the prefix (e.g. `bifrost/bifrost/<chain>`).
|
|
143
|
+
* Each field replaces the harness-wide one for that model, so a
|
|
144
|
+
* small-window model can sit alongside the large-window default.
|
|
145
|
+
* Boot-time lifetime, like the rest of compaction.
|
|
146
|
+
*/
|
|
147
|
+
readonly modelOverrides?: Readonly<Record<string, Partial<{
|
|
148
|
+
enabled: boolean;
|
|
149
|
+
reserveTokens: number;
|
|
150
|
+
keepRecentTokens: number;
|
|
151
|
+
backgroundTokens: number;
|
|
152
|
+
}>>>;
|
|
139
153
|
};
|
|
140
154
|
/**
|
|
141
155
|
* The memory-seam bounds (memory-seam Task 6), mirroring the
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const MAX_USERNAME_LENGTH = 128;
|
|
2
|
+
export declare const MAX_SUFFIX_ATTEMPTS = 10000;
|
|
3
|
+
export declare class UsernameExhaustedError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare function deriveUsername(input: string): string | undefined;
|
|
6
|
+
export declare function isUsernameKey(s: string): boolean;
|
|
7
|
+
export declare function foldSubject(s: string): string;
|
|
8
|
+
export declare function withSuffix(base: string, taken: (key: string) => boolean): string;
|