toolroll 0.9.54 → 0.9.55
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 +9 -0
- package/README.md +5 -4
- package/dist/agent-onboard.js +4 -1
- package/dist/approval-rules-ui.d.ts +3 -2
- package/dist/approval-rules-ui.js +15 -20
- package/dist/assignment-ui.d.ts +3 -2
- package/dist/assignment-ui.js +4 -11
- package/dist/backup-ui.d.ts +2 -1
- package/dist/backup-ui.js +16 -27
- package/dist/brand-mark.d.ts +3 -2
- package/dist/brand-mark.js +4 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-shell.d.ts +2 -1
- package/dist/browser-shell.js +9 -9
- package/dist/browser-workspace.d.ts +2 -3
- package/dist/browser-workspace.js +0 -3
- package/dist/chat-polish.d.ts +5 -4
- package/dist/chat-polish.js +9 -10
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +26 -53
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +9 -3
- package/dist/coding-shipping-ui.d.ts +2 -1
- package/dist/coding-shipping-ui.js +6 -6
- package/dist/coding-ui.d.ts +3 -1
- package/dist/coding-ui.js +30 -29
- package/dist/contracts/cli.d.ts +1 -3
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +6 -5
- package/dist/contracts/health.d.ts +1 -3
- package/dist/control-ui.d.ts +5 -4
- package/dist/control-ui.js +9 -34
- package/dist/credentials-ui.d.ts +5 -4
- package/dist/credentials-ui.js +23 -24
- package/dist/demo-chat.d.ts +3 -3
- package/dist/demo-chat.js +29 -76
- package/dist/discord-settings.d.ts +3 -2
- package/dist/discord-settings.js +17 -42
- package/dist/envelope.js +10 -0
- package/dist/evidence-pack.d.ts +2 -1
- package/dist/evidence-pack.js +45 -49
- package/dist/export-ui.d.ts +7 -2
- package/dist/export-ui.js +4 -10
- package/dist/flow-gallery-ui.d.ts +4 -3
- package/dist/flow-gallery-ui.js +28 -43
- package/dist/flow-starters-ui.d.ts +2 -1
- package/dist/flow-starters-ui.js +12 -15
- package/dist/flow-triggers.d.ts +1 -0
- package/dist/flow-triggers.js +16 -14
- package/dist/flows-ui.d.ts +4 -3
- package/dist/flows-ui.js +19 -30
- package/dist/flows.js +2 -1
- package/dist/guides.js +108 -3
- package/dist/html.d.ts +76 -0
- package/dist/html.js +173 -0
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +19 -23
- package/dist/kits-ui.d.ts +5 -4
- package/dist/kits-ui.js +18 -20
- package/dist/knowledge-ui.d.ts +6 -5
- package/dist/knowledge-ui.js +26 -25
- package/dist/lead-context.d.ts +11 -1
- package/dist/lead-context.js +6 -8
- package/dist/ledger-view.d.ts +2 -1
- package/dist/ledger-view.js +27 -29
- package/dist/limits-ui.d.ts +2 -1
- package/dist/limits-ui.js +7 -7
- package/dist/live-bus.d.ts +50 -8
- package/dist/live-bus.js +133 -50
- package/dist/mcp-oauth.js +35 -36
- package/dist/models-ui.d.ts +2 -1
- package/dist/models-ui.js +25 -35
- package/dist/monitoring-ui.d.ts +4 -3
- package/dist/monitoring-ui.js +13 -26
- package/dist/openrouter-models.d.ts +2 -1
- package/dist/openrouter-models.js +5 -9
- package/dist/operate.js +11 -7
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +13 -13
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +17 -23
- package/dist/project-delete-ui.d.ts +4 -3
- package/dist/project-delete-ui.js +15 -27
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +17 -32
- package/dist/recipe-ui.d.ts +8 -7
- package/dist/recipe-ui.js +39 -32
- package/dist/release-adapters.d.ts +60 -0
- package/dist/release-adapters.js +435 -0
- package/dist/release-cli.d.ts +24 -0
- package/dist/release-cli.js +181 -0
- package/dist/release-gate.d.ts +80 -0
- package/dist/release-gate.js +150 -0
- package/dist/release-links.d.ts +29 -0
- package/dist/release-links.js +102 -0
- package/dist/release.d.ts +331 -0
- package/dist/release.js +550 -0
- package/dist/reply-shape.d.ts +2 -1
- package/dist/reply-shape.js +5 -5
- package/dist/repository-context-ui.d.ts +2 -1
- package/dist/repository-context-ui.js +2 -7
- package/dist/request-budget-ui.d.ts +2 -1
- package/dist/request-budget-ui.js +16 -16
- package/dist/result-review.d.ts +2 -3
- package/dist/result-review.js +3 -9
- package/dist/retention-ui.d.ts +7 -1
- package/dist/retention-ui.js +7 -11
- package/dist/serve.d.ts +11 -2
- package/dist/serve.js +69 -58
- package/dist/server/chat.d.ts +3 -6
- package/dist/server/chat.js +969 -1006
- package/dist/server/chrome.d.ts +291 -0
- package/dist/server/chrome.js +747 -0
- package/dist/server/console.css +2819 -0
- package/dist/server/flows.d.ts +2 -6
- package/dist/server/flows.js +840 -766
- package/dist/server/guards.d.ts +1 -1
- package/dist/server/guards.js +5 -2
- package/dist/server/handler-context.d.ts +1 -1
- package/dist/server/handler-context.js +1 -1
- package/dist/server/handler-registry.d.ts +8 -6
- package/dist/server/handler-registry.js +14 -2
- package/dist/server/http.d.ts +274 -0
- package/dist/server/http.js +239 -0
- package/dist/server/live.d.ts +34 -0
- package/dist/server/live.js +309 -0
- package/dist/server/pages.d.ts +2 -6
- package/dist/server/pages.js +808 -789
- package/dist/server/people-tokens.d.ts +1 -6
- package/dist/server/people-tokens.js +580 -572
- package/dist/server/remote.d.ts +4 -7
- package/dist/server/remote.js +37 -70
- package/dist/server/render-chat.d.ts +402 -0
- package/dist/server/render-chat.js +927 -0
- package/dist/server/render-pages.d.ts +535 -0
- package/dist/server/render-pages.js +1277 -0
- package/dist/server/render-people.d.ts +32 -0
- package/dist/server/render-people.js +121 -0
- package/dist/server/render-results.d.ts +744 -0
- package/dist/server/render-results.js +2237 -0
- package/dist/server/render-settings.d.ts +147 -0
- package/dist/server/render-settings.js +577 -0
- package/dist/server/render-tasks.d.ts +632 -0
- package/dist/server/render-tasks.js +2345 -0
- package/dist/server/request-context.d.ts +46 -0
- package/dist/server/request-context.js +17 -0
- package/dist/server/route-table.d.ts +25 -3
- package/dist/server/route-table.js +163 -75
- package/dist/server/runtime.d.ts +15 -7
- package/dist/server/runtime.js +7 -1
- package/dist/server/session.d.ts +138 -0
- package/dist/server/session.js +94 -0
- package/dist/server/settings.d.ts +1 -5
- package/dist/server/settings.js +2181 -2117
- package/dist/server/task-views.d.ts +11 -4
- package/dist/server/task-views.js +26 -22
- package/dist/server/tasks.d.ts +9 -9
- package/dist/server/tasks.js +1486 -1418
- package/dist/server-telemetry.d.ts +4 -3
- package/dist/server-telemetry.js +4 -8
- package/dist/skills-ui.d.ts +4 -3
- package/dist/skills-ui.js +14 -17
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +2 -1
- package/dist/slack-settings.d.ts +3 -2
- package/dist/slack-settings.js +17 -42
- package/dist/spend-ui.d.ts +2 -1
- package/dist/spend-ui.js +9 -32
- package/dist/sso-ui.d.ts +3 -2
- package/dist/sso-ui.js +26 -27
- package/dist/storage-ui.d.ts +2 -1
- package/dist/storage-ui.js +14 -33
- package/dist/surface.js +14 -3
- package/dist/task-composer.d.ts +2 -1
- package/dist/task-composer.js +7 -7
- package/dist/task-status.d.ts +4 -3
- package/dist/task-status.js +15 -16
- package/dist/team-http.d.ts +5 -9
- package/dist/team-http.js +5 -63
- package/dist/team-ui.d.ts +2 -1
- package/dist/team-ui.js +2 -5
- package/dist/teammates-ui.d.ts +5 -4
- package/dist/teammates-ui.js +61 -90
- package/dist/teams-settings.d.ts +3 -2
- package/dist/teams-settings.js +20 -31
- package/dist/telegram-settings.d.ts +3 -2
- package/dist/telegram-settings.js +14 -25
- package/dist/toolroll-update-ui.d.ts +3 -2
- package/dist/toolroll-update-ui.js +35 -39
- package/dist/tools-ui.d.ts +5 -2
- package/dist/tools-ui.js +31 -31
- package/dist/when-html.d.ts +11 -3
- package/dist/when-html.js +5 -6
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +10 -10
- package/package.json +1 -1
- package/dist/server/shared.d.ts +0 -3151
- package/dist/server/shared.js +0 -12001
package/dist/server/pages.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { handlersOf } from './handler-registry.js';
|
|
2
|
+
import { html, joinHtml, postForm } from "../html.js";
|
|
1
3
|
/** pages handlers, moved without changing their route bodies. */
|
|
2
4
|
import { createHmac, randomBytes } from "node:crypto";
|
|
3
5
|
import { existsSync, opendirSync, readdirSync, realpathSync } from "node:fs";
|
|
@@ -32,454 +34,265 @@ import { healthWords } from "../server-telemetry.js";
|
|
|
32
34
|
import { tally } from "../summary.js";
|
|
33
35
|
import { serveInstallAsset } from "./pages-assets.js";
|
|
34
36
|
import { healthz } from "./remote-hooks.js";
|
|
35
|
-
import {
|
|
37
|
+
import { projectChip, refuse, regionScript, relativeAge, screen, settingsRows, transcriptScript, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
|
|
38
|
+
import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
|
|
39
|
+
import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
|
|
40
|
+
import { capsPage } from "./render-settings.js";
|
|
41
|
+
import { decisionAnswerScript, taskBody } from "./render-tasks.js";
|
|
42
|
+
import {} from "./session.js";
|
|
36
43
|
export function createPagesHandlers(runtime) {
|
|
37
44
|
const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, projectOf, lookupSession } = runtime;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
return { name: one.name, path: resolved, git: existsSync(join(resolved, ".git")) };
|
|
76
|
-
})
|
|
77
|
-
.filter((one) => one !== null)
|
|
78
|
-
.sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
|
|
79
|
-
.slice(0, 200);
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
return refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
|
|
83
|
-
}
|
|
84
|
-
const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
|
|
85
|
-
const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
|
|
86
|
-
const csrf = who.session.csrf;
|
|
87
|
-
return sendScreen(response, 200, browsePage(chromeFor(who.session.project, "projects"), {
|
|
88
|
-
at: canonical,
|
|
89
|
-
root,
|
|
90
|
-
roots: browseRoots,
|
|
91
|
-
parent,
|
|
92
|
-
entries,
|
|
93
|
-
csrf,
|
|
94
|
-
}));
|
|
95
|
-
}
|
|
96
|
-
if (url.pathname === "/projects/github") {
|
|
97
|
-
// The signed-in gh account's repositories, OFFERED instead of typed
|
|
98
|
-
// (operator request): read-only listing through the same hardened gh
|
|
99
|
-
// runner onboarding uses, every identity re-parsed strictly, and each
|
|
100
|
-
// row carries exactly ONE honest action — open (already a project
|
|
101
|
-
// here), add (a clone under a root this server may serve, never yet
|
|
102
|
-
// opened), or the EXISTING preview-and-password clone ceremony.
|
|
103
|
-
// Approver only: a viewer has no business enumerating private
|
|
104
|
-
// repository names.
|
|
105
|
-
if (who.via !== "cookie")
|
|
106
|
-
return refuse(response, who, 403, "listing repositories feeds a browser session's act");
|
|
107
|
-
if (who.role !== "approver")
|
|
108
|
-
return refuse(response, who, 403, "your login can watch — adding projects is an approver's act");
|
|
109
|
-
if (store.isDemo())
|
|
110
|
-
return refuse(response, who, 403, "the sandbox never talks to GitHub");
|
|
111
|
-
const listed = await (options.ghList ?? listGithubRepos)();
|
|
112
|
-
// What this machine already has, matched by the clone's own recorded
|
|
113
|
-
// origin — registered projects first, then the immediate children of
|
|
114
|
-
// the configured roots. Identities are compared case-insensitively
|
|
115
|
-
// (GitHub's rule); the FIRST match wins.
|
|
116
|
-
const localByIdentity = new Map();
|
|
117
|
-
const registered = new Set();
|
|
118
|
-
for (const project of store.listProjects()) {
|
|
119
|
-
if (!visible(project.path))
|
|
120
|
-
continue;
|
|
121
|
-
registered.add(project.path);
|
|
122
|
-
const identity = githubIdentityOf(originUrlOf(project.path) ?? "");
|
|
123
|
-
if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
|
|
124
|
-
localByIdentity.set(identity.toLowerCase(), project.path);
|
|
45
|
+
// The filesystem browser (operator request): pick a project folder by
|
|
46
|
+
// looking, not typing. CONFINED to exactly what opening allows —
|
|
47
|
+
// explicit --repo mode has an enumerable list and gets no browser at
|
|
48
|
+
// all; --project-root mode browses its roots; unscoped mode (trust-
|
|
49
|
+
// everything, named as such) browses from the home directory.
|
|
50
|
+
// Directory NAMES only, never file contents; symlinks are resolved
|
|
51
|
+
// and re-checked so a link cannot walk out of the fence.
|
|
52
|
+
async function projectsBrowse(ctx) {
|
|
53
|
+
const { url, who, response } = ctx;
|
|
54
|
+
if (who.via !== "cookie")
|
|
55
|
+
return refuse(response, who, 403, "browsing feeds a browser session's act");
|
|
56
|
+
const browseRoots = ceiling.roots.length > 0 ? [...ceiling.roots] : unscopedMode ? [realpathSync(homedir())] : [];
|
|
57
|
+
if (browseRoots.length === 0) {
|
|
58
|
+
return refuse(response, who, 404, "this server was configured with an explicit repo list — the openable projects are all on the projects page", "/projects");
|
|
59
|
+
}
|
|
60
|
+
const asked = url.searchParams.get("at") ?? browseRoots[0];
|
|
61
|
+
const canonical = canonicalProject(asked);
|
|
62
|
+
const inside = canonical !== null &&
|
|
63
|
+
browseRoots.some(root => canonical === root || canonical.startsWith(`${root}/`));
|
|
64
|
+
if (canonical === null || !inside) {
|
|
65
|
+
return refuse(response, who, 403, "that path is outside what this server may browse", "/projects/browse");
|
|
66
|
+
}
|
|
67
|
+
let entries = [];
|
|
68
|
+
try {
|
|
69
|
+
entries = readdirSync(canonical, { withFileTypes: true })
|
|
70
|
+
.filter(one => one.isDirectory() || one.isSymbolicLink())
|
|
71
|
+
.filter(one => !one.name.startsWith(".") && one.name !== "node_modules")
|
|
72
|
+
.slice(0, 400)
|
|
73
|
+
.map(one => {
|
|
74
|
+
const path = join(canonical, one.name);
|
|
75
|
+
// Resolve NOW so a symlink pointing outside the fence renders
|
|
76
|
+
// as nothing rather than as a door.
|
|
77
|
+
const resolved = canonicalProject(path);
|
|
78
|
+
if (resolved === null || !browseRoots.some(root => resolved === root || resolved.startsWith(`${root}/`))) {
|
|
79
|
+
return null;
|
|
125
80
|
}
|
|
81
|
+
return { name: one.name, path: resolved, git: existsSync(join(resolved, ".git")) };
|
|
82
|
+
})
|
|
83
|
+
.filter((one) => one !== null)
|
|
84
|
+
.sort((a, b) => Number(b.git) - Number(a.git) || a.name.localeCompare(b.name))
|
|
85
|
+
.slice(0, 200);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return refuse(response, who, 404, "that directory cannot be read", "/projects/browse");
|
|
89
|
+
}
|
|
90
|
+
const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
|
|
91
|
+
const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
|
|
92
|
+
const csrf = who.session.csrf;
|
|
93
|
+
return sendScreen(response, 200, browsePage(chromeFor(who.session.project, "projects"), {
|
|
94
|
+
at: canonical,
|
|
95
|
+
root,
|
|
96
|
+
roots: browseRoots,
|
|
97
|
+
parent,
|
|
98
|
+
entries,
|
|
99
|
+
csrf,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
// The signed-in gh account's repositories, OFFERED instead of typed
|
|
103
|
+
// (operator request): read-only listing through the same hardened gh
|
|
104
|
+
// runner onboarding uses, every identity re-parsed strictly, and each
|
|
105
|
+
// row carries exactly ONE honest action — open (already a project
|
|
106
|
+
// here), add (a clone under a root this server may serve, never yet
|
|
107
|
+
// opened), or the EXISTING preview-and-password clone ceremony.
|
|
108
|
+
// Approver only: a viewer has no business enumerating private
|
|
109
|
+
// repository names.
|
|
110
|
+
async function projectsGithub(ctx) {
|
|
111
|
+
const { who, response } = ctx;
|
|
112
|
+
if (store.isDemo())
|
|
113
|
+
return refuse(response, who, 403, "the sandbox never talks to GitHub");
|
|
114
|
+
const listed = await (options.ghList ?? listGithubRepos)();
|
|
115
|
+
// What this machine already has, matched by the clone's own recorded
|
|
116
|
+
// origin — registered projects first, then the immediate children of
|
|
117
|
+
// the configured roots. Identities are compared case-insensitively
|
|
118
|
+
// (GitHub's rule); the FIRST match wins.
|
|
119
|
+
const localByIdentity = new Map();
|
|
120
|
+
const registered = new Set();
|
|
121
|
+
for (const project of store.listProjects()) {
|
|
122
|
+
if (!visible(project.path))
|
|
123
|
+
continue;
|
|
124
|
+
registered.add(project.path);
|
|
125
|
+
const identity = githubIdentityOf(originUrlOf(project.path) ?? "");
|
|
126
|
+
if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
|
|
127
|
+
localByIdentity.set(identity.toLowerCase(), project.path);
|
|
126
128
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
}
|
|
130
|
+
// Roots are walked ONLY when there are listed repos to match against
|
|
131
|
+
// (finding 1), through an iterator that stops at 400 entries — a
|
|
132
|
+
// directory with a million children costs 400 stats, not a scan.
|
|
133
|
+
if (listed.ok && listed.repos.length > 0) {
|
|
134
|
+
for (const root of ceiling.roots) {
|
|
135
|
+
const children = [];
|
|
136
|
+
try {
|
|
137
|
+
const dir = opendirSync(root);
|
|
133
138
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (entry.isDirectory())
|
|
141
|
-
children.push(join(root, entry.name));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
finally {
|
|
145
|
-
dir.closeSync();
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
// an unreadable root offers nothing
|
|
150
|
-
}
|
|
151
|
-
for (const child of children) {
|
|
152
|
-
if (!existsSync(join(child, ".git")))
|
|
153
|
-
continue;
|
|
154
|
-
const identity = githubIdentityOf(originUrlOf(child) ?? "");
|
|
155
|
-
if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
|
|
156
|
-
localByIdentity.set(identity.toLowerCase(), child);
|
|
139
|
+
for (let seen = 0; seen < 400; seen++) {
|
|
140
|
+
const entry = dir.readSync();
|
|
141
|
+
if (entry === null)
|
|
142
|
+
break;
|
|
143
|
+
if (entry.isDirectory())
|
|
144
|
+
children.push(join(root, entry.name));
|
|
157
145
|
}
|
|
158
146
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return sendScreen(response, 200, githubReposPage(chromeFor(who.session.project, "projects"), {
|
|
162
|
-
listed,
|
|
163
|
-
local: localByIdentity,
|
|
164
|
-
registered,
|
|
165
|
-
csrf: who.session.csrf,
|
|
166
|
-
cloneReady: ceiling.roots.length > 0,
|
|
167
|
-
openProject: who.session.project ?? null,
|
|
168
|
-
}));
|
|
169
|
-
}
|
|
170
|
-
if (url.pathname === "/projects") {
|
|
171
|
-
return void projectsScreen(response, who, url.searchParams.get("said"), 200, safeReturn(url.searchParams.get("return")));
|
|
172
|
-
}
|
|
173
|
-
if (url.pathname === "/")
|
|
174
|
-
return redirect(response, who.via === "cookie" && !restricted() ? "/chat" : "/work");
|
|
175
|
-
// The board's old name; bookmarks keep working. A GET-only alias, so a
|
|
176
|
-
// plain 302 — never the shared 303 helper, which belongs to POST landings.
|
|
177
|
-
if (url.pathname === "/morning") {
|
|
178
|
-
response.writeHead(302, { ...SAFETY, Location: "/activity" });
|
|
179
|
-
response.end();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (url.pathname === "/workbench") {
|
|
183
|
-
// The fleet under one gaze (attended A1): the rail rolls up the whole
|
|
184
|
-
// ceiling regardless of the open project — watching is cross-project
|
|
185
|
-
// by nature; acting happens on task screens that re-prove everything.
|
|
186
|
-
const admission = admissionList();
|
|
187
|
-
const snapshot = store.boardScoped(null, now, 200, admission);
|
|
188
|
-
const rows = snapshot.tasks.filter(facts => facts.repo === null || visible(facts.repo));
|
|
189
|
-
const cards = rows.map(facts => classify(facts.blockerRepo !== null && !visible(facts.blockerRepo) ? { ...facts, blockerState: null } : facts, now));
|
|
190
|
-
const attention = cards.filter(card => card.lane === "attention").slice(0, 100);
|
|
191
|
-
const building = cards.filter(card => card.lane === "building");
|
|
192
|
-
const waiting = cards.filter(card => card.lane === "waiting");
|
|
193
|
-
const queued = cards.filter(card => card.lane === "queued");
|
|
194
|
-
const done = snapshot.done
|
|
195
|
-
.filter(one => one.repo === null || visible(one.repo))
|
|
196
|
-
.slice(0, 5)
|
|
197
|
-
.map(one => ({ taskId: one.taskId, title: one.title, outcome: one.outcome, repo: one.repo }));
|
|
198
|
-
const selected = url.searchParams.get("t");
|
|
199
|
-
const rail = workbenchRail({ attention, building, waiting, queued, done, selected, saturated: snapshot.saturated });
|
|
200
|
-
if (url.searchParams.get("fragment") === "rail") {
|
|
201
|
-
// The rail alone: same auth, same ceiling, no shell, no scripts.
|
|
202
|
-
return respond(response, 200, "text/html; charset=utf-8", rail);
|
|
203
|
-
}
|
|
204
|
-
// The portfolio overview (arc slice 1a). All-scope hygiene throughout:
|
|
205
|
-
// admission binds inside every bounded SQL read, and every unlimited
|
|
206
|
-
// feed's rows pass visible() BEFORE they render or tally — a hidden
|
|
207
|
-
// project must not leak into a row, a count, or a dollar.
|
|
208
|
-
const sinceIso = new Date(now.getTime() - 24 * 3_600_000).toISOString();
|
|
209
|
-
const decisions = store
|
|
210
|
-
.listDecisionsScoped(null)
|
|
211
|
-
.filter(one => visible(one.repo ?? null))
|
|
212
|
-
.slice(0, 10);
|
|
213
|
-
const approvals = store.scopesAwaitingApproval(null, 10, admission).filter(one => visible(one.repo ?? null));
|
|
214
|
-
const requeueables = store.listRequeueablesScoped(null, now, 10, admission).filter(one => visible(one.repo ?? null));
|
|
215
|
-
const cancelledBlockers = store
|
|
216
|
-
.listCancelledBlockersScoped(null, 10, admission)
|
|
217
|
-
.filter(one => visible(one.repo ?? null) && visible(one.blockerRepo ?? null));
|
|
218
|
-
const runs24 = store.runsSinceScoped(sinceIso, null).filter(run => visible(taskRepoOf(run.taskRef)));
|
|
219
|
-
const live = store.liveClaims(null, now).filter(one => visible(one.repo));
|
|
220
|
-
const ledger = store
|
|
221
|
-
.portfolioLedgerScoped(null, sinceIso, 30, admission)
|
|
222
|
-
.filter(row => visible(row.repo));
|
|
223
|
-
// Gaps stay project-relative (the roll-up inbox's rule): computed for
|
|
224
|
-
// the OPEN project only — there is no scope-safe road to another
|
|
225
|
-
// project's /caps.
|
|
226
|
-
const gaps = project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10);
|
|
227
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
228
|
-
let detail = portfolioOverview({
|
|
229
|
-
attention, building, waiting, queued, done, saturated: snapshot.saturated,
|
|
230
|
-
decisions, approvals, requeueables, cancelledBlockers, gaps,
|
|
231
|
-
gapsProject: project, runs24, live, ledger, csrf, now,
|
|
232
|
-
}) +
|
|
233
|
-
`<div class="workbench-mobile-rail">${rail}</div>`;
|
|
234
|
-
if (selected !== null) {
|
|
235
|
-
const view = taskViewData(selected, who, null);
|
|
236
|
-
detail = view === null
|
|
237
|
-
? `<p class="workbench-mobile-back"><a href="/workbench">← all work</a></p><div class="card"><p class="meta">No such task — it may have been outside this console's view</p></div>`
|
|
238
|
-
: `<p class="workbench-mobile-back"><a href="/workbench">← all work</a></p>${taskBody({ ...view, degraded: "pane" })}`;
|
|
239
|
-
}
|
|
240
|
-
return sendScreen(response, 200, screen("portfolio", detail, {
|
|
241
|
-
chrome: chromeFor(project, "workbench", `<div id="wb-rail">${rail}</div><p class="meta" id="wb-rail-stamp"></p>`, "all"),
|
|
242
|
-
functional: {
|
|
243
|
-
// The decision enhancement rides ONLY the overview: a selected
|
|
244
|
-
// task's pane may carry a password ceremony, and sensitive
|
|
245
|
-
// pages gain no new scripts (commit-1 review, finding 1).
|
|
246
|
-
script: regionScript("wb-rail", "rail", building.length > 0 ? 10 : 30) +
|
|
247
|
-
(selected === null && decisions.length > 0 ? decisionAnswerScript() : ""),
|
|
248
|
-
fetches: true,
|
|
249
|
-
},
|
|
250
|
-
}));
|
|
251
|
-
}
|
|
252
|
-
if (url.pathname === "/ledger") {
|
|
253
|
-
const admitted = admissionList();
|
|
254
|
-
const projects = admitted ?? consoleProjects();
|
|
255
|
-
const chosen = url.searchParams.get("project") ?? "";
|
|
256
|
-
if (chosen !== "" && !projects.includes(chosen))
|
|
257
|
-
return refuse(response, who, 403, "That project is outside your access.", "/ledger");
|
|
258
|
-
const before = url.searchParams.get("before");
|
|
259
|
-
if (before !== null && (!/^[1-9][0-9]{0,14}$/.test(before) || !Number.isSafeInteger(Number(before))))
|
|
260
|
-
return refuse(response, who, 400, "Invalid ledger cursor.", "/ledger");
|
|
261
|
-
// Events that belong to no project (sign-ins, accounts, installation policy) are an instance operator's to read.
|
|
262
|
-
const query = { repos: chosen === "" ? admitted : [chosen], instance: chosen === "" && store.isInstanceOperator(who.name),
|
|
263
|
-
actor: (url.searchParams.get("actor") ?? "").slice(0, 200), outcome: (url.searchParams.get("outcome") ?? "").slice(0, 100),
|
|
264
|
-
source: url.searchParams.get("source") ?? "", taskId: (url.searchParams.get("task") ?? "").slice(0, 200) };
|
|
265
|
-
if (url.searchParams.get("format") === "csv") {
|
|
266
|
-
const generation = store.accountOf(who.name)?.generation;
|
|
267
|
-
// Descending immutable IDs pin the export at its first read; later
|
|
268
|
-
// arrivals cannot duplicate or extend it. Yield between bounded pages
|
|
269
|
-
// and honor backpressure/disconnects, so exports cannot fill memory.
|
|
270
|
-
const first = store.actionLedger({ ...query, limit: 100 });
|
|
271
|
-
async function* chunks() {
|
|
272
|
-
yield LEDGER_CSV_HEADER;
|
|
273
|
-
let page = first;
|
|
274
|
-
while (page.length > 0) {
|
|
275
|
-
const current = store.accountOf(who.name);
|
|
276
|
-
if (current === null || current.revokedAt !== null || current.generation !== generation ||
|
|
277
|
-
page.some(row => !visible(row.repo)))
|
|
278
|
-
throw new Error("Ledger access changed during export");
|
|
279
|
-
yield ledgerCsvRows(page);
|
|
280
|
-
const cursor = page.at(-1).id;
|
|
281
|
-
await yieldEventLoop();
|
|
282
|
-
const next = store.actionLedger({ ...query, before: cursor, limit: 100 });
|
|
283
|
-
// Pages only ever move to older entries; one that doesn't is the end.
|
|
284
|
-
page = next.length > 0 && next[0].id < cursor ? next : [];
|
|
147
|
+
finally {
|
|
148
|
+
dir.closeSync();
|
|
285
149
|
}
|
|
286
150
|
}
|
|
287
|
-
response.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="standing-orders-actions.csv"', "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
288
|
-
try {
|
|
289
|
-
await pipeline(Readable.from(chunks()), response);
|
|
290
|
-
}
|
|
291
151
|
catch {
|
|
292
|
-
|
|
152
|
+
// an unreadable root offers nothing
|
|
153
|
+
}
|
|
154
|
+
for (const child of children) {
|
|
155
|
+
if (!existsSync(join(child, ".git")))
|
|
156
|
+
continue;
|
|
157
|
+
const identity = githubIdentityOf(originUrlOf(child) ?? "");
|
|
158
|
+
if (identity !== null && !localByIdentity.has(identity.toLowerCase())) {
|
|
159
|
+
localByIdentity.set(identity.toLowerCase(), child);
|
|
160
|
+
}
|
|
293
161
|
}
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
// v103: the chain is sealed and checked before it's shown; an operator sees the checkpoint to copy off the machine.
|
|
297
|
-
const report = store.ledgerChain();
|
|
298
|
-
const operator = who.via === "cookie" && store.isInstanceOperator(who.name);
|
|
299
|
-
const latest = store.ledgerCheckpoints(1)[0] ?? null;
|
|
300
|
-
const chainView = { ok: report.ok, entries: report.entries, through: report.through, head: report.head, unsealed: report.unsealed, problem: report.problem, checkedAt: report.checkedAt,
|
|
301
|
-
latest: operator && latest !== null ? latest : null, csrf: operator ? who.session.csrf : null, today: now.toISOString().slice(0, 10) };
|
|
302
|
-
const rows = store.actionLedger({ ...query, ...(before === null ? {} : { before: Number(before) }), limit: 51 });
|
|
303
|
-
if (url.searchParams.get("format") === "json")
|
|
304
|
-
return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("ledgerPage", { chain: { ok: report.ok, entries: report.entries, through: report.through, head: report.head, problem: report.problem }, entries: rows.slice(0, 50), nextBefore: rows.length > 50 ? rows[49].id : null })));
|
|
305
|
-
return sendScreen(response, 200, screen("Action ledger", ledgerBody(rows, projects, url.searchParams, chainView), { chrome: chromeFor(chosen === "" ? null : chosen, "ledger", undefined, chosen === "" ? "all" : "project") }));
|
|
306
|
-
}
|
|
307
|
-
if (url.pathname === "/activity") {
|
|
308
|
-
const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
|
309
|
-
return sendScreen(response, 200, homePage(chromeFor(project, "runs"), {
|
|
310
|
-
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
311
|
-
taskCount: store.listTasksScoped(project, undefined, 1, null).length,
|
|
312
|
-
repo: project,
|
|
313
|
-
summary: tally(store.runsSinceScoped(since, project)),
|
|
314
|
-
decisions: store.listDecisionsScoped(project),
|
|
315
|
-
incidents: store.openIncidents(project),
|
|
316
|
-
stranded: store.strandedTasks(project),
|
|
317
|
-
gaps: project === null ? null : computeGaps(store, project, now),
|
|
318
|
-
// Rows that want a person, plus task updates the live pairing failed to send (never quiet progress).
|
|
319
|
-
outboxPending: store.pendingForAttention().length,
|
|
320
|
-
settings: options.telegramTokenFile !== undefined,
|
|
321
|
-
building: store.liveClaims(project, now),
|
|
322
|
-
runners: store.listRunners(),
|
|
323
|
-
worktrees: store
|
|
324
|
-
.listWorktrees()
|
|
325
|
-
.filter(one => project === null || sameRepo(one.repo, project)),
|
|
326
|
-
episode: project === null ? null : store.latestWatchEpisode(project),
|
|
327
|
-
now,
|
|
328
|
-
}));
|
|
329
|
-
}
|
|
330
|
-
if (url.pathname === "/system") {
|
|
331
|
-
const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
|
332
|
-
void since;
|
|
333
|
-
return sendScreen(response, 200, systemPage(chromeFor(project, "system"), {
|
|
334
|
-
agents: ["plan", "build", "repair", "review"].map(phase => {
|
|
335
|
-
const answer = resolvePhaseAgent(store, phase, project, {});
|
|
336
|
-
const row = (project === null ? null : store.phaseConfig(project, phase)) ??
|
|
337
|
-
store.phaseConfig(INSTALLATION_SCOPE, phase);
|
|
338
|
-
return {
|
|
339
|
-
phase,
|
|
340
|
-
...(answer.ok
|
|
341
|
-
? { provider: answer.spec.provider, model: answer.spec.model, source: answer.source }
|
|
342
|
-
: { problem: answer.problem }),
|
|
343
|
-
setBy: row?.updatedBy ?? null,
|
|
344
|
-
};
|
|
345
|
-
}),
|
|
346
|
-
building: store.liveClaims(project, now),
|
|
347
|
-
runners: store.listRunners(),
|
|
348
|
-
worktrees: store.listWorktrees().filter(one => project === null || sameRepo(one.repo, project)),
|
|
349
|
-
episode: project === null ? null : store.latestWatchEpisode(project),
|
|
350
|
-
outboxPending: store.pendingForAttention().length,
|
|
351
|
-
// External work at a glance (arc 3 finding 24): every dispatch
|
|
352
|
-
// grant with its blocked state, plus any open sync episode — the
|
|
353
|
-
// page a sync-failed push deep-links to now shows the fact.
|
|
354
|
-
externalWork: store
|
|
355
|
-
.listGrants()
|
|
356
|
-
.filter(one => one.dispatch === true && one.remoteRepo != null && visible(one.repo))
|
|
357
|
-
.map(one => ({
|
|
358
|
-
remoteRepo: String(one.remoteRepo),
|
|
359
|
-
blocked: one.dispatchBlocked ?? null,
|
|
360
|
-
openEpisode: store
|
|
361
|
-
.listNotifications("all")
|
|
362
|
-
.find(n => n.kind === "sync-failed" && n.resolvedAt === null && n.dedupeKey.startsWith(`sync:${one.remoteRepo}:`))?.subject ?? null,
|
|
363
|
-
})),
|
|
364
|
-
now,
|
|
365
|
-
}));
|
|
366
|
-
}
|
|
367
|
-
if (url.pathname === "/peek") {
|
|
368
|
-
// The multiplexer in the console (peek): one pane per live run the
|
|
369
|
-
// ceiling admits, each fed by the run page's own transcript poller
|
|
370
|
-
// (the byte-offset JSON protocol — text into textContent, never
|
|
371
|
-
// markup). The pane SET is re-checked on a slow cadence; when it
|
|
372
|
-
// changes the page reloads rather than swapping pollers mid-flight.
|
|
373
|
-
const live = store.liveRuns(clock()).filter(run => runVisible(run));
|
|
374
|
-
if (url.searchParams.get("fragment") === "1") {
|
|
375
|
-
return respond(response, 200, "application/json", JSON.stringify({ runs: live.map(run => run.id) }));
|
|
376
|
-
}
|
|
377
|
-
const now = clock();
|
|
378
|
-
const panes = live.map(run => {
|
|
379
|
-
const window = options.localRunner === undefined ? null : readLiveWindow(evidenceRoot, run.id, 0);
|
|
380
|
-
const tail = window !== null && window.ok ? window.text.split("\n").filter(one => one !== "").slice(-40).join("\n") : "";
|
|
381
|
-
const stage = run.phase !== null ? run.phase.replace(/-/g, " ") : run.providerStartedAt === null ? "preparing workspace" : "the agent is working";
|
|
382
|
-
return [
|
|
383
|
-
`<section class="card peek-pane" data-run="${run.id}">`,
|
|
384
|
-
`<p class="row"><a href="/r/${run.id}" class="mono">#${run.id}</a> <a href="${taskHref(run.taskId)}"><strong>${escape(run.title)}</strong></a>${projectChip(run.repo)} ` +
|
|
385
|
-
`<span class="right meta mono">${escape(run.runner)} \u00b7 ${escape(run.role)} \u00b7 ${escape(stage)} \u00b7 ${escape(relativeAge(run.startedAt, now))}</span></p>`,
|
|
386
|
-
options.localRunner === undefined
|
|
387
|
-
? `<p class="meta">The transcript is readable on the machine that runs the worker \u2014 start the console there with <code>toolroll up</code></p>`
|
|
388
|
-
: `<pre class="recap plan-doc live-pane" id="live-transcript-${run.id}" data-initial-offset="0">${escape(tail)}</pre><p class="meta" id="live-transcript-${run.id}-state"></p>`,
|
|
389
|
-
`</section>`,
|
|
390
|
-
].join("\n");
|
|
391
|
-
});
|
|
392
|
-
const script = (options.localRunner === undefined ? "" : live.map(run => transcriptScript(`/r/${run.id}`, `live-transcript-${run.id}`)).join("")) +
|
|
393
|
-
`(function(){var seen=${JSON.stringify(live.map(run => run.id))};function check(){if(document.hidden){setTimeout(check,12000);return;}` +
|
|
394
|
-
`fetch("/peek?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(r){return r.ok?r.json():null;})` +
|
|
395
|
-
`.then(function(d){if(d&&Array.isArray(d.runs)&&JSON.stringify(d.runs)!==JSON.stringify(seen)){location.reload();return;}setTimeout(check,12000);})` +
|
|
396
|
-
`.catch(function(){setTimeout(check,12000);});}setTimeout(check,12000);})();`;
|
|
397
|
-
return sendScreen(response, 200, screen("peek", [
|
|
398
|
-
`<h1>Peek</h1>`,
|
|
399
|
-
panes.length === 0
|
|
400
|
-
? `<p class="meta">No agent is working right now \u2014 this page follows them the moment one starts</p>`
|
|
401
|
-
: panes.join("\n"),
|
|
402
|
-
].join("\n"), { chrome: chromeFor(project, "runs"), functional: { script, fetches: true } }));
|
|
403
|
-
}
|
|
404
|
-
if (url.pathname === "/fleet") {
|
|
405
|
-
// Cross-project by design: which agent is on which project is the
|
|
406
|
-
// question, so this screen is a survey — reads are still filtered
|
|
407
|
-
// through the ceiling before a card renders. No project needed.
|
|
408
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
409
|
-
const queued = store.fleetQueue(clock());
|
|
410
|
-
const building = store.liveClaims(null, clock());
|
|
411
|
-
const owned = new Set(queued.map(one => one.assignedRunner).filter((one) => one !== null));
|
|
412
|
-
const runners = store.listRunners().filter(one => one.retiredAt === null || owned.has(one.name));
|
|
413
|
-
const body = fleetBody(queued, building, runners, csrf, store.queueRevision(), visible);
|
|
414
|
-
if (url.searchParams.get("fragment") === "1") {
|
|
415
|
-
return respond(response, 200, "text/html; charset=utf-8", body);
|
|
416
162
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
163
|
+
}
|
|
164
|
+
return sendScreen(response, 200, githubReposPage(chromeFor(who.via === "cookie" ? who.session.project : null, "projects"), {
|
|
165
|
+
listed,
|
|
166
|
+
local: localByIdentity,
|
|
167
|
+
registered,
|
|
168
|
+
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
169
|
+
cloneReady: ceiling.roots.length > 0,
|
|
170
|
+
openProject: who.via === "cookie" ? who.session.project ?? null : null,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
async function projectsIndex(ctx) {
|
|
174
|
+
const { url, who, response } = ctx;
|
|
175
|
+
return void projectsScreen(response, who, url.searchParams.get("said"), 200, safeReturn(url.searchParams.get("return")));
|
|
176
|
+
}
|
|
177
|
+
async function homeRedirect(ctx) {
|
|
178
|
+
const { who, response } = ctx;
|
|
179
|
+
return redirect(response, who.via === "cookie" && !restricted() ? "/chat" : "/work");
|
|
180
|
+
}
|
|
181
|
+
// The board's old name; bookmarks keep working. A GET-only alias, so a
|
|
182
|
+
// plain 302 — never the shared 303 helper, which belongs to POST landings.
|
|
183
|
+
async function morningAlias(ctx) {
|
|
184
|
+
const { response } = ctx;
|
|
185
|
+
response.writeHead(302, { ...SAFETY, Location: "/activity" });
|
|
186
|
+
response.end();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
async function workbench(ctx) {
|
|
190
|
+
const { url, who, response, now, project } = ctx;
|
|
191
|
+
// The fleet under one gaze (attended A1): the rail rolls up the whole
|
|
192
|
+
// ceiling regardless of the open project — watching is cross-project
|
|
193
|
+
// by nature; acting happens on task screens that re-prove everything.
|
|
194
|
+
const admission = admissionList();
|
|
195
|
+
const snapshot = store.boardScoped(null, now, 200, admission);
|
|
196
|
+
const rows = snapshot.tasks.filter(facts => facts.repo === null || visible(facts.repo));
|
|
197
|
+
const cards = rows.map(facts => classify(facts.blockerRepo !== null && !visible(facts.blockerRepo) ? { ...facts, blockerState: null } : facts, now));
|
|
198
|
+
const attention = cards.filter(card => card.lane === "attention").slice(0, 100);
|
|
199
|
+
const building = cards.filter(card => card.lane === "building");
|
|
200
|
+
const waiting = cards.filter(card => card.lane === "waiting");
|
|
201
|
+
const queued = cards.filter(card => card.lane === "queued");
|
|
202
|
+
const done = snapshot.done
|
|
203
|
+
.filter(one => one.repo === null || visible(one.repo))
|
|
204
|
+
.slice(0, 5)
|
|
205
|
+
.map(one => ({ taskId: one.taskId, title: one.title, outcome: one.outcome, repo: one.repo }));
|
|
206
|
+
const selected = url.searchParams.get("t");
|
|
207
|
+
const rail = workbenchRail({ attention, building, waiting, queued, done, selected, saturated: snapshot.saturated });
|
|
208
|
+
if (url.searchParams.get("fragment") === "rail") {
|
|
209
|
+
// The rail alone: same auth, same ceiling, no shell, no scripts.
|
|
210
|
+
return respond(response, 200, "text/html; charset=utf-8", rail);
|
|
211
|
+
}
|
|
212
|
+
// The portfolio overview (arc slice 1a). All-scope hygiene throughout:
|
|
213
|
+
// admission binds inside every bounded SQL read, and every unlimited
|
|
214
|
+
// feed's rows pass visible() BEFORE they render or tally — a hidden
|
|
215
|
+
// project must not leak into a row, a count, or a dollar.
|
|
216
|
+
const sinceIso = new Date(now.getTime() - 24 * 3_600_000).toISOString();
|
|
217
|
+
const decisions = store
|
|
218
|
+
.listDecisionsScoped(null)
|
|
219
|
+
.filter(one => visible(one.repo ?? null))
|
|
220
|
+
.slice(0, 10);
|
|
221
|
+
const approvals = store.scopesAwaitingApproval(null, 10, admission).filter(one => visible(one.repo ?? null));
|
|
222
|
+
const requeueables = store.listRequeueablesScoped(null, now, 10, admission).filter(one => visible(one.repo ?? null));
|
|
223
|
+
const cancelledBlockers = store
|
|
224
|
+
.listCancelledBlockersScoped(null, 10, admission)
|
|
225
|
+
.filter(one => visible(one.repo ?? null) && visible(one.blockerRepo ?? null));
|
|
226
|
+
const runs24 = store.runsSinceScoped(sinceIso, null).filter(run => visible(taskRepoOf(run.taskRef)));
|
|
227
|
+
const live = store.liveClaims(null, now).filter(one => visible(one.repo));
|
|
228
|
+
const ledger = store
|
|
229
|
+
.portfolioLedgerScoped(null, sinceIso, 30, admission)
|
|
230
|
+
.filter(row => visible(row.repo));
|
|
231
|
+
// Gaps stay project-relative (the roll-up inbox's rule): computed for
|
|
232
|
+
// the OPEN project only — there is no scope-safe road to another
|
|
233
|
+
// project's /caps.
|
|
234
|
+
const gaps = project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10);
|
|
235
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
236
|
+
let detail = html `${portfolioOverview({
|
|
237
|
+
attention, building, waiting, queued, done, saturated: snapshot.saturated,
|
|
238
|
+
decisions, approvals, requeueables, cancelledBlockers, gaps,
|
|
239
|
+
gapsProject: project, runs24, live, ledger, csrf, now,
|
|
240
|
+
})}<div class="workbench-mobile-rail">${rail}</div>`;
|
|
241
|
+
if (selected !== null) {
|
|
242
|
+
const view = taskViewData(selected, who, null);
|
|
243
|
+
detail = view === null
|
|
244
|
+
? html `<p class="workbench-mobile-back"><a href="/workbench">← all work</a></p><div class="card"><p class="meta">No such task — it may have been outside this console's view</p></div>`
|
|
245
|
+
: html `<p class="workbench-mobile-back"><a href="/workbench">← all work</a></p>${taskBody({ ...view, degraded: "pane" })}`;
|
|
246
|
+
}
|
|
247
|
+
return sendScreen(response, 200, screen("portfolio", detail, {
|
|
248
|
+
chrome: chromeFor(project, "workbench", html `<div id="wb-rail">${rail}</div><p class="meta" id="wb-rail-stamp"></p>`, "all"),
|
|
249
|
+
functional: {
|
|
250
|
+
// The decision enhancement rides ONLY the overview: a selected
|
|
251
|
+
// task's pane may carry a password ceremony, and sensitive
|
|
252
|
+
// pages gain no new scripts (commit-1 review, finding 1).
|
|
253
|
+
script: regionScript("wb-rail", "rail") +
|
|
254
|
+
(selected === null && decisions.length > 0 ? decisionAnswerScript() : ""),
|
|
255
|
+
fetches: true,
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
async function ledgerPage(ctx) {
|
|
260
|
+
const { url, who, response, now } = ctx;
|
|
261
|
+
const admitted = admissionList();
|
|
262
|
+
const projects = admitted ?? consoleProjects();
|
|
263
|
+
const chosen = url.searchParams.get("project") ?? "";
|
|
264
|
+
if (chosen !== "" && !projects.includes(chosen))
|
|
265
|
+
return refuse(response, who, 403, "That project is outside your access.", "/ledger");
|
|
266
|
+
const before = url.searchParams.get("before");
|
|
267
|
+
if (before !== null && (!/^[1-9][0-9]{0,14}$/.test(before) || !Number.isSafeInteger(Number(before))))
|
|
268
|
+
return refuse(response, who, 400, "Invalid ledger cursor.", "/ledger");
|
|
269
|
+
// Events that belong to no project (sign-ins, accounts, installation policy) are an instance operator's to read.
|
|
270
|
+
const query = { repos: chosen === "" ? admitted : [chosen], instance: chosen === "" && store.isInstanceOperator(who.name),
|
|
271
|
+
actor: (url.searchParams.get("actor") ?? "").slice(0, 200), outcome: (url.searchParams.get("outcome") ?? "").slice(0, 100),
|
|
272
|
+
source: url.searchParams.get("source") ?? "", taskId: (url.searchParams.get("task") ?? "").slice(0, 200) };
|
|
273
|
+
if (url.searchParams.get("format") === "csv") {
|
|
457
274
|
const generation = store.accountOf(who.name)?.generation;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return false;
|
|
463
|
-
if (who.via === "cookie")
|
|
464
|
-
return identify(request, false)?.name === who.name;
|
|
465
|
-
if (tokenId === null)
|
|
466
|
-
return true;
|
|
467
|
-
const token = store.apiTokenSecret(tokenId)?.row;
|
|
468
|
-
return token !== undefined && tokenLive(token, Date.now());
|
|
469
|
-
};
|
|
470
|
-
// The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
|
|
471
|
-
const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
|
|
275
|
+
// Descending immutable IDs pin the export at its first read; later
|
|
276
|
+
// arrivals cannot duplicate or extend it. Yield between bounded pages
|
|
277
|
+
// and honor backpressure/disconnects, so exports cannot fill memory.
|
|
278
|
+
const first = store.actionLedger({ ...query, limit: 100 });
|
|
472
279
|
async function* chunks() {
|
|
473
|
-
|
|
474
|
-
|
|
280
|
+
yield LEDGER_CSV_HEADER;
|
|
281
|
+
let page = first;
|
|
282
|
+
while (page.length > 0) {
|
|
283
|
+
const current = store.accountOf(who.name);
|
|
284
|
+
if (current === null || current.revokedAt !== null || current.generation !== generation ||
|
|
285
|
+
page.some(row => !visible(row.repo)))
|
|
475
286
|
throw new Error("Ledger access changed during export");
|
|
476
|
-
yield
|
|
287
|
+
yield ledgerCsvRows(page);
|
|
288
|
+
const cursor = page.at(-1).id;
|
|
477
289
|
await yieldEventLoop();
|
|
290
|
+
const next = store.actionLedger({ ...query, before: cursor, limit: 100 });
|
|
291
|
+
// Pages only ever move to older entries; one that doesn't is the end.
|
|
292
|
+
page = next.length > 0 && next[0].id < cursor ? next : [];
|
|
478
293
|
}
|
|
479
294
|
}
|
|
480
|
-
response.
|
|
481
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="standing-orders-audit-${from.slice(0, 10)}-to-${last.slice(0, 10)}.json"`,
|
|
482
|
-
"cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
295
|
+
response.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="standing-orders-actions.csv"', "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
483
296
|
try {
|
|
484
297
|
await pipeline(Readable.from(chunks()), response);
|
|
485
298
|
}
|
|
@@ -488,313 +301,508 @@ export function createPagesHandlers(runtime) {
|
|
|
488
301
|
}
|
|
489
302
|
return;
|
|
490
303
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
section("Work tools", workToolRows(chrome.projectScoped, chrome.chat, chrome.code)),
|
|
502
|
-
section("Settings", settingsRows(chrome.projectScoped, chrome.settings)),
|
|
503
|
-
].join("\n"), { chrome }));
|
|
504
|
-
}
|
|
505
|
-
if (url.pathname === "/caps") {
|
|
506
|
-
if (project === null) {
|
|
507
|
-
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), null, [], ""));
|
|
508
|
-
}
|
|
509
|
-
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
|
|
510
|
-
}
|
|
511
|
-
// v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
|
|
512
|
-
if (url.pathname === "/health") {
|
|
513
|
-
if (!store.isInstanceOperator(who.name))
|
|
514
|
-
return refuse(response, who, 403, "An instance operator reads server health.", "/");
|
|
515
|
-
const health = store.telemetry.snapshot();
|
|
516
|
-
return request.headers.accept?.includes('application/json')
|
|
517
|
-
? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
|
|
518
|
-
: respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
|
|
519
|
-
}
|
|
520
|
-
if (url.pathname === "/metrics") {
|
|
521
|
-
if (!store.isInstanceOperator(who.name))
|
|
522
|
-
return refuse(response, who, 403, "An instance operator reads metrics.", "/");
|
|
523
|
-
response.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
524
|
-
const set = options.configDir === undefined ? null : readMonitoring(options.configDir);
|
|
525
|
-
const destinations = new Map([
|
|
526
|
-
...(set?.webhook ? [["webhook", targetOf(set.webhook.url)]] : []),
|
|
527
|
-
...(set?.folder ? [["folder", targetOf(set.folder.path)]] : []),
|
|
528
|
-
...(set?.traces ? [["traces", targetOf(set.traces.endpoint)]] : []),
|
|
529
|
-
]);
|
|
530
|
-
return void response.end(prometheusMetrics(store, now, admissionList(), destinations));
|
|
531
|
-
}
|
|
532
|
-
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
304
|
+
// v103: the chain is sealed and checked before it's shown; an operator sees the checkpoint to copy off the machine.
|
|
305
|
+
const report = store.ledgerChain();
|
|
306
|
+
const operator = who.via === "cookie" && store.isInstanceOperator(who.name);
|
|
307
|
+
const latest = store.ledgerCheckpoints(1)[0] ?? null;
|
|
308
|
+
const chainView = { ok: report.ok, entries: report.entries, through: report.through, head: report.head, unsealed: report.unsealed, problem: report.problem, checkedAt: report.checkedAt,
|
|
309
|
+
latest: operator && latest !== null ? latest : null, csrf: operator ? who.session.csrf : null, today: now.toISOString().slice(0, 10) };
|
|
310
|
+
const rows = store.actionLedger({ ...query, ...(before === null ? {} : { before: Number(before) }), limit: 51 });
|
|
311
|
+
if (url.searchParams.get("format") === "json")
|
|
312
|
+
return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("ledgerPage", { chain: { ok: report.ok, entries: report.entries, through: report.through, head: report.head, problem: report.problem }, entries: rows.slice(0, 50), nextBefore: rows.length > 50 ? rows[49].id : null })));
|
|
313
|
+
return sendScreen(response, 200, screen("Action ledger", ledgerBody(rows, projects, url.searchParams, chainView), { chrome: chromeFor(chosen === "" ? null : chosen, "ledger", undefined, chosen === "" ? "all" : "project") }));
|
|
533
314
|
}
|
|
534
|
-
async function
|
|
535
|
-
const {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
315
|
+
async function activityPage(ctx) {
|
|
316
|
+
const { who, response, now, project } = ctx;
|
|
317
|
+
const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
|
318
|
+
return sendScreen(response, 200, homePage(chromeFor(project, "runs"), {
|
|
319
|
+
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
320
|
+
taskCount: store.listTasksScoped(project, undefined, 1, null).length,
|
|
321
|
+
repo: project,
|
|
322
|
+
summary: tally(store.runsSinceScoped(since, project)),
|
|
323
|
+
decisions: store.listDecisionsScoped(project),
|
|
324
|
+
incidents: store.openIncidents(project),
|
|
325
|
+
stranded: store.strandedTasks(project),
|
|
326
|
+
gaps: project === null ? null : computeGaps(store, project, now),
|
|
327
|
+
// Rows that want a person, plus task updates the live pairing failed to send (never quiet progress).
|
|
328
|
+
outboxPending: store.pendingForAttention().length,
|
|
329
|
+
settings: options.telegramTokenFile !== undefined,
|
|
330
|
+
building: store.liveClaims(project, now),
|
|
331
|
+
runners: store.listRunners(),
|
|
332
|
+
worktrees: store
|
|
333
|
+
.listWorktrees()
|
|
334
|
+
.filter(one => project === null || sameRepo(one.repo, project)),
|
|
335
|
+
episode: project === null ? null : store.latestWatchEpisode(project),
|
|
336
|
+
now,
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
async function systemScreen(ctx) {
|
|
340
|
+
const { response, now, project } = ctx;
|
|
341
|
+
const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
|
342
|
+
void since;
|
|
343
|
+
return sendScreen(response, 200, systemPage(chromeFor(project, "system"), {
|
|
344
|
+
agents: ["plan", "build", "repair", "review"].map(phase => {
|
|
345
|
+
const answer = resolvePhaseAgent(store, phase, project, {});
|
|
346
|
+
const row = (project === null ? null : store.phaseConfig(project, phase)) ??
|
|
347
|
+
store.phaseConfig(INSTALLATION_SCOPE, phase);
|
|
348
|
+
return {
|
|
349
|
+
phase,
|
|
350
|
+
...(answer.ok
|
|
351
|
+
? { provider: answer.spec.provider, model: answer.spec.model, source: answer.source }
|
|
352
|
+
: { problem: answer.problem }),
|
|
353
|
+
setBy: row?.updatedBy ?? null,
|
|
354
|
+
};
|
|
355
|
+
}),
|
|
356
|
+
building: store.liveClaims(project, now),
|
|
357
|
+
runners: store.listRunners(),
|
|
358
|
+
worktrees: store.listWorktrees().filter(one => project === null || sameRepo(one.repo, project)),
|
|
359
|
+
episode: project === null ? null : store.latestWatchEpisode(project),
|
|
360
|
+
outboxPending: store.pendingForAttention().length,
|
|
361
|
+
// External work at a glance (arc 3 finding 24): every dispatch
|
|
362
|
+
// grant with its blocked state, plus any open sync episode — the
|
|
363
|
+
// page a sync-failed push deep-links to now shows the fact.
|
|
364
|
+
externalWork: store
|
|
365
|
+
.listGrants()
|
|
366
|
+
.filter(one => one.dispatch === true && one.remoteRepo != null && visible(one.repo))
|
|
367
|
+
.map(one => ({
|
|
368
|
+
remoteRepo: String(one.remoteRepo),
|
|
369
|
+
blocked: one.dispatchBlocked ?? null,
|
|
370
|
+
openEpisode: store
|
|
371
|
+
.listNotifications("all")
|
|
372
|
+
.find(n => n.kind === "sync-failed" && n.resolvedAt === null && n.dedupeKey.startsWith(`sync:${one.remoteRepo}:`))?.subject ?? null,
|
|
373
|
+
})),
|
|
374
|
+
now,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
// The multiplexer in the console (peek): one pane per live run the
|
|
378
|
+
// ceiling admits, each fed by the run page's own transcript poller
|
|
379
|
+
// (the byte-offset JSON protocol — text into textContent, never
|
|
380
|
+
// markup). The pane SET is re-checked on a slow cadence; when it
|
|
381
|
+
// changes the page reloads rather than swapping pollers mid-flight.
|
|
382
|
+
async function peekPage(ctx) {
|
|
383
|
+
const { url, response, project } = ctx;
|
|
384
|
+
const live = store.liveRuns(clock()).filter(run => runVisible(run));
|
|
385
|
+
if (url.searchParams.get("fragment") === "1") {
|
|
386
|
+
return respond(response, 200, "application/json", JSON.stringify({ runs: live.map(run => run.id) }));
|
|
387
|
+
}
|
|
388
|
+
const now = clock();
|
|
389
|
+
const panes = live.map(run => {
|
|
390
|
+
const window = options.localRunner === undefined ? null : readLiveWindow(evidenceRoot, run.id, 0);
|
|
391
|
+
const tail = window !== null && window.ok ? window.text.split("\n").filter(one => one !== "").slice(-40).join("\n") : "";
|
|
392
|
+
const stage = run.phase !== null ? run.phase.replace(/-/g, " ") : run.providerStartedAt === null ? "preparing workspace" : "the agent is working";
|
|
393
|
+
return joinHtml([
|
|
394
|
+
html `<section class="card peek-pane" data-run="${run.id}">`,
|
|
395
|
+
html `<p class="row"><a href="/r/${run.id}" class="mono">#${run.id}</a> <a href="${taskHref(run.taskId)}"><strong>${run.title}</strong></a>${projectChip(run.repo)} <span class="right meta mono">${run.runner} \u00b7 ${run.role} \u00b7 ${stage} \u00b7 ${relativeAge(run.startedAt, now)}</span></p>`,
|
|
396
|
+
options.localRunner === undefined
|
|
397
|
+
? html `<p class="meta">The transcript is readable on the machine that runs the worker \u2014 start the console there with <code>toolroll up</code></p>`
|
|
398
|
+
: html `<pre class="recap plan-doc live-pane" id="live-transcript-${run.id}" data-initial-offset="0">${tail}</pre><p class="meta" id="live-transcript-${run.id}-state"></p>`,
|
|
399
|
+
html `</section>`,
|
|
400
|
+
], "\n");
|
|
401
|
+
});
|
|
402
|
+
const script = (options.localRunner === undefined ? "" : live.map(run => transcriptScript(`/r/${run.id}`, `live-transcript-${run.id}`)).join("")) +
|
|
403
|
+
`(function(){var seen=${JSON.stringify(live.map(run => run.id))};function check(){if(document.hidden){setTimeout(check,12000);return;}` +
|
|
404
|
+
`fetch("/peek?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(r){return r.ok?r.json():null;})` +
|
|
405
|
+
`.then(function(d){if(d&&Array.isArray(d.runs)&&JSON.stringify(d.runs)!==JSON.stringify(seen)){location.reload();return;}setTimeout(check,12000);})` +
|
|
406
|
+
`.catch(function(){setTimeout(check,12000);});}setTimeout(check,12000);})();`;
|
|
407
|
+
return sendScreen(response, 200, screen("peek", joinHtml([
|
|
408
|
+
html `<h1>Peek</h1>`,
|
|
409
|
+
panes.length === 0
|
|
410
|
+
? html `<p class="meta">No agent is working right now \u2014 this page follows them the moment one starts</p>`
|
|
411
|
+
: joinHtml(panes, "\n"),
|
|
412
|
+
], "\n"), { chrome: chromeFor(project, "runs"), functional: { script, fetches: true } }));
|
|
413
|
+
}
|
|
414
|
+
async function fleetPage(ctx) {
|
|
415
|
+
const { url, who, response, project } = ctx;
|
|
416
|
+
// Cross-project by design: which agent is on which project is the
|
|
417
|
+
// question, so this screen is a survey — reads are still filtered
|
|
418
|
+
// through the ceiling before a card renders. No project needed.
|
|
419
|
+
const queued = store.fleetQueue(clock());
|
|
420
|
+
const building = store.liveClaims(null, clock());
|
|
421
|
+
const owned = new Set(queued.map(one => one.assignedRunner).filter((one) => one !== null));
|
|
422
|
+
const runners = store.listRunners().filter(one => one.retiredAt === null || owned.has(one.name));
|
|
423
|
+
const body = fleetBody(queued, building, runners, store.queueRevision(), visible);
|
|
424
|
+
if (url.searchParams.get("fragment") === "1") {
|
|
425
|
+
return respond(response, 200, "text/html; charset=utf-8", body);
|
|
426
|
+
}
|
|
427
|
+
const said = url.searchParams.get("said");
|
|
428
|
+
const fleetScreen = screen("fleet", joinHtml([
|
|
429
|
+
html `<h1>Fleet</h1>`,
|
|
430
|
+
html `<p class="hint">one lane per worker — drag a queued card onto another worker to re-reserve it</p>`,
|
|
431
|
+
...(said === null ? [] : [html `<p class="meta">${said}</p>`]),
|
|
432
|
+
html `<div id="fleet-region">${body}</div>`,
|
|
433
|
+
html `<p class="meta" id="fleet-region-stamp"></p>`,
|
|
434
|
+
html `<h2>Register a worker</h2>`,
|
|
435
|
+
postForm("/fleet/runner/register", html `<label>Name<input type="text" name="name" placeholder="builder-2" maxlength="60" required></label><label>Capacity<input type="text" name="capacity" inputmode="numeric" value="1" aria-label="capacity"></label><label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label><button type="submit">Register — its token is shown once</button>`, { attrs: { class: "card" } }),
|
|
436
|
+
html `<details class="arm-danger"><summary>Retire a worker</summary>${postForm("/fleet/runner/retire", html `<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label><label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label><button type="submit" class="danger">Retire it</button>`, { attrs: { class: "card" } })}</details>`,
|
|
437
|
+
], "\n"),
|
|
438
|
+
// The password fields make this screen sensitive: sendScreen strips
|
|
439
|
+
// the chrome additions and keeps the reorder poller — the named
|
|
440
|
+
// functional exception (it never reads the fields).
|
|
441
|
+
{ chrome: chromeFor(project, "fleet", undefined, "all"), functional: { script: fleetScript(), fetches: true } });
|
|
442
|
+
return sendScreen(response, 200, fleetScreen);
|
|
443
|
+
}
|
|
444
|
+
// v103: every ledger entry in a date range, sealed, with an evidence pack for each task it names.
|
|
445
|
+
async function ledgerExport(ctx) {
|
|
446
|
+
const { url, who, request, response, now } = ctx;
|
|
447
|
+
const from = exportDay(url.searchParams.get("from")), last = exportDay(url.searchParams.get("to"));
|
|
448
|
+
if (from === null || last === null)
|
|
449
|
+
return refuse(response, who, 400, "Choose a start and end day.", "/ledger");
|
|
450
|
+
const to = new Date(Date.parse(last) + 86_400_000).toISOString();
|
|
451
|
+
if (to <= from || Date.parse(to) - Date.parse(from) > 366 * 86_400_000)
|
|
452
|
+
return refuse(response, who, 400, "Choose a range of a year or less, ending on or after its start.", "/ledger");
|
|
453
|
+
const access = workAccess();
|
|
454
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: "ledger exported", outcome: "exported", source: "access",
|
|
455
|
+
detail: `${from.slice(0, 10)} to ${last.slice(0, 10)}` });
|
|
456
|
+
// A piece at a time (a page of entries, or one pack), yielding between them, and stopping if the reader's access
|
|
457
|
+
// changes: their account, and the session or API token they came with (read without writing anything).
|
|
458
|
+
const generation = store.accountOf(who.name)?.generation;
|
|
459
|
+
const tokenId = who.via === "bearer" && who.token !== undefined ? parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id ?? null : null;
|
|
460
|
+
const stillLive = () => {
|
|
461
|
+
const current = store.accountOf(who.name);
|
|
462
|
+
if (current === null || current.revokedAt !== null || current.generation !== generation)
|
|
463
|
+
return false;
|
|
464
|
+
if (who.via === "cookie")
|
|
465
|
+
return identify(request, false)?.name === who.name;
|
|
466
|
+
if (tokenId === null)
|
|
467
|
+
return true;
|
|
468
|
+
const token = store.apiTokenSecret(tokenId)?.row;
|
|
469
|
+
return token !== undefined && tokenLive(token, Date.now());
|
|
470
|
+
};
|
|
471
|
+
// The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
|
|
472
|
+
const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
|
|
473
|
+
async function* chunks() {
|
|
474
|
+
for (const piece of pieces) {
|
|
475
|
+
if (!stillLive())
|
|
476
|
+
throw new Error("Ledger access changed during export");
|
|
477
|
+
yield piece;
|
|
478
|
+
await yieldEventLoop();
|
|
557
479
|
}
|
|
558
|
-
|
|
480
|
+
}
|
|
481
|
+
response.setTimeout(60_000, () => response.destroy());
|
|
482
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="standing-orders-audit-${from.slice(0, 10)}-to-${last.slice(0, 10)}.json"`,
|
|
483
|
+
"cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
484
|
+
try {
|
|
485
|
+
await pipeline(Readable.from(chunks()), response);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
response.destroy();
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
async function menuPage(ctx) {
|
|
493
|
+
const { response, project } = ctx;
|
|
494
|
+
// The phone's overflow drawer as an honest page: every destination
|
|
495
|
+
// the tab bar does not carry, one tap away, no JavaScript — grouped
|
|
496
|
+
// under the same workflows/admin headings as the rail's accordion,
|
|
497
|
+
// with settings last, outside both.
|
|
498
|
+
const chrome = chromeFor(project, "menu");
|
|
499
|
+
const section = (label, rows) => html `<h2 class="menu-group-label">${label}</h2><div class="menu-list">${joinHtml(rows.map(row => html `<a class="menu-row" href="${row.href}"><strong>${row.label}</strong><span class="meta">${row.hint}</span></a>`), "\n")}</div>`;
|
|
500
|
+
return sendScreen(response, 200, screen("Workspace tools", joinHtml([
|
|
501
|
+
section("Work tools", workToolRows(chrome.projectScoped, chrome.chat, chrome.code)),
|
|
502
|
+
section("Settings", settingsRows(chrome.projectScoped, chrome.settings)),
|
|
503
|
+
], "\n"), { chrome }));
|
|
504
|
+
}
|
|
505
|
+
async function capsScreen(ctx) {
|
|
506
|
+
const { response, now, project } = ctx;
|
|
507
|
+
if (project === null) {
|
|
508
|
+
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), null, [], ""));
|
|
509
|
+
}
|
|
510
|
+
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
|
|
511
|
+
}
|
|
512
|
+
async function health(ctx) {
|
|
513
|
+
const { request, response } = ctx;
|
|
514
|
+
const health = store.telemetry.snapshot();
|
|
515
|
+
return request.headers.accept?.includes('application/json')
|
|
516
|
+
? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
|
|
517
|
+
: respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
|
|
518
|
+
}
|
|
519
|
+
// v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
|
|
520
|
+
async function metrics(ctx) {
|
|
521
|
+
const { response, now } = ctx;
|
|
522
|
+
response.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
523
|
+
const set = options.configDir === undefined ? null : readMonitoring(options.configDir);
|
|
524
|
+
const destinations = new Map([
|
|
525
|
+
...(set?.webhook ? [["webhook", targetOf(set.webhook.url)]] : []),
|
|
526
|
+
...(set?.folder ? [["folder", targetOf(set.folder.path)]] : []),
|
|
527
|
+
...(set?.traces ? [["traces", targetOf(set.traces.endpoint)]] : []),
|
|
528
|
+
]);
|
|
529
|
+
return void response.end(prometheusMetrics(store, now, admissionList(), destinations));
|
|
530
|
+
}
|
|
531
|
+
async function ledgerCheckpoint(ctx) {
|
|
532
|
+
const { who, response, now } = ctx;
|
|
533
|
+
const made = store.ledgerCheckpoint(who.name, now);
|
|
534
|
+
if (made !== null && "problem" in made)
|
|
535
|
+
return refuse(response, who, 409, `No checkpoint was made: the chain doesn't verify (${made.problem}).`, "/ledger");
|
|
536
|
+
return redirect(response, made === null ? "/ledger" : `/ledger?checkpoint=${made.through}`);
|
|
537
|
+
}
|
|
538
|
+
async function projectsSelect(ctx) {
|
|
539
|
+
const { who, response, posted } = ctx;
|
|
540
|
+
const body = readForm(posted, CONSOLE_FORMS.projectsSelect);
|
|
541
|
+
// Session-only project switching (E2): reads the registry, writes
|
|
542
|
+
// ONLY session.project — the durable upsert lives in /projects/open,
|
|
543
|
+
// which stays an approver's act. This is the one project verb a
|
|
544
|
+
// viewer may use.
|
|
545
|
+
if (who.via !== "cookie") {
|
|
546
|
+
return refuse(response, who, 403, "selecting a project is a browser session's act");
|
|
547
|
+
}
|
|
548
|
+
const askedPath = (body.get("path") ?? "").trim();
|
|
549
|
+
const canonicalPick = askedPath === "" ? null : canonicalProject(askedPath);
|
|
550
|
+
if ((askedPath !== "" && canonicalPick === null) || (canonicalPick !== null && (!visible(canonicalPick) || !(await authorizedProject(liveCeiling(), canonicalPick))))) {
|
|
551
|
+
return refuse(response, who, 403, "that project is outside this console's reach");
|
|
552
|
+
}
|
|
553
|
+
who.session.project = canonicalPick;
|
|
554
|
+
who.session.projectRevision += 1;
|
|
555
|
+
sessions.persist(who.session);
|
|
556
|
+
return redirect(response, safeReturn(body.get("return")));
|
|
557
|
+
}
|
|
558
|
+
async function projectsRemove(ctx) {
|
|
559
|
+
const { who, response, posted, now } = ctx;
|
|
560
|
+
const body = readForm(posted, CONSOLE_FORMS.projectsRemove);
|
|
561
|
+
// The reversible remove: off every list and the builder; tasks, results and settings stay. Deleting is
|
|
562
|
+
// Settings → Project → Delete project. The same admission code as `repos remove` (project-admission.ts).
|
|
563
|
+
const repo = body.get("repo") ?? "";
|
|
564
|
+
if (!consoleProjects().includes(repo))
|
|
565
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
566
|
+
const released = await releaseProject(store, { registryFile: options.registryPath ?? null, path: repo, actor: { label: who.name, origin: "console" }, now });
|
|
567
|
+
if (!released.ok)
|
|
568
|
+
return redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&problem=${encodeURIComponent(`${projectName(repo)} wasn't removed: ${released.message}`)}`);
|
|
569
|
+
deletedRepos.add(repo);
|
|
570
|
+
if (who.via === "cookie" && who.session.project === repo) {
|
|
571
|
+
who.session.project = null;
|
|
559
572
|
who.session.projectRevision += 1;
|
|
560
573
|
sessions.persist(who.session);
|
|
561
|
-
return redirect(response, safeReturn(body.get("return")));
|
|
562
|
-
}
|
|
563
|
-
if (url.pathname === "/projects/remove") {
|
|
564
|
-
const body = readForm(posted, CONSOLE_FORMS.projectsRemove);
|
|
565
|
-
// The reversible remove: off every list and the builder; tasks, results and settings stay. Deleting is
|
|
566
|
-
// Settings → Project → Delete project. The same admission code as `repos remove` (project-admission.ts).
|
|
567
|
-
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
568
|
-
return refuse(response, who, 403, "An instance operator removes projects.", "/settings/project");
|
|
569
|
-
const repo = body.get("repo") ?? "";
|
|
570
|
-
if (!consoleProjects().includes(repo))
|
|
571
|
-
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
572
|
-
const released = await releaseProject(store, { registryFile: options.registryPath ?? null, path: repo, actor: { label: who.name, origin: "console" }, now });
|
|
573
|
-
if (!released.ok)
|
|
574
|
-
return redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&problem=${encodeURIComponent(`${projectName(repo)} wasn't removed: ${released.message}`)}`);
|
|
575
|
-
deletedRepos.add(repo);
|
|
576
|
-
if (who.session.project === repo) {
|
|
577
|
-
who.session.project = null;
|
|
578
|
-
who.session.projectRevision += 1;
|
|
579
|
-
sessions.persist(who.session);
|
|
580
|
-
}
|
|
581
|
-
return redirect(response, `/settings/project?said=${encodeURIComponent(`Removed ${projectName(repo)}. Its tasks and results stay saved.`)}`);
|
|
582
|
-
}
|
|
583
|
-
if (url.pathname === "/projects/open") {
|
|
584
|
-
const body = readForm(posted, CONSOLE_FORMS.projectsOpen);
|
|
585
|
-
// Sessions only: a bearer caller names its project per request and has
|
|
586
|
-
// no session to mutate — refusing here keeps that boundary legible.
|
|
587
|
-
if (who.via !== "cookie") {
|
|
588
|
-
return refuse(response, who, 403, "opening a project is a browser session's act");
|
|
589
|
-
}
|
|
590
|
-
const asked = (body.get("path") ?? "").trim();
|
|
591
|
-
const canonical = asked === "" ? null : canonicalProject(asked);
|
|
592
|
-
if (canonical === null) {
|
|
593
|
-
return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
|
|
594
|
-
}
|
|
595
|
-
// Authorization is the ceiling, then the path must actually be a git
|
|
596
|
-
// repository — validation with direct argv and a bound, never a shell.
|
|
597
|
-
// Both checks apply even to configured repos: naming a directory in
|
|
598
|
-
// config authorizes it; only being a repository makes it openable.
|
|
599
|
-
if (!(await authorizedProject(liveCeiling(), canonical))) {
|
|
600
|
-
return void projectsScreen(response, who, "that path is outside what this server was configured to serve", 403, safeReturn(body.get("return")));
|
|
601
|
-
}
|
|
602
|
-
if (!(await isGitRepo(canonical))) {
|
|
603
|
-
return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")));
|
|
604
|
-
}
|
|
605
|
-
// An account limited to listed projects never widens its own list.
|
|
606
|
-
if (!visible(canonical)) {
|
|
607
|
-
return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
|
|
608
|
-
}
|
|
609
|
-
// Opening an allowed repository admits it the same way `repos add` does
|
|
610
|
-
// (project-admission.ts). A co-located `up` notices the durable list and
|
|
611
|
-
// connects its builder; there is no separate restart or binding step.
|
|
612
|
-
const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
|
|
613
|
-
if (!admitted.ok) {
|
|
614
|
-
return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, 400, safeReturn(body.get("return")));
|
|
615
|
-
}
|
|
616
|
-
deletedRepos.delete(admitted.repo);
|
|
617
|
-
who.session.project = admitted.repo;
|
|
618
|
-
who.session.projectRevision++;
|
|
619
|
-
// The switcher (board pass) opens a project from any screen and
|
|
620
|
-
// returns there — a same-site path only, never an off-site road.
|
|
621
|
-
return redirect(response, safeReturn(body.get("return")));
|
|
622
|
-
}
|
|
623
|
-
// Runner lifecycle, brought in from the terminal behind the same
|
|
624
|
-
// password step-up the console uses for every other credential-grade
|
|
625
|
-
// act. Register mints a token that is shown ONCE and stored only as a
|
|
626
|
-
// hash — the page that shows it renders no self-refreshing script, so
|
|
627
|
-
// the token is never re-rendered.
|
|
628
|
-
if (url.pathname === "/fleet/runner/register") {
|
|
629
|
-
const body = readForm(posted, CONSOLE_FORMS.runnerRegister);
|
|
630
|
-
if (who.via !== "cookie")
|
|
631
|
-
return refuse(response, who, 403, "runner registration is a browser surface");
|
|
632
|
-
const token = body.get("token") ?? "";
|
|
633
|
-
if (!authenticateApprover(who, token).ok) {
|
|
634
|
-
return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
|
|
635
|
-
}
|
|
636
|
-
const name = (body.get("name") ?? "").trim();
|
|
637
|
-
if (name === "" || name.length > 60 || /[\r\n\t]/.test(name) || hasForbiddenControls(name)) {
|
|
638
|
-
return refuse(response, who, 400, "a worker's name is one short line, no control characters", "/fleet");
|
|
639
|
-
}
|
|
640
|
-
const capacity = Number((body.get("capacity") ?? "1").trim() || "1");
|
|
641
|
-
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 64) {
|
|
642
|
-
return refuse(response, who, 400, "capacity is a whole number of tasks, 1 to 64", "/fleet");
|
|
643
|
-
}
|
|
644
|
-
const { token: minted } = registerRunner(store, { name, host: hostname(), capacity, now });
|
|
645
|
-
const tokenScreen = screen("fleet", [
|
|
646
|
-
`<h1>${escape(name)} is registered</h1>`,
|
|
647
|
-
`<div class="card">`,
|
|
648
|
-
`<p>Its token, shown once. Only a hash is kept, so copy it now.</p>`,
|
|
649
|
-
`<p class="mono secret-value">${escape(minted)}</p>`,
|
|
650
|
-
`<p class="meta">Keep it beside the worker, in a file only you can read (0600) or a secrets manager. If it's lost, register the name again; its old claims are taken back.</p>`,
|
|
651
|
-
`</div>`,
|
|
652
|
-
`<p class="meta"><a href="/fleet">Back to Fleet</a></p>`,
|
|
653
|
-
].join("\n"),
|
|
654
|
-
// A one-time secret on screen: no script of any kind rides along.
|
|
655
|
-
{ chrome: chromeFor(projectOf(who, request) ?? null, "fleet"), forceSensitive: true });
|
|
656
|
-
return sendScreen(response, 200, tokenScreen);
|
|
657
|
-
}
|
|
658
|
-
if (url.pathname === "/fleet/runner/retire") {
|
|
659
|
-
const body = readForm(posted, CONSOLE_FORMS.runnerRetire);
|
|
660
|
-
if (who.via !== "cookie")
|
|
661
|
-
return refuse(response, who, 403, "runner retirement is a browser surface");
|
|
662
|
-
const token = body.get("token") ?? "";
|
|
663
|
-
if (!authenticateApprover(who, token).ok) {
|
|
664
|
-
return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
|
|
665
|
-
}
|
|
666
|
-
const name = (body.get("name") ?? "").trim();
|
|
667
|
-
const retired = store.retireRunner(name, now);
|
|
668
|
-
if (!retired)
|
|
669
|
-
return refuse(response, who, 404, `no worker \`${name}\``, "/fleet");
|
|
670
|
-
return redirect(response, `/fleet?said=${encodeURIComponent(`${name} is retired — its queued work is still here, drag it elsewhere`)}`);
|
|
671
|
-
}
|
|
672
|
-
if (url.pathname === "/projects/onboard-preview" || url.pathname === "/projects/onboard-confirm") {
|
|
673
|
-
const body = readForm(posted, CONSOLE_FORMS.onboard);
|
|
674
|
-
// Repo onboarding (findings 1-39): bearer and demo refuse BEFORE any
|
|
675
|
-
// gh call; the console flow exists only on root-configured serves.
|
|
676
|
-
if (who.via !== "cookie")
|
|
677
|
-
return refuse(response, who, 403, "adding repositories is a browser session's act");
|
|
678
|
-
if (store.isDemo())
|
|
679
|
-
return refuse(response, who, 403, "the sandbox never clones");
|
|
680
|
-
if (process.platform === "win32")
|
|
681
|
-
return refuse(response, who, 403, "adding from GitHub is not supported on Windows yet");
|
|
682
|
-
if (ceiling.roots.length === 0)
|
|
683
|
-
return projectsScreen(response, who, "naming where repositories live takes --project-root", 400);
|
|
684
|
-
if (url.pathname === "/projects/onboard-preview") {
|
|
685
|
-
const shape = parseGithubRepo(body.get("repo") ?? "");
|
|
686
|
-
if (!shape.ok)
|
|
687
|
-
return projectsScreen(response, who, shape.problem, 400);
|
|
688
|
-
// Roots by INDEX (finding 13) — a posted path is never accepted.
|
|
689
|
-
const rootIndex = Number(body.get("root") ?? "0");
|
|
690
|
-
const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
|
|
691
|
-
if (rootRaw === undefined)
|
|
692
|
-
return projectsScreen(response, who, "pick one of the configured roots", 400);
|
|
693
|
-
let root;
|
|
694
|
-
try {
|
|
695
|
-
root = realpathSync(rootRaw);
|
|
696
|
-
}
|
|
697
|
-
catch {
|
|
698
|
-
return projectsScreen(response, who, "that projects root does not resolve right now", 400);
|
|
699
|
-
}
|
|
700
|
-
const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
|
|
701
|
-
if (!previewed.ok)
|
|
702
|
-
return projectsScreen(response, who, previewed.message, 400);
|
|
703
|
-
const reparsed = parseGithubRepo(previewed.preview.nameWithOwner);
|
|
704
|
-
if (!reparsed.ok)
|
|
705
|
-
return projectsScreen(response, who, "GitHub named a repository shape this console refuses", 400);
|
|
706
|
-
// The hook boundary is NORMALIZED like the real preview would be
|
|
707
|
-
// (verification finding 2): a size that is not a nonnegative finite
|
|
708
|
-
// number is unknown, and unknown is LARGE.
|
|
709
|
-
const diskUsageKib = typeof previewed.preview.diskUsageKib === "number" && Number.isFinite(previewed.preview.diskUsageKib) && previewed.preview.diskUsageKib >= 0
|
|
710
|
-
? previewed.preview.diskUsageKib
|
|
711
|
-
: null;
|
|
712
|
-
const target = join(root, reparsed.name);
|
|
713
|
-
if (dirname(target) !== root)
|
|
714
|
-
return projectsScreen(response, who, "the target escaped its root — refused", 400);
|
|
715
|
-
// The session-held record (finding 14): swept at mint, capped 3.
|
|
716
|
-
const session = who.session;
|
|
717
|
-
session.onboard ??= new Map();
|
|
718
|
-
const cutoff = Date.now() - 10 * 60_000;
|
|
719
|
-
for (const [key, record] of session.onboard)
|
|
720
|
-
if (record.mintedAt < cutoff)
|
|
721
|
-
session.onboard.delete(key);
|
|
722
|
-
if (session.onboard.size >= 3)
|
|
723
|
-
return projectsScreen(response, who, "three previews are already waiting — confirm or let one expire", 400);
|
|
724
|
-
const nonce = randomBytes(16).toString("hex");
|
|
725
|
-
session.onboard.set(nonce, {
|
|
726
|
-
nameWithOwner: previewed.preview.nameWithOwner,
|
|
727
|
-
rootIndex,
|
|
728
|
-
target,
|
|
729
|
-
diskUsageKib,
|
|
730
|
-
large: isLargeRepo({ ...previewed.preview, diskUsageKib }),
|
|
731
|
-
mintedAt: Date.now(),
|
|
732
|
-
});
|
|
733
|
-
return projectsScreen(response, who, null, 200);
|
|
734
|
-
}
|
|
735
|
-
// CONFIRM: password, then session liveness AGAIN via lookupSession
|
|
736
|
-
// (finding 22/33) — the record is consumed from the RETURNED live
|
|
737
|
-
// session, synchronously, before the first await.
|
|
738
|
-
const password = body.get("token") ?? "";
|
|
739
|
-
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
740
|
-
return projectsScreen(response, who, "cloning takes your password, typed again", 403);
|
|
741
|
-
}
|
|
742
|
-
const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
|
|
743
|
-
const live = cookieId === undefined ? null : lookupSession(cookieId, false);
|
|
744
|
-
if (live === null)
|
|
745
|
-
return refuse(response, who, 403, "this session ended while the form was open — sign in again");
|
|
746
|
-
const nonce = body.get("nonce") ?? "";
|
|
747
|
-
const record = live.onboard?.get(nonce);
|
|
748
|
-
if (record === undefined || record.mintedAt < Date.now() - 10 * 60_000) {
|
|
749
|
-
live.onboard?.delete(nonce);
|
|
750
|
-
return projectsScreen(response, who, "that preview expired or was already used — preview again", 400);
|
|
751
|
-
}
|
|
752
|
-
live.onboard?.delete(nonce); // consumed BEFORE the first await
|
|
753
|
-
if (record.large && body.get("big-ok") !== "1") {
|
|
754
|
-
return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400);
|
|
755
|
-
}
|
|
756
|
-
const rootRaw = ceiling.roots[record.rootIndex];
|
|
757
|
-
if (rootRaw === undefined)
|
|
758
|
-
return projectsScreen(response, who, "the configured roots changed — preview again", 400);
|
|
759
|
-
let root;
|
|
760
|
-
try {
|
|
761
|
-
root = realpathSync(rootRaw);
|
|
762
|
-
}
|
|
763
|
-
catch {
|
|
764
|
-
return projectsScreen(response, who, "that projects root does not resolve right now", 400);
|
|
765
|
-
}
|
|
766
|
-
if (dirname(record.target) !== root)
|
|
767
|
-
return projectsScreen(response, who, "the configured roots changed — preview again", 400);
|
|
768
|
-
const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
|
|
769
|
-
const answer = await execRun("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 15_000 });
|
|
770
|
-
return { code: answer.code, stdout: answer.stdout };
|
|
771
|
-
});
|
|
772
|
-
if (!cloned.ok)
|
|
773
|
-
return projectsScreen(response, who, cloned.message, 400);
|
|
774
|
-
// The clone answer is BOUND to the record's exact target (verification
|
|
775
|
-
// finding 2): whatever produced it — the real primitive or a test
|
|
776
|
-
// hook — an answer naming any other path never enrolls or opens.
|
|
777
|
-
if (cloned.target !== record.target) {
|
|
778
|
-
return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
|
|
779
|
-
}
|
|
780
|
-
// authorizedProject AFTER the repository exists (finding 20).
|
|
781
|
-
const proved = await authorizedProject(liveCeiling(), cloned.target);
|
|
782
|
-
const admitted = proved ? (canonicalProject(cloned.target) ?? cloned.target) : null;
|
|
783
|
-
if (admitted === null) {
|
|
784
|
-
return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400);
|
|
785
|
-
}
|
|
786
|
-
const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
|
|
787
|
-
if (!joined.ok) {
|
|
788
|
-
return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
|
|
789
|
-
}
|
|
790
|
-
deletedRepos.delete(admitted);
|
|
791
|
-
who.session.project = admitted;
|
|
792
|
-
who.session.projectRevision += 1;
|
|
793
|
-
return projectsScreen(response, who, options.upConsole === true
|
|
794
|
-
? `${projectName(admitted)} is ready — the builder is connecting automatically`
|
|
795
|
-
: `${admitted} is ready and open`, 200);
|
|
796
574
|
}
|
|
797
|
-
return
|
|
575
|
+
return redirect(response, `/settings/project?said=${encodeURIComponent(`Removed ${projectName(repo)}. Its tasks and results stay saved.`)}`);
|
|
576
|
+
}
|
|
577
|
+
async function projectsOpen(ctx) {
|
|
578
|
+
const { who, response, posted, now } = ctx;
|
|
579
|
+
const body = readForm(posted, CONSOLE_FORMS.projectsOpen);
|
|
580
|
+
// Sessions only: a bearer caller names its project per request and has
|
|
581
|
+
// no session to mutate — refusing here keeps that boundary legible.
|
|
582
|
+
if (who.via !== "cookie") {
|
|
583
|
+
return refuse(response, who, 403, "opening a project is a browser session's act");
|
|
584
|
+
}
|
|
585
|
+
const asked = (body.get("path") ?? "").trim();
|
|
586
|
+
const canonical = asked === "" ? null : canonicalProject(asked);
|
|
587
|
+
if (canonical === null) {
|
|
588
|
+
return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
|
|
589
|
+
}
|
|
590
|
+
// Authorization is the ceiling, then the path must actually be a git
|
|
591
|
+
// repository — validation with direct argv and a bound, never a shell.
|
|
592
|
+
// Both checks apply even to configured repos: naming a directory in
|
|
593
|
+
// config authorizes it; only being a repository makes it openable.
|
|
594
|
+
if (!(await authorizedProject(liveCeiling(), canonical))) {
|
|
595
|
+
return void projectsScreen(response, who, "that path is outside what this server was configured to serve", 403, safeReturn(body.get("return")));
|
|
596
|
+
}
|
|
597
|
+
if (!(await isGitRepo(canonical))) {
|
|
598
|
+
return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")));
|
|
599
|
+
}
|
|
600
|
+
// An account limited to listed projects never widens its own list.
|
|
601
|
+
if (!visible(canonical)) {
|
|
602
|
+
return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
|
|
603
|
+
}
|
|
604
|
+
// Opening an allowed repository admits it the same way `repos add` does
|
|
605
|
+
// (project-admission.ts). A co-located `up` notices the durable list and
|
|
606
|
+
// connects its builder; there is no separate restart or binding step.
|
|
607
|
+
const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
|
|
608
|
+
if (!admitted.ok) {
|
|
609
|
+
return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, 400, safeReturn(body.get("return")));
|
|
610
|
+
}
|
|
611
|
+
deletedRepos.delete(admitted.repo);
|
|
612
|
+
who.session.project = admitted.repo;
|
|
613
|
+
who.session.projectRevision++;
|
|
614
|
+
// The switcher (board pass) opens a project from any screen and
|
|
615
|
+
// returns there — a same-site path only, never an off-site road.
|
|
616
|
+
return redirect(response, safeReturn(body.get("return")));
|
|
617
|
+
}
|
|
618
|
+
// Runner lifecycle, brought in from the terminal behind the same
|
|
619
|
+
// password step-up the console uses for every other credential-grade
|
|
620
|
+
// act. Register mints a token that is shown ONCE and stored only as a
|
|
621
|
+
// hash — the page that shows it renders no self-refreshing script, so
|
|
622
|
+
// the token is never re-rendered.
|
|
623
|
+
async function fleetRegister(ctx) {
|
|
624
|
+
const { who, request, response, posted, now } = ctx;
|
|
625
|
+
const body = readForm(posted, CONSOLE_FORMS.runnerRegister);
|
|
626
|
+
if (who.via !== "cookie")
|
|
627
|
+
return refuse(response, who, 403, "runner registration is a browser surface");
|
|
628
|
+
const token = body.get("token") ?? "";
|
|
629
|
+
if (!authenticateApprover(who, token).ok) {
|
|
630
|
+
return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
|
|
631
|
+
}
|
|
632
|
+
const name = (body.get("name") ?? "").trim();
|
|
633
|
+
if (name === "" || name.length > 60 || /[\r\n\t]/.test(name) || hasForbiddenControls(name)) {
|
|
634
|
+
return refuse(response, who, 400, "a worker's name is one short line, no control characters", "/fleet");
|
|
635
|
+
}
|
|
636
|
+
const capacity = Number((body.get("capacity") ?? "1").trim() || "1");
|
|
637
|
+
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 64) {
|
|
638
|
+
return refuse(response, who, 400, "capacity is a whole number of tasks, 1 to 64", "/fleet");
|
|
639
|
+
}
|
|
640
|
+
const { token: minted } = registerRunner(store, { name, host: hostname(), capacity, now });
|
|
641
|
+
const tokenScreen = screen("fleet", joinHtml([
|
|
642
|
+
html `<h1>${name} is registered</h1>`,
|
|
643
|
+
html `<div class="card">`,
|
|
644
|
+
html `<p>Its token, shown once. Only a hash is kept, so copy it now.</p>`,
|
|
645
|
+
html `<p class="mono secret-value">${minted}</p>`,
|
|
646
|
+
html `<p class="meta">Keep it beside the worker, in a file only you can read (0600) or a secrets manager. If it's lost, register the name again; its old claims are taken back.</p>`,
|
|
647
|
+
html `</div>`,
|
|
648
|
+
html `<p class="meta"><a href="/fleet">Back to Fleet</a></p>`,
|
|
649
|
+
], "\n"),
|
|
650
|
+
// A one-time secret on screen: no script of any kind rides along.
|
|
651
|
+
{ chrome: chromeFor(projectOf(who, request) ?? null, "fleet"), forceSensitive: true });
|
|
652
|
+
return sendScreen(response, 200, tokenScreen);
|
|
653
|
+
}
|
|
654
|
+
async function fleetRetire(ctx) {
|
|
655
|
+
const { who, response, posted, now } = ctx;
|
|
656
|
+
const body = readForm(posted, CONSOLE_FORMS.runnerRetire);
|
|
657
|
+
if (who.via !== "cookie")
|
|
658
|
+
return refuse(response, who, 403, "runner retirement is a browser surface");
|
|
659
|
+
const token = body.get("token") ?? "";
|
|
660
|
+
if (!authenticateApprover(who, token).ok) {
|
|
661
|
+
return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
|
|
662
|
+
}
|
|
663
|
+
const name = (body.get("name") ?? "").trim();
|
|
664
|
+
const retired = store.retireRunner(name, now);
|
|
665
|
+
if (!retired)
|
|
666
|
+
return refuse(response, who, 404, `no worker \`${name}\``, "/fleet");
|
|
667
|
+
return redirect(response, `/fleet?said=${encodeURIComponent(`${name} is retired — its queued work is still here, drag it elsewhere`)}`);
|
|
668
|
+
}
|
|
669
|
+
async function onboardPreview(ctx) {
|
|
670
|
+
const { who, response, posted } = ctx;
|
|
671
|
+
const body = readForm(posted, CONSOLE_FORMS.onboard);
|
|
672
|
+
// Repo onboarding (findings 1-39): bearer and demo refuse BEFORE any
|
|
673
|
+
// gh call; the console flow exists only on root-configured serves.
|
|
674
|
+
if (who.via !== "cookie")
|
|
675
|
+
return refuse(response, who, 403, "adding repositories is a browser session's act");
|
|
676
|
+
if (store.isDemo())
|
|
677
|
+
return refuse(response, who, 403, "the sandbox never clones");
|
|
678
|
+
if (process.platform === "win32")
|
|
679
|
+
return refuse(response, who, 403, "adding from GitHub is not supported on Windows yet");
|
|
680
|
+
if (ceiling.roots.length === 0)
|
|
681
|
+
return projectsScreen(response, who, "naming where repositories live takes --project-root", 400);
|
|
682
|
+
const shape = parseGithubRepo(body.get("repo") ?? "");
|
|
683
|
+
if (!shape.ok)
|
|
684
|
+
return projectsScreen(response, who, shape.problem, 400);
|
|
685
|
+
// Roots by INDEX (finding 13) — a posted path is never accepted.
|
|
686
|
+
const rootIndex = Number(body.get("root") ?? "0");
|
|
687
|
+
const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
|
|
688
|
+
if (rootRaw === undefined)
|
|
689
|
+
return projectsScreen(response, who, "pick one of the configured roots", 400);
|
|
690
|
+
let root;
|
|
691
|
+
try {
|
|
692
|
+
root = realpathSync(rootRaw);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return projectsScreen(response, who, "that projects root does not resolve right now", 400);
|
|
696
|
+
}
|
|
697
|
+
const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
|
|
698
|
+
if (!previewed.ok)
|
|
699
|
+
return projectsScreen(response, who, previewed.message, 400);
|
|
700
|
+
const reparsed = parseGithubRepo(previewed.preview.nameWithOwner);
|
|
701
|
+
if (!reparsed.ok)
|
|
702
|
+
return projectsScreen(response, who, "GitHub named a repository shape this console refuses", 400);
|
|
703
|
+
// The hook boundary is NORMALIZED like the real preview would be
|
|
704
|
+
// (verification finding 2): a size that is not a nonnegative finite
|
|
705
|
+
// number is unknown, and unknown is LARGE.
|
|
706
|
+
const diskUsageKib = typeof previewed.preview.diskUsageKib === "number" && Number.isFinite(previewed.preview.diskUsageKib) && previewed.preview.diskUsageKib >= 0
|
|
707
|
+
? previewed.preview.diskUsageKib
|
|
708
|
+
: null;
|
|
709
|
+
const target = join(root, reparsed.name);
|
|
710
|
+
if (dirname(target) !== root)
|
|
711
|
+
return projectsScreen(response, who, "the target escaped its root — refused", 400);
|
|
712
|
+
// The session-held record (finding 14): swept at mint, capped 3.
|
|
713
|
+
const session = who.session;
|
|
714
|
+
session.onboard ??= new Map();
|
|
715
|
+
const cutoff = Date.now() - 10 * 60_000;
|
|
716
|
+
for (const [key, record] of session.onboard)
|
|
717
|
+
if (record.mintedAt < cutoff)
|
|
718
|
+
session.onboard.delete(key);
|
|
719
|
+
if (session.onboard.size >= 3)
|
|
720
|
+
return projectsScreen(response, who, "three previews are already waiting — confirm or let one expire", 400);
|
|
721
|
+
const nonce = randomBytes(16).toString("hex");
|
|
722
|
+
session.onboard.set(nonce, {
|
|
723
|
+
nameWithOwner: previewed.preview.nameWithOwner,
|
|
724
|
+
rootIndex,
|
|
725
|
+
target,
|
|
726
|
+
diskUsageKib,
|
|
727
|
+
large: isLargeRepo({ ...previewed.preview, diskUsageKib }),
|
|
728
|
+
mintedAt: Date.now(),
|
|
729
|
+
});
|
|
730
|
+
return projectsScreen(response, who, null, 200);
|
|
731
|
+
}
|
|
732
|
+
async function onboardConfirm(ctx) {
|
|
733
|
+
const { who, request, response, posted, now } = ctx;
|
|
734
|
+
const body = readForm(posted, CONSOLE_FORMS.onboard);
|
|
735
|
+
// Repo onboarding (findings 1-39): bearer and demo refuse BEFORE any
|
|
736
|
+
// gh call; the console flow exists only on root-configured serves.
|
|
737
|
+
if (who.via !== "cookie")
|
|
738
|
+
return refuse(response, who, 403, "adding repositories is a browser session's act");
|
|
739
|
+
if (store.isDemo())
|
|
740
|
+
return refuse(response, who, 403, "the sandbox never clones");
|
|
741
|
+
if (process.platform === "win32")
|
|
742
|
+
return refuse(response, who, 403, "adding from GitHub is not supported on Windows yet");
|
|
743
|
+
if (ceiling.roots.length === 0)
|
|
744
|
+
return projectsScreen(response, who, "naming where repositories live takes --project-root", 400);
|
|
745
|
+
// CONFIRM: password, then session liveness AGAIN via lookupSession
|
|
746
|
+
// (finding 22/33) — the record is consumed from the RETURNED live
|
|
747
|
+
// session, synchronously, before the first await.
|
|
748
|
+
const password = body.get("token") ?? "";
|
|
749
|
+
if (password === "" || !authenticateApprover(who, password).ok) {
|
|
750
|
+
return projectsScreen(response, who, "cloning takes your password, typed again", 403);
|
|
751
|
+
}
|
|
752
|
+
const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
|
|
753
|
+
const live = cookieId === undefined ? null : lookupSession(cookieId, false);
|
|
754
|
+
if (live === null)
|
|
755
|
+
return refuse(response, who, 403, "this session ended while the form was open — sign in again");
|
|
756
|
+
const nonce = body.get("nonce") ?? "";
|
|
757
|
+
const record = live.onboard?.get(nonce);
|
|
758
|
+
if (record === undefined || record.mintedAt < Date.now() - 10 * 60_000) {
|
|
759
|
+
live.onboard?.delete(nonce);
|
|
760
|
+
return projectsScreen(response, who, "that preview expired or was already used — preview again", 400);
|
|
761
|
+
}
|
|
762
|
+
live.onboard?.delete(nonce); // consumed BEFORE the first await
|
|
763
|
+
if (record.large && body.get("big-ok") !== "1") {
|
|
764
|
+
return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400);
|
|
765
|
+
}
|
|
766
|
+
const rootRaw = ceiling.roots[record.rootIndex];
|
|
767
|
+
if (rootRaw === undefined)
|
|
768
|
+
return projectsScreen(response, who, "the configured roots changed — preview again", 400);
|
|
769
|
+
let root;
|
|
770
|
+
try {
|
|
771
|
+
root = realpathSync(rootRaw);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
return projectsScreen(response, who, "that projects root does not resolve right now", 400);
|
|
775
|
+
}
|
|
776
|
+
if (dirname(record.target) !== root)
|
|
777
|
+
return projectsScreen(response, who, "the configured roots changed — preview again", 400);
|
|
778
|
+
const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
|
|
779
|
+
const answer = await execRun("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 15_000 });
|
|
780
|
+
return { code: answer.code, stdout: answer.stdout };
|
|
781
|
+
});
|
|
782
|
+
if (!cloned.ok)
|
|
783
|
+
return projectsScreen(response, who, cloned.message, 400);
|
|
784
|
+
// The clone answer is BOUND to the record's exact target (verification
|
|
785
|
+
// finding 2): whatever produced it — the real primitive or a test
|
|
786
|
+
// hook — an answer naming any other path never enrolls or opens.
|
|
787
|
+
if (cloned.target !== record.target) {
|
|
788
|
+
return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
|
|
789
|
+
}
|
|
790
|
+
// authorizedProject AFTER the repository exists (finding 20).
|
|
791
|
+
const proved = await authorizedProject(liveCeiling(), cloned.target);
|
|
792
|
+
const admitted = proved ? (canonicalProject(cloned.target) ?? cloned.target) : null;
|
|
793
|
+
if (admitted === null) {
|
|
794
|
+
return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400);
|
|
795
|
+
}
|
|
796
|
+
const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
|
|
797
|
+
if (!joined.ok) {
|
|
798
|
+
return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
|
|
799
|
+
}
|
|
800
|
+
deletedRepos.delete(admitted);
|
|
801
|
+
who.session.project = admitted;
|
|
802
|
+
who.session.projectRevision += 1;
|
|
803
|
+
return projectsScreen(response, who, options.upConsole === true
|
|
804
|
+
? `${projectName(admitted)} is ready — the builder is connecting automatically`
|
|
805
|
+
: `${admitted} is ready and open`, 200);
|
|
798
806
|
}
|
|
799
807
|
/**
|
|
800
808
|
* The opener: every project inside the ceiling, most recently opened
|
|
@@ -814,7 +822,6 @@ export function createPagesHandlers(runtime) {
|
|
|
814
822
|
candidates.add(canonical);
|
|
815
823
|
}
|
|
816
824
|
const open = who.via === "cookie" ? who.session.project : null;
|
|
817
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
818
825
|
// Onboarding availability (findings 2/28/32/39): a cookie session on a
|
|
819
826
|
// root-configured, non-demo, POSIX serve gets the live card; everybody
|
|
820
827
|
// else gets the card DISABLED with the reason in words.
|
|
@@ -847,62 +854,74 @@ export function createPagesHandlers(runtime) {
|
|
|
847
854
|
peeks[one.path] = peekOf(one.path);
|
|
848
855
|
for (const path of candidates)
|
|
849
856
|
peeks[path] = peekOf(path);
|
|
850
|
-
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open,
|
|
857
|
+
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
|
|
851
858
|
}
|
|
852
|
-
async function
|
|
853
|
-
const {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
return;
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
}
|
|
873
|
-
|
|
859
|
+
async function healthzEdge(ctx) {
|
|
860
|
+
const { response, method } = ctx;
|
|
861
|
+
return healthz(store, response, method === 'HEAD');
|
|
862
|
+
}
|
|
863
|
+
async function browserAsset(ctx) {
|
|
864
|
+
const { request, response, url } = ctx;
|
|
865
|
+
serveBrowserAsset(request, response, url.pathname);
|
|
866
|
+
}
|
|
867
|
+
async function workspaceStyle(ctx) {
|
|
868
|
+
const { request, response, url } = ctx;
|
|
869
|
+
// Only the current build's hash is served; any other hash is no asset.
|
|
870
|
+
if (url.pathname !== WORKSPACE_STYLE.path)
|
|
871
|
+
return respond(response, 404, 'text/plain; charset=utf-8', 'No such address.');
|
|
872
|
+
return WORKSPACE_STYLE.serve(request, response);
|
|
873
|
+
}
|
|
874
|
+
async function installAsset(ctx) {
|
|
875
|
+
const { response, url } = ctx;
|
|
876
|
+
serveInstallAsset(response, url.pathname, respond);
|
|
877
|
+
}
|
|
878
|
+
async function fontAsset(ctx) {
|
|
879
|
+
const { response, url } = ctx;
|
|
880
|
+
serveInstallAsset(response, url.pathname, respond);
|
|
881
|
+
}
|
|
882
|
+
async function desktopHealth(ctx) {
|
|
883
|
+
const { response, url } = ctx;
|
|
884
|
+
const identity = options.desktopIdentity;
|
|
885
|
+
if (identity === undefined)
|
|
886
|
+
return respond(response, 404, 'text/plain; charset=utf-8', 'No such address.');
|
|
887
|
+
const challenge = url.searchParams.get("challenge") ?? "";
|
|
888
|
+
if (!/^[a-f0-9]{64}$/.test(challenge) || !/^[a-f0-9]{64}$/.test(identity))
|
|
889
|
+
return respond(response, 400, "application/json", JSON.stringify({ error: "invalid challenge" }));
|
|
890
|
+
response.setHeader("cache-control", "no-store");
|
|
891
|
+
return respond(response, 200, "application/json", JSON.stringify({ proof: createHmac("sha256", Buffer.from(identity, "hex")).update(challenge).digest("hex") }));
|
|
874
892
|
}
|
|
875
|
-
const registrations =
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
893
|
+
const registrations = handlersOf("pages", {
|
|
894
|
+
"projects.browse": projectsBrowse,
|
|
895
|
+
"projects.github": projectsGithub,
|
|
896
|
+
"projects.page": projectsIndex,
|
|
897
|
+
"home": homeRedirect,
|
|
898
|
+
"morning": morningAlias,
|
|
899
|
+
"workbench": workbench,
|
|
900
|
+
"ledger": ledgerPage,
|
|
901
|
+
"activity": activityPage,
|
|
902
|
+
"system": systemScreen,
|
|
903
|
+
"peek": peekPage,
|
|
904
|
+
"fleet": fleetPage,
|
|
905
|
+
"ledger.export": ledgerExport,
|
|
906
|
+
"menu": menuPage,
|
|
907
|
+
"caps": capsScreen,
|
|
908
|
+
"health": health,
|
|
909
|
+
"metrics": metrics,
|
|
910
|
+
"ledger.checkpoint": ledgerCheckpoint,
|
|
911
|
+
"projects.select": projectsSelect,
|
|
912
|
+
"projects.remove": projectsRemove,
|
|
913
|
+
"projects.open": projectsOpen,
|
|
914
|
+
"fleet.register": fleetRegister,
|
|
915
|
+
"fleet.retire": fleetRetire,
|
|
916
|
+
"projects.onboard-preview": onboardPreview,
|
|
917
|
+
"projects.onboard-confirm": onboardConfirm,
|
|
918
|
+
}, {
|
|
919
|
+
"edge.healthz": healthzEdge,
|
|
920
|
+
"edge.browser-asset": browserAsset,
|
|
921
|
+
"edge.workspace-style": workspaceStyle,
|
|
922
|
+
"edge.install-asset": installAsset,
|
|
923
|
+
"edge.font": fontAsset,
|
|
924
|
+
"edge.desktop-health": desktopHealth,
|
|
925
|
+
});
|
|
926
|
+
return { registrations, projectsScreen };
|
|
908
927
|
}
|