@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.
- package/CHANGELOG.md +145 -7
- package/README.md +333 -13
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +48 -8
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +92 -6
- package/dist/harness/builtins.js +1 -1
- 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 +23 -1
- package/dist/harness/sections/context-files.js +1 -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/sandbox/availability.js +69 -20
- package/dist/sandbox/launch.js +11 -0
- package/dist/sandbox/policy.js +24 -3
- package/dist/sandbox/worker.js +4 -24
- package/dist/serve.js +162 -40
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +93 -28
- 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/images/cache.js +13 -5
- package/dist/server/index.js +10 -2
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/notifications.js +21 -4
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/images.js +16 -9
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/me.js +1 -1
- package/dist/server/routes/meta.js +4 -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 +12 -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 +33 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +16 -15
- package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/CONTEXT.md +0 -163
- 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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:
|
|
143
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
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);
|
package/dist/server/routes/me.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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]));
|