@plurnk/plurnk-web 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +337 -2
- package/SPEC.md +869 -0
- package/ai.plurnk/.env.defaults +38 -0
- package/ai.plurnk/assets.json +193 -0
- package/ai.plurnk/creations.js +65 -0
- package/ai.plurnk/diagram-compiler.js +15 -0
- package/ai.plurnk/diagrams.js +130 -0
- package/ai.plurnk/directory-addresses.js +35 -0
- package/ai.plurnk/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/ai.plurnk/dist/assets/index-B4w6iDKw.css +1 -0
- package/ai.plurnk/dist/assets/index-B4w6iDKw.css.br +0 -0
- package/ai.plurnk/dist/assets/index-B4w6iDKw.css.gz +0 -0
- package/ai.plurnk/dist/assets/index-CGRGblLU.js +2 -0
- package/ai.plurnk/dist/assets/index-CGRGblLU.js.br +0 -0
- package/ai.plurnk/dist/assets/index-CGRGblLU.js.gz +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css +1 -0
- package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css.br +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css.gz +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js +96 -0
- package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js.br +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js.gz +0 -0
- package/ai.plurnk/dist/assets/overview-DEhW9HUG.js +1 -0
- package/ai.plurnk/dist/assets/overview-DEhW9HUG.js.br +0 -0
- package/ai.plurnk/dist/assets/overview-DEhW9HUG.js.gz +0 -0
- package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js +1 -0
- package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js.br +0 -0
- package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js.gz +0 -0
- package/ai.plurnk/dist/assets/session-BcdGbgAa.js +4 -0
- package/ai.plurnk/dist/assets/session-BcdGbgAa.js.br +0 -0
- package/ai.plurnk/dist/assets/session-BcdGbgAa.js.gz +0 -0
- package/ai.plurnk/dist/assets/session-agent-DwY8S586.js +111 -0
- package/ai.plurnk/dist/assets/session-agent-DwY8S586.js.br +0 -0
- package/ai.plurnk/dist/assets/session-agent-DwY8S586.js.gz +0 -0
- package/ai.plurnk/dist/assets/src-Du01yeMu.js +1 -0
- package/ai.plurnk/dist/assets/src-Du01yeMu.js.br +0 -0
- package/ai.plurnk/dist/assets/src-J2qT9xpv.js +6 -0
- package/ai.plurnk/dist/assets/src-J2qT9xpv.js.br +0 -0
- package/ai.plurnk/dist/assets/src-J2qT9xpv.js.gz +0 -0
- package/ai.plurnk/dist/icons/APACHE-2.0.txt +202 -0
- package/ai.plurnk/dist/icons/APACHE-2.0.txt.br +0 -0
- package/ai.plurnk/dist/icons/APACHE-2.0.txt.gz +0 -0
- package/ai.plurnk/dist/icons/NOTO-NOTICE.txt +10 -0
- package/ai.plurnk/dist/icons/NOTO-NOTICE.txt.br +0 -0
- package/ai.plurnk/dist/icons/NOTO-NOTICE.txt.gz +0 -0
- package/ai.plurnk/dist/icons/plurnk-maskable.svg +64 -0
- package/ai.plurnk/dist/icons/plurnk-maskable.svg.br +0 -0
- package/ai.plurnk/dist/icons/plurnk-maskable.svg.gz +0 -0
- package/ai.plurnk/dist/icons/plurnk.svg +63 -0
- package/ai.plurnk/dist/icons/plurnk.svg.br +0 -0
- package/ai.plurnk/dist/icons/plurnk.svg.gz +0 -0
- package/ai.plurnk/dist/index.html +421 -0
- package/ai.plurnk/dist/manifest.webmanifest +27 -0
- package/ai.plurnk/dist/manifest.webmanifest.br +0 -0
- package/ai.plurnk/dist/manifest.webmanifest.gz +0 -0
- package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt +25 -0
- package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt +571 -0
- package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt +25 -0
- package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/OTM-LICENSE.txt +21 -0
- package/ai.plurnk/dist/vendor/OTM-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/OTM-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/PICO-LICENSE.txt +21 -0
- package/ai.plurnk/dist/vendor/PICO-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/PICO-LICENSE.txt.gz +0 -0
- package/ai.plurnk/documents.js +483 -0
- package/ai.plurnk/http.js +69 -0
- package/ai.plurnk/index-document.js +5 -0
- package/ai.plurnk/module.js +326 -0
- package/ai.plurnk/session.js +319 -0
- package/ai.plurnk/static-assets.js +40 -0
- package/package.json +92 -5
- package/plugin.json +13 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { STATUS_CODES } from "node:http";
|
|
3
|
+
import { directoryPath } from "./directory-addresses.js";
|
|
4
|
+
import { escapeHtml, RequestError } from "./http.js";
|
|
5
|
+
|
|
6
|
+
// A dialog's dismissal; a form offered again on an error page has none.
|
|
7
|
+
const CANCEL = '<button type="button" data-dialog-close data-i18n="cancel">Cancel</button>';
|
|
8
|
+
/** @typedef {{ readonly id: number, readonly name: string, readonly project_root: string | null, readonly project_label: string | null, readonly archived_at: string | null }} DirectoryWorkspace */
|
|
9
|
+
/** @typedef {{ readonly id: number, readonly coordinate: string, readonly completion: 200 | 499 }} DirectoryReply */
|
|
10
|
+
/** @typedef {{ readonly id: number, readonly name: string, readonly parentSessionId: number | null, readonly origin: string, readonly lifecycle: string, readonly archived_at: string | null, readonly reply?: DirectoryReply | null, readonly workspaceId?: number }} DirectorySession */
|
|
11
|
+
/** @typedef {{ readonly id: number, readonly name: string, readonly projectRoot: string | null }} DirectoryOverviewWorkspace */
|
|
12
|
+
/** @typedef {{ readonly id: number, readonly workspaceId: number, readonly name: string, readonly parentSessionId: number | null, readonly lifecycle: string, readonly reply: DirectoryReply | null }} DirectoryOverviewSession */
|
|
13
|
+
/** @typedef {{ readonly workspaces: readonly DirectoryOverviewWorkspace[], readonly sessions: readonly DirectoryOverviewSession[] }} DirectoryOverview */
|
|
14
|
+
|
|
15
|
+
/** @param {DirectorySession} a @param {DirectorySession} b */
|
|
16
|
+
const orderByReply = (a, b) => {
|
|
17
|
+
const aReply = a.reply ?? null;
|
|
18
|
+
const bReply = b.reply ?? null;
|
|
19
|
+
return aReply !== null && bReply !== null ? bReply.id - aReply.id
|
|
20
|
+
: aReply !== null ? -1 : bReply !== null ? 1 : 0;
|
|
21
|
+
};
|
|
22
|
+
/** {§web-document} {§web-directory-documents} One compiled document supplies the complete HTML/OTM substrate. */
|
|
23
|
+
export default class Documents {
|
|
24
|
+
#beforeTitle;
|
|
25
|
+
#beforeMain;
|
|
26
|
+
#afterMain;
|
|
27
|
+
#home;
|
|
28
|
+
#yolo;
|
|
29
|
+
#reasoningPreviewChars;
|
|
30
|
+
|
|
31
|
+
/** {§web-authorization-style} Read the stylesheet declaration from our compiled head, not unrelated lazy assets.
|
|
32
|
+
* @param {string} document
|
|
33
|
+
*/
|
|
34
|
+
static stylesheet(document) {
|
|
35
|
+
const links = [...document.split("</head>")[0].matchAll(/<link\b(?=[^>]*\brel="stylesheet")[^>]*>/gu)];
|
|
36
|
+
if (links.length !== 1) throw new Error("The web document must declare exactly one application stylesheet.");
|
|
37
|
+
const path = /\bhref="(\/[^"]+)"/u.exec(links[0][0])?.[1];
|
|
38
|
+
if (!path || path.startsWith("//")) throw new Error("The application stylesheet must have an origin-relative path.");
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} document @param {boolean | null} yolo @param {number | null} reasoningPreviewChars */
|
|
43
|
+
constructor(document, yolo, reasoningPreviewChars) {
|
|
44
|
+
this.#yolo = yolo;
|
|
45
|
+
this.#reasoningPreviewChars = reasoningPreviewChars;
|
|
46
|
+
const parts = document.split(/<main\b[^>]*>|<\/main>/u);
|
|
47
|
+
if (parts.length !== 3) throw new Error("The web document must contain exactly one main element.");
|
|
48
|
+
const title = parts[0].split(/<title\b[^>]*>|<\/title>/u);
|
|
49
|
+
if (title.length !== 3) throw new Error("The web document must contain exactly one title element before main.");
|
|
50
|
+
this.#beforeTitle = title[0];
|
|
51
|
+
const navigation = title[2].split(/<template id="session-navigation"[^>]*>|<\/template>/u);
|
|
52
|
+
if (navigation.length !== 3) throw new Error("The web document must contain exactly one session-navigation template before main.");
|
|
53
|
+
const header = navigation[2].split("</header>");
|
|
54
|
+
if (header.length !== 2) throw new Error("The web document must close its header once after session navigation and before main.");
|
|
55
|
+
this.#beforeMain = [navigation[0], navigation[1], ...header];
|
|
56
|
+
this.#home = parts[1];
|
|
57
|
+
this.#afterMain = parts[2];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {string} title @param {string} body @param {"home" | "daemon" | "workspace" | "session" | "error" | "account"} kind @param {boolean} [authenticated] @param {string} [breadcrumb] @param {string} [footer] @param {string} [dialogs] @param {string} [mainAttributes] */
|
|
61
|
+
#render(title, body, kind, authenticated = false, breadcrumb = this.#breadcrumb(), footer = "", dialogs = "", mainAttributes = "") {
|
|
62
|
+
const [before, navigation, afterNavigation, afterHeader] = this.#beforeMain;
|
|
63
|
+
const label = kind === "home" ? title : `${title} — Plurnk`;
|
|
64
|
+
return Buffer.from(`${this.#beforeTitle}<title data-text="page-title">${escapeHtml(label)}</title>${before}${authenticated ? navigation : ""}${afterNavigation}${breadcrumb}</header>${afterHeader}<main id="main" data-page="${kind}"${mainAttributes}>${body}${dialogs}</main>${footer}${this.#afterMain}`);
|
|
65
|
+
}
|
|
66
|
+
/** @param {boolean} authenticated @param {{ readSessionOverview?: (args: { workspaceId?: number, limit: number, excerptChars: number }) => Promise<DirectoryOverview> }} [port] */
|
|
67
|
+
home(authenticated, port) {
|
|
68
|
+
if (typeof port?.readSessionOverview !== "function") return this.#render("Plurnk", this.#home, "home", authenticated);
|
|
69
|
+
return port.readSessionOverview({ limit: Number.MAX_SAFE_INTEGER, excerptChars: 1 }).then((overview) =>
|
|
70
|
+
this.#render("Plurnk", this.#overviewTree(overview), "home", authenticated, this.#breadcrumb(), "", "", ` data-auth="${authenticated ? "session" : "none"}" data-connection="initial"`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** {§web-page-shell} HTTP failures keep native navigation without disclosing request data.
|
|
74
|
+
* @param {number} status @param {string} message @param {boolean} [authenticated]
|
|
75
|
+
* @param {{ recovery?: string, retry?: string }} [next] What the person can do next, and the form to submit again.
|
|
76
|
+
*/
|
|
77
|
+
error(status, message, authenticated = false, { recovery, retry } = {}) {
|
|
78
|
+
const reason = STATUS_CODES[status];
|
|
79
|
+
if (reason === undefined) throw new Error(`Unknown HTTP status: ${status}`);
|
|
80
|
+
const title = `${status} ${reason}`;
|
|
81
|
+
return this.#render(title, '<article aria-labelledby="error-title"><header><h1 id="error-title">'
|
|
82
|
+
+ `<span aria-hidden="true">⚠️</span> <span data-text="error-title">${escapeHtml(title)}</span></h1></header>`
|
|
83
|
+
+ `<p data-text="error-message">${escapeHtml(message)}</p>`
|
|
84
|
+
+ (recovery === undefined ? "" : `<p data-text="error-recovery">${escapeHtml(recovery)}</p>`)
|
|
85
|
+
+ (retry ?? "")
|
|
86
|
+
+ '<footer><a href="/" data-i18n="home">Home</a></footer></article>', "error", authenticated);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** {§web-workspace-creation} The creation form a refused submission offers again, with its draft and a fresh token. @param {string} name */
|
|
90
|
+
retryCreation(name) { return this.#creationFields(name, ""); }
|
|
91
|
+
|
|
92
|
+
/** {§web-workspace-rename} @param {string} workspace @param {string} name */
|
|
93
|
+
retryRename(workspace, name) { return this.#renameFields(workspace, name, ""); }
|
|
94
|
+
|
|
95
|
+
/** {§web-session-creation} @param {string} workspace @param {string} name */
|
|
96
|
+
retrySession(workspace, name) { return this.#sessionFields(workspace, name, ""); }
|
|
97
|
+
|
|
98
|
+
/** {§web-account} Account navigation stays separate from session management. */
|
|
99
|
+
account() {
|
|
100
|
+
return this.#render("Account", '<article aria-labelledby="account-title"><header><h1 id="account-title" data-i18n="account">Account</h1></header>'
|
|
101
|
+
+ '<nav aria-label="Account"><ul><li><a href="/account/sessions/" data-i18n="sessions">Sessions</a></li></ul></nav></article>', "account", true);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** {§web-account} Only authority-supplied facts are presented as session information.
|
|
105
|
+
* @param {{ family: string, sessions: readonly Pick<import("@plurnk/plurnk-contracts").CredentialSession, "family" | "createdAt" | "expiresAt" | "clients">[] }} account
|
|
106
|
+
*/
|
|
107
|
+
sessions({ family, sessions }) {
|
|
108
|
+
const time = (/** @type {number} */ timestamp) => {
|
|
109
|
+
const iso = new Date(timestamp).toISOString();
|
|
110
|
+
return `<time datetime="${iso}" data-text="session-time">${iso.replace("T", " ").replace(/\.\d{3}Z$/u, " UTC")}</time>`;
|
|
111
|
+
};
|
|
112
|
+
const entries = sessions.map((entry) => {
|
|
113
|
+
const current = entry.family === family;
|
|
114
|
+
const action = current ? "/auth/logout" : `/account/sessions/${encodeURIComponent(entry.family)}/revoke`;
|
|
115
|
+
return `<tr data-session="${escapeHtml(entry.family)}"><th scope="row"><span data-i18n="${current ? "this-session" : "another-session"}">${current ? "This session" : "Another session"}</span>`
|
|
116
|
+
+ `<br><small data-text="session-clients">${entry.clients.map(escapeHtml).join(", ")}</small></th>`
|
|
117
|
+
+ `<td>${time(entry.createdAt)}</td><td>${time(entry.expiresAt)}</td>`
|
|
118
|
+
+ `<td><form method="post" action="${escapeHtml(action)}"><button type="submit" data-i18n="${current ? "end-this-session" : "revoke-session"}">${current ? "End this session" : "Revoke session"}</button></form></td></tr>`;
|
|
119
|
+
}).join("");
|
|
120
|
+
const others = sessions.some((entry) => entry.family !== family)
|
|
121
|
+
? '<form method="post" action="/account/sessions/revoke-others"><button type="submit" data-i18n="revoke-others">Revoke other sessions</button></form>' : "";
|
|
122
|
+
const breadcrumb = this.#breadcrumb([
|
|
123
|
+
'<a href="/account/" data-i18n="account">Account</a>',
|
|
124
|
+
'<span aria-current="page" data-i18n="sessions">Sessions</span>',
|
|
125
|
+
]);
|
|
126
|
+
return this.#render("Sessions", '<article aria-labelledby="sessions-title"><header><h1 id="sessions-title" data-i18n="sessions">Sessions</h1></header>'
|
|
127
|
+
+ '<figure><table><thead><tr><th scope="col" data-i18n="session">Session</th><th scope="col" data-i18n="started">Started</th>'
|
|
128
|
+
+ '<th scope="col" data-i18n="expires">Expires</th><th scope="col" data-i18n="actions">Actions</th></tr></thead>'
|
|
129
|
+
+ `<tbody>${entries}</tbody></table></figure>${others ? `<footer>${others}</footer>` : ""}</article>`, "account", true, breadcrumb);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} label @param {import("./directory-addresses.js").Directory} directory */
|
|
133
|
+
#link(label, directory) {
|
|
134
|
+
const path = directoryPath(directory);
|
|
135
|
+
return path === null ? `<span data-text="name">${escapeHtml(label)}</span>`
|
|
136
|
+
: `<a href="${escapeHtml(path)}" data-text="name"${directory.kind === "session" ? ` data-session-name="${escapeHtml(directory.session)}" data-bind="href:session-path"` : ""}>${escapeHtml(label)}</a>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** {§web-workspace-creation} Native dialog modal for workspace creation; the daemon names an unnamed workspace. */
|
|
140
|
+
#creationForm() {
|
|
141
|
+
return '<dialog id="dialog-workspace-create"><article>'
|
|
142
|
+
+ '<header><p><strong data-i18n="new-workspace">New workspace</strong></p>'
|
|
143
|
+
+ '<button type="button" aria-label="Close" rel="prev" data-dialog-close></button></header>'
|
|
144
|
+
+ this.#creationFields("", CANCEL)
|
|
145
|
+
+ '</article></dialog>';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {string} name @param {string} cancel */
|
|
149
|
+
#creationFields(name, cancel) {
|
|
150
|
+
return '<form id="workspace-create" method="post" action="/workspaces/new">'
|
|
151
|
+
+ `<input type="hidden" name="token" value="${randomUUID()}">`
|
|
152
|
+
+ `<input type="text" name="name" value="${escapeHtml(name)}" placeholder="Workspace name" aria-label="Workspace name" aria-describedby="workspace-name-hint">`
|
|
153
|
+
+ '<small id="workspace-name-hint" data-i18n="workspace-name-hint">Leave it empty and the daemon names the workspace.</small>'
|
|
154
|
+
+ `<footer>${cancel}<button type="submit" data-i18n="confirm">Confirm</button></footer></form>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* {§web-workspace-rename} Native dialog modal for workspace rename with pre-populated current name.
|
|
159
|
+
* @param {{name: string}} workspace
|
|
160
|
+
*/
|
|
161
|
+
#renameForm(workspace) {
|
|
162
|
+
return '<dialog id="dialog-workspace-rename"><article>'
|
|
163
|
+
+ '<header><p><strong data-i18n="rename-workspace">Rename workspace</strong></p>'
|
|
164
|
+
+ '<button type="button" aria-label="Close" rel="prev" data-dialog-close></button></header>'
|
|
165
|
+
+ this.#renameFields(workspace.name, workspace.name, CANCEL)
|
|
166
|
+
+ '</article></dialog>';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {string} workspace @param {string} name @param {string} cancel */
|
|
170
|
+
#renameFields(workspace, name, cancel) {
|
|
171
|
+
return '<form id="workspace-rename" method="post" action="/workspaces/rename">'
|
|
172
|
+
+ `<input type="hidden" name="workspace" value="${escapeHtml(workspace)}">`
|
|
173
|
+
+ `<input type="text" name="name" value="${escapeHtml(name)}" placeholder="New name" aria-label="Rename workspace" required>`
|
|
174
|
+
+ `<footer>${cancel}<button type="submit" data-i18n="confirm">Confirm</button></footer></form>`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* {§web-session-creation} Native dialog modal for session creation; the daemon names an unnamed session.
|
|
179
|
+
* @param {{name: string}} workspace
|
|
180
|
+
*/
|
|
181
|
+
#sessionCreationForm(workspace) {
|
|
182
|
+
return '<dialog id="dialog-session-create"><article>'
|
|
183
|
+
+ '<header><p><strong data-i18n="new-session">New session</strong></p>'
|
|
184
|
+
+ '<button type="button" aria-label="Close" rel="prev" data-dialog-close></button></header>'
|
|
185
|
+
+ this.#sessionFields(workspace.name, "", CANCEL)
|
|
186
|
+
+ '</article></dialog>';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {string} workspace @param {string} name @param {string} cancel */
|
|
190
|
+
#sessionFields(workspace, name, cancel) {
|
|
191
|
+
return '<form id="session-create" method="post" action="/sessions/new">'
|
|
192
|
+
+ `<input type="hidden" name="token" value="${randomUUID()}">`
|
|
193
|
+
+ `<input type="hidden" name="workspace" value="${escapeHtml(workspace)}">`
|
|
194
|
+
+ `<input type="text" name="name" value="${escapeHtml(name)}" placeholder="Session name" aria-label="Session name" aria-describedby="session-name-hint">`
|
|
195
|
+
+ '<small id="session-name-hint" data-i18n="session-name-hint">Leave it empty and the daemon names the session.</small>'
|
|
196
|
+
+ `<footer>${cancel}<button type="submit" data-i18n="confirm">Confirm</button></footer></form>`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* {§web-workspace-archive} Native dialog modal to archive the workspace.
|
|
200
|
+
* @param {{name: string}} workspace
|
|
201
|
+
*/
|
|
202
|
+
#workspaceArchiveForm(workspace) {
|
|
203
|
+
return '<dialog id="dialog-workspace-archive"><article>'
|
|
204
|
+
+ '<header><p><strong data-i18n="archive-workspace">Archive workspace</strong></p>'
|
|
205
|
+
+ '<button type="button" aria-label="Close" rel="prev" data-dialog-close></button></header>'
|
|
206
|
+
+ this.#workspaceArchiveFields(workspace.name, CANCEL)
|
|
207
|
+
+ '</article></dialog>';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @param {string} workspace @param {string} cancel */
|
|
211
|
+
#workspaceArchiveFields(workspace, cancel) {
|
|
212
|
+
return '<form id="workspace-archive" method="post" action="/workspaces/archive">'
|
|
213
|
+
+ `<input type="hidden" name="workspace" value="${escapeHtml(workspace)}">`
|
|
214
|
+
+ '<p data-i18n="archive-workspace-detail">Archiving removes the workspace from directory lists while keeping its files and history intact.</p>'
|
|
215
|
+
+ `<footer>${cancel}<button type="submit" data-i18n="confirm">Confirm</button></footer></form>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* {§web-session-archive} Native dialog modal to archive the session and its subtree.
|
|
220
|
+
* @param {{name: string}} workspace
|
|
221
|
+
* @param {{name: string}} session
|
|
222
|
+
*/
|
|
223
|
+
#sessionArchiveForm(workspace, session) {
|
|
224
|
+
return '<dialog id="dialog-session-archive"><article>'
|
|
225
|
+
+ '<header><p><strong data-i18n="archive-session">Archive session</strong></p>'
|
|
226
|
+
+ '<button type="button" aria-label="Close" rel="prev" data-dialog-close></button></header>'
|
|
227
|
+
+ this.#sessionArchiveFields(workspace.name, session.name, CANCEL)
|
|
228
|
+
+ '</article></dialog>';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {string} workspace @param {string} session @param {string} cancel */
|
|
232
|
+
#sessionArchiveFields(workspace, session, cancel) {
|
|
233
|
+
return '<form id="session-archive" method="post" action="/sessions/archive">'
|
|
234
|
+
+ `<input type="hidden" name="workspace" value="${escapeHtml(workspace)}">`
|
|
235
|
+
+ `<input type="hidden" name="session" value="${escapeHtml(session)}">`
|
|
236
|
+
+ '<p data-i18n="archive-session-detail">Archiving removes the session and its descendants from directory overviews without destroying history.</p>'
|
|
237
|
+
+ `<footer>${cancel}<button type="submit" data-i18n="confirm">Confirm</button></footer></form>`;
|
|
238
|
+
}
|
|
239
|
+
/** @param {string | null} projectRoot */
|
|
240
|
+
#workspaceMarker(projectRoot) {
|
|
241
|
+
const folder = projectRoot !== null;
|
|
242
|
+
return `<span role="img" aria-label="${folder ? "Project workspace" : "Folderless workspace"}">${folder ? "🗂️" : "☁️"}</span>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @param {string[]} [items] @param {boolean} [current] */
|
|
246
|
+
#breadcrumb(items = [], current = false) {
|
|
247
|
+
const root = current
|
|
248
|
+
? '<span role="img" aria-current="page" aria-label="Workspaces" title="Workspaces">🗃️</span> <button type="button" data-dialog-target="dialog-workspace-create" aria-label="New workspace" title="New workspace">➕</button>'
|
|
249
|
+
: '<a href="/~/" aria-label="Workspaces" title="Workspaces">🗃️</a>';
|
|
250
|
+
return `<nav aria-label="breadcrumb"><ul>${[root, ...items].map((item) => `<li>${item}</li>`).join("")}</ul></nav>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @param {import("./directory-addresses.js").Directory} directory @param {string} marker */
|
|
254
|
+
#breadcrumbs(directory, marker) {
|
|
255
|
+
const current = (/** @type {string} */ name) => `<span aria-current="page" data-text="name">${escapeHtml(name)}</span>`;
|
|
256
|
+
const items = [];
|
|
257
|
+
if (directory.kind === "workspace") {
|
|
258
|
+
items.push(`${marker} ${current(directory.workspace)} <button type="button" data-dialog-target="dialog-workspace-rename" aria-label="Rename workspace" title="Rename workspace">✏️</button> <button type="button" data-dialog-target="dialog-workspace-archive" aria-label="Archive workspace" title="Archive workspace">🗑️</button> <button type="button" data-dialog-target="dialog-session-create" aria-label="New session" title="New session">➕</button>`);
|
|
259
|
+
}
|
|
260
|
+
if (directory.kind === "session") {
|
|
261
|
+
const path = directoryPath({ kind: "workspace", workspace: directory.workspace });
|
|
262
|
+
if (path === null) throw new Error("An admitted session must have a workspace directory address.");
|
|
263
|
+
items.push(`<span id="session-workspace">${marker} <a href="${escapeHtml(path)}" data-bind="href:workspace-path" data-text="workspace-name">${escapeHtml(directory.workspace)}</a></span>`, `${current(directory.session)} <button type="button" data-dialog-target="dialog-session-archive" aria-label="Archive session" title="Archive session">🗑️</button>`);
|
|
264
|
+
}
|
|
265
|
+
return this.#breadcrumb(items, directory.kind === "daemon");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** {§web-session-directory}
|
|
269
|
+
* @param {DirectorySession} session
|
|
270
|
+
* @param {ReadonlyMap<number, DirectorySession>} sessions
|
|
271
|
+
*/
|
|
272
|
+
#parent(session, sessions) {
|
|
273
|
+
if (session.parentSessionId === null) return null;
|
|
274
|
+
const parent = sessions.get(session.parentSessionId);
|
|
275
|
+
if (parent === undefined) throw new Error(`Session ${session.id} has an absent parent ${session.parentSessionId}.`);
|
|
276
|
+
return parent;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** {§web-session-directory} The same identity is a leaf header or a native disclosure summary.
|
|
280
|
+
* @param {{name: string, project_root: string | null}} workspace
|
|
281
|
+
* @param {DirectorySession} session
|
|
282
|
+
* @param {DirectorySession | null} parent
|
|
283
|
+
*/
|
|
284
|
+
#sessionIdentity(workspace, session, parent) {
|
|
285
|
+
const path = directoryPath({ kind: "session", workspace: workspace.name, session: session.name });
|
|
286
|
+
const name = escapeHtml(session.name);
|
|
287
|
+
const ant = path === null ? '<span data-session-link role="img" aria-label="Session"></span>'
|
|
288
|
+
: `<a data-session-link href="${escapeHtml(path)}" aria-label="${name}" title="Open session ${name}"></a>`;
|
|
289
|
+
const parentName = parent === null ? "" : parent.origin === "model"
|
|
290
|
+
? this.#link(parent.name, { kind: "session", workspace: workspace.name, session: parent.name })
|
|
291
|
+
: `<span>${escapeHtml(parent.name)}</span>`;
|
|
292
|
+
const lineage = parent === null ? "" : `<span data-session-parent>${parentName} > </span>`;
|
|
293
|
+
const lifecycle = escapeHtml(session.lifecycle);
|
|
294
|
+
const folder = workspace.project_root !== null;
|
|
295
|
+
return `<span data-session-identity>${ant} <span data-lifecycle="${lifecycle}" role="img" aria-label="${lifecycle}" title="${lifecycle}"></span> `
|
|
296
|
+
+ `${lineage}<span>${name}</span></span>`
|
|
297
|
+
+ `<span data-session-workspace><span data-folder="${folder}" role="img" aria-label="${folder ? "Project workspace" : "Folderless workspace"}"></span> `
|
|
298
|
+
+ `${this.#link(workspace.name, { kind: "workspace", workspace: workspace.name })}</span>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** {§web-session-directory} A finished reply adds a disclosure body outlet addressed at its ACP entry resource.
|
|
302
|
+
* @param {number} workspaceId
|
|
303
|
+
* @param {DirectorySession} session
|
|
304
|
+
*/
|
|
305
|
+
#replyOutlet(workspaceId, session) {
|
|
306
|
+
const reply = session.reply ?? null;
|
|
307
|
+
if (reply === null) return "";
|
|
308
|
+
const target = encodeURIComponent(`log:///${reply.coordinate}`);
|
|
309
|
+
return `<div data-session-reply="/acp/${workspaceId}/${session.id}/entry?target=${escapeHtml(target)}"></div>`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** {§web-session-directory}
|
|
313
|
+
* @param {ReadonlyMap<number, { readonly id: number, readonly name: string, readonly project_root: string | null }>} workspaces
|
|
314
|
+
* @param {ReadonlyMap<number, DirectorySession & { readonly workspaceId: number }>} sessions
|
|
315
|
+
*/
|
|
316
|
+
#sessionTree(workspaces, sessions) {
|
|
317
|
+
// {§web-directory-documents} An archived session leaves the tree; its page still opens by its address.
|
|
318
|
+
const models = [...sessions.values()]
|
|
319
|
+
.filter(({ origin, archived_at }) => origin === "model" && archived_at === null)
|
|
320
|
+
.toSorted(orderByReply);
|
|
321
|
+
if (models.length === 0) return '<p data-i18n="no-sessions">No sessions.</p>';
|
|
322
|
+
const children = Map.groupBy(models, ({ parentSessionId }) => parentSessionId);
|
|
323
|
+
const background = models.filter((session) => {
|
|
324
|
+
const parent = this.#parent(session, sessions);
|
|
325
|
+
return parent !== null && parent.origin !== "model";
|
|
326
|
+
});
|
|
327
|
+
let rendered = 0;
|
|
328
|
+
/** @param {readonly (DirectorySession & { readonly workspaceId: number })[]} entries @returns {string} */
|
|
329
|
+
const list = (entries) => entries.length === 0 ? ""
|
|
330
|
+
: `<ul>${entries.map((session) => {
|
|
331
|
+
rendered++;
|
|
332
|
+
const workspace = workspaces.get(session.workspaceId);
|
|
333
|
+
if (workspace === undefined) throw new Error(`Session ${session.id} belongs to an absent workspace ${session.workspaceId}.`);
|
|
334
|
+
const descendants = children.get(session.id) ?? [];
|
|
335
|
+
const identity = this.#sessionIdentity(workspace, session, this.#parent(session, sessions));
|
|
336
|
+
const reply = this.#replyOutlet(workspace.id, session);
|
|
337
|
+
const content = descendants.length === 0 && reply === "" ? `<header>${identity}</header>`
|
|
338
|
+
: `<details><summary>${identity}</summary>${list(descendants)}${reply}</details>`;
|
|
339
|
+
return `<li data-directory-entry="session"><article aria-label="Session">${content}</article></li>`;
|
|
340
|
+
}).join("")}</ul>`;
|
|
341
|
+
const roots = list(children.get(null) ?? []);
|
|
342
|
+
const other = background.length === 0 ? "" : '<details><summary data-i18n="background-work">Background work</summary>'
|
|
343
|
+
+ `${list(background)}</details>`;
|
|
344
|
+
if (rendered !== models.length) throw new Error("Session directory holds shown model sessions that no shown root reaches.");
|
|
345
|
+
return roots + other;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** {§web-session-directory}
|
|
349
|
+
* @param {DirectoryOverview} overview
|
|
350
|
+
*/
|
|
351
|
+
#overviewTree(overview) {
|
|
352
|
+
const workspaces = new Map(overview.workspaces.map(({ id, name, projectRoot }) => [id, { id, name, project_root: projectRoot }]));
|
|
353
|
+
/** @type {Map<number, DirectorySession & { readonly workspaceId: number }>} */
|
|
354
|
+
const byId = new Map(overview.sessions.map((session) => [session.id, {
|
|
355
|
+
id: session.id,
|
|
356
|
+
workspaceId: session.workspaceId,
|
|
357
|
+
name: session.name,
|
|
358
|
+
parentSessionId: session.parentSessionId,
|
|
359
|
+
origin: "model",
|
|
360
|
+
lifecycle: session.lifecycle,
|
|
361
|
+
archived_at: null,
|
|
362
|
+
reply: session.reply,
|
|
363
|
+
}]));
|
|
364
|
+
if (byId.size !== overview.sessions.length) throw new Error("Session directory contains duplicate identities.");
|
|
365
|
+
for (const session of overview.sessions) {
|
|
366
|
+
if (session.parentSessionId !== null && !byId.has(session.parentSessionId)) {
|
|
367
|
+
byId.set(session.parentSessionId, {
|
|
368
|
+
id: session.parentSessionId,
|
|
369
|
+
workspaceId: session.workspaceId,
|
|
370
|
+
name: "_plurnk",
|
|
371
|
+
parentSessionId: null,
|
|
372
|
+
origin: "_plurnk",
|
|
373
|
+
lifecycle: "idle",
|
|
374
|
+
archived_at: null,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return this.#sessionTree(workspaces, byId);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** {§web-session-observer} Identity and navigation belong to the document; status arrives through ACP.
|
|
382
|
+
* @param {import("./directory-addresses.js").Directory & {kind: "session"}} directory @param {number} workspaceId @param {boolean} authenticated
|
|
383
|
+
* @param {boolean} automatic
|
|
384
|
+
*/
|
|
385
|
+
#sessionStatus(directory, workspaceId, authenticated, automatic) {
|
|
386
|
+
const path = directoryPath(directory);
|
|
387
|
+
if (path === null) throw new Error("An admitted session must have a directory address.");
|
|
388
|
+
const footer = `<footer id="session-status" aria-label="Session status" data-session-id="${workspaceId}#${escapeHtml(directory.session)}"`
|
|
389
|
+
+ ` data-session-name="${escapeHtml(directory.session)}" data-auth="${authenticated ? "session" : "none"}" data-reasoning-preview-chars="${this.#reasoningPreviewChars}" data-connection="initial">`
|
|
390
|
+
+ '<p role="status" data-text="connection-label">Not connected.</p><noscript><p data-i18n="live-status-needs-js">Live status requires JavaScript.</p></noscript>'
|
|
391
|
+
+ '<div data-status-line><ul aria-label="Model controls">'
|
|
392
|
+
+ `<li data-status-field="yolo"><button type="button" id="session-yolo" aria-pressed="${automatic}" aria-label="Automatic approval" title="${automatic ? "Auto" : "Manual"} Approval (/yolo)"></button></li>`
|
|
393
|
+
+ '<li data-status-field="model" data-bind="aria-label:model-label title:model-label"><button type="button" id="session-model" aria-label="Model" aria-expanded="false" aria-controls="session-model-settings"><span data-text="model">No model</span></button><button type="button" id="session-effort" aria-label="Effort" aria-expanded="false" aria-controls="session-effort-settings"><span data-text="effort"></span></button></li></ul>'
|
|
394
|
+
+ '<ul aria-label="Session statistics"><li data-status-field="lifecycle" data-bind="aria-label:lifecycle title:lifecycle"></li>'
|
|
395
|
+
+ '<li data-status-field="elapsed" aria-label="Latest loop elapsed time" title="Latest loop elapsed time"><time data-text="elapsed"></time></li>'
|
|
396
|
+
+ '<li data-status-field="usage" data-bind="aria-label:usage-label title:usage-label"><span data-text="input">?</span> <span data-text="output">?</span></li>'
|
|
397
|
+
+ '<li data-status-field="context" data-bind="aria-label:context-label title:context-label"><span data-text="context"></span></li>'
|
|
398
|
+
+ '<li data-status-field="descendants"><details id="session-descendants"><summary data-bind="aria-label:descendants-label title:descendants-label"><span data-text="descendants">0</span></summary><ul></ul></details></li>'
|
|
399
|
+
+ '<li data-status-field="gated" data-bind="aria-label:gated-label title:gated-label"><span data-text="gated">0</span></li>'
|
|
400
|
+
+ '<li data-status-field="wait"><span data-i18n="wait-remaining">updates in</span> <time data-text="wait" data-bind="datetime:wait-at title:wait-at"></time></li>'
|
|
401
|
+
+ '<li data-status-field="inbox" data-bind="aria-label:inbox-label title:inbox-label"><span data-text="inbox">0</span></li>'
|
|
402
|
+
+ '<li data-status-field="preparation" data-text="preparation"></li>'
|
|
403
|
+
+ '<li data-status-field="activity" data-text="activity" data-bind="title:activity-message"></li>'
|
|
404
|
+
+ '<li data-status-field="cost" data-text="cost" data-bind="aria-label:cost-label title:cost-label">?</li></ul></div></footer>';
|
|
405
|
+
const content = '<article id="session-connection" role="alert"><header><span aria-hidden="true">⚠️</span> <span data-text="connection-label">Not connected.</span></header>'
|
|
406
|
+
+ '<p data-text="connection-detail">Displayed values are no longer live.</p>'
|
|
407
|
+
+ `<form id="session-reconnect" action="${escapeHtml(path)}" data-bind="action:session-path" data-reconnect><button type="submit" data-i18n="reconnect">Reconnect</button></form><p>`
|
|
408
|
+
+ `<a data-sign-in href="${escapeHtml(`/auth/login?return=${encodeURIComponent(path)}`)}" data-bind="href:sign-in-path" data-i18n="sign-in">Sign in</a>`
|
|
409
|
+
+ '</p></article>'
|
|
410
|
+
+ '<details id="session-streams" data-stream-outcomes><summary data-i18n="stream-outcomes">Observed stream outcomes</summary>'
|
|
411
|
+
+ '<p data-i18n="stream-outcomes-scope">Conclusions observed on this page, not a complete execution history.</p><ul></ul></details>'
|
|
412
|
+
+ '<section id="session-diagnostics" aria-label="Observed diagnostics"><p data-i18n="observed-diagnostics">Observed diagnostics</p><ul></ul></section>'
|
|
413
|
+
+ '<details id="session-activity"><summary data-i18n="observed-activity">Observed activity</summary>'
|
|
414
|
+
+ '<p data-i18n="activity-scope">Non-conversation evidence observed on this page, not a complete history.</p><ul></ul></details>'
|
|
415
|
+
+ '<section id="session-conversation" aria-label="Conversation">'
|
|
416
|
+
+ '<p data-empty-conversation data-i18n="no-messages">No messages.</p>'
|
|
417
|
+
+ '<noscript><p data-i18n="conversation-needs-js">Conversation requires JavaScript.</p></noscript>'
|
|
418
|
+
+ '<ul data-messages></ul></section>';
|
|
419
|
+
return { content, footer };
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* @param {import("./directory-addresses.js").Directory} directory
|
|
423
|
+
* @param {{ listWorkspaces(): Promise<readonly DirectoryWorkspace[]>, listSessions(workspaceId: number): Promise<readonly DirectorySession[]>, readSessionOverview?: (args: { workspaceId?: number, limit: number, excerptChars: number }) => Promise<DirectoryOverview> }} port
|
|
424
|
+
* @param {boolean} authenticated
|
|
425
|
+
* @param {boolean} [own] {§web-session-approval} whether the person reached the page by their own navigation; only then
|
|
426
|
+
* does a session page start with the configured automatic approval
|
|
427
|
+
*/
|
|
428
|
+
async read(directory, port, authenticated, own = false) {
|
|
429
|
+
const render = (/** @type {string} */ title, /** @type {string} */ body, marker = "", footer = "", dialogs = "", mainAttributes = "") =>
|
|
430
|
+
this.#render(title, body, directory.kind, authenticated, this.#breadcrumbs(directory, marker), footer, dialogs, mainAttributes);
|
|
431
|
+
const workspaces = await port.listWorkspaces();
|
|
432
|
+
if (directory.kind === "daemon") {
|
|
433
|
+
// {§web-directory-documents} An archived workspace leaves the list; its page still opens by its address.
|
|
434
|
+
const items = workspaces.filter(({ archived_at }) => archived_at === null).map(({ name, project_root }) =>
|
|
435
|
+
`<li data-directory-entry="workspace" data-folder="${project_root !== null}"><article aria-label="${project_root === null ? "Folderless workspace" : "Project workspace"}"><header>${this.#link(name, { kind: "workspace", workspace: name })}</header></article></li>`).join("");
|
|
436
|
+
const list = items ? `<ul>${items}</ul>` : '<p data-i18n="no-workspaces">No workspaces.</p>';
|
|
437
|
+
return render("Workspaces", list, "", "", this.#creationForm());
|
|
438
|
+
}
|
|
439
|
+
const workspace = workspaces.find(({ name }) => name === directory.workspace);
|
|
440
|
+
if (workspace === undefined) return null;
|
|
441
|
+
const sessions = await port.listSessions(workspace.id);
|
|
442
|
+
const byId = new Map(sessions.map((session) => [session.id, session]));
|
|
443
|
+
if (byId.size !== sessions.length) throw new Error("Session directory contains duplicate identities.");
|
|
444
|
+
if (directory.kind === "workspace") {
|
|
445
|
+
// {§web-workspace-folder}
|
|
446
|
+
const separateFolder = workspace.project_label !== null && workspace.project_label !== workspace.name;
|
|
447
|
+
const folder = separateFolder ? '<p><span role="img" aria-label="Project folder">🗂️</span> '
|
|
448
|
+
+ `<span data-text="project-label">${escapeHtml(workspace.project_label)}</span></p>` : "";
|
|
449
|
+
const marker = separateFolder ? '<span role="img" aria-label="Workspace">☁️</span>' : this.#workspaceMarker(workspace.project_root);
|
|
450
|
+
const dialogs = this.#renameForm(workspace) + this.#workspaceArchiveForm(workspace) + this.#sessionCreationForm(workspace);
|
|
451
|
+
const live = typeof port.readSessionOverview === "function";
|
|
452
|
+
const overview = typeof port.readSessionOverview === "function"
|
|
453
|
+
? await port.readSessionOverview({ workspaceId: workspace.id, limit: Number.MAX_SAFE_INTEGER, excerptChars: 1 })
|
|
454
|
+
: null;
|
|
455
|
+
const replies = new Map(overview?.sessions.map((entry) => [entry.id, entry.reply]) ?? []);
|
|
456
|
+
const treeSessions = new Map(sessions.map((entry) => [entry.id, {
|
|
457
|
+
...entry,
|
|
458
|
+
workspaceId: workspace.id,
|
|
459
|
+
reply: replies.get(entry.id) ?? entry.reply ?? null,
|
|
460
|
+
}]));
|
|
461
|
+
const mainAttributes = live ? ` data-workspace-id="${workspace.id}" data-auth="${authenticated ? "session" : "none"}" data-connection="initial"` : "";
|
|
462
|
+
return render(workspace.name, folder + this.#sessionTree(new Map([[workspace.id, workspace]]), treeSessions), marker, "", dialogs, mainAttributes);
|
|
463
|
+
}
|
|
464
|
+
const session = sessions.find(({ name, origin }) => name === directory.session && origin === "model");
|
|
465
|
+
if (session === undefined) return null;
|
|
466
|
+
if (this.#yolo === null || this.#reasoningPreviewChars === null) {
|
|
467
|
+
throw new RequestError(503, "Session pages are unavailable because their configuration is invalid.");
|
|
468
|
+
}
|
|
469
|
+
const parent = this.#parent(session, byId);
|
|
470
|
+
const label = parent === null ? "" : parent.origin === "model"
|
|
471
|
+
? this.#link(parent.name, { kind: "session", workspace: workspace.name, session: parent.name })
|
|
472
|
+
: `<span data-text="name">${escapeHtml(parent.name)}</span>`;
|
|
473
|
+
const title = `${workspace.name}@${directory.session}`;
|
|
474
|
+
const folder = workspace.project_label !== null && workspace.project_label !== workspace.name ? workspace.project_label : "";
|
|
475
|
+
const iconLabel = workspace.project_label === null ? "Folderless workspace" : folder ? "Workspace" : "Project workspace";
|
|
476
|
+
const marker = `<span role="img" aria-label="${iconLabel}" data-bind="aria-label:workspace-icon-label" data-text="workspace-icon">${workspace.project_label !== null && !folder ? "🗂️" : "☁️"}</span>`;
|
|
477
|
+
const status = this.#sessionStatus(directory, workspace.id, authenticated, this.#yolo && own);
|
|
478
|
+
const dialogs = this.#sessionArchiveForm(workspace, session);
|
|
479
|
+
return render(title, `<p id="session-folder" data-project-label="${escapeHtml(folder)}"><span role="img" aria-label="Project folder">🗂️</span> <span data-text="project-label">${escapeHtml(folder)}</span></p>`
|
|
480
|
+
+ (parent === null ? "" : `<p><span data-i18n="parent-session">Parent:</span> ${label}</p>`)
|
|
481
|
+
+ status.content, marker, status.footer, dialogs);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Problems } from "@plurnk/plurnk-contracts";
|
|
2
|
+
|
|
3
|
+
/** @param {string} value */
|
|
4
|
+
export const escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<")
|
|
5
|
+
.replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
6
|
+
|
|
7
|
+
export class RequestError extends Error {
|
|
8
|
+
/** @param {number} status @param {string} message @param {ErrorOptions & { recovery?: string, retry?: string }} [options] */
|
|
9
|
+
constructor(status, message, { recovery, retry, ...options } = {}) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.status = status;
|
|
12
|
+
/** What the person can do next, as the daemon words it. */
|
|
13
|
+
this.recovery = recovery;
|
|
14
|
+
/** The form to submit again, holding the person's draft. */
|
|
15
|
+
this.retry = retry;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* {§web-workspace-creation} A daemon refusal answers with the daemon's own status, detail and recovery; a daemon that
|
|
21
|
+
* failed is a 502. Either way the person's draft comes back in `retry`.
|
|
22
|
+
* @param {unknown} cause @param {string} failed @param {string} [retry]
|
|
23
|
+
*/
|
|
24
|
+
export function refusal(cause, failed, retry = undefined) {
|
|
25
|
+
const problem = Problems.fromError(cause);
|
|
26
|
+
if (problem === null || problem.status < 400 || problem.status >= 500) return new RequestError(502, failed, { cause, retry });
|
|
27
|
+
const recovery = typeof problem.recovery === "string" ? problem.recovery : undefined;
|
|
28
|
+
return new RequestError(problem.status, problem.detail ?? problem.title, { cause, recovery, retry });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {import("@plurnk/plurnk-contracts").RequestHeaders} req @param {string} name */
|
|
32
|
+
export function cookie(req, name) {
|
|
33
|
+
const raw = req.headers.cookie;
|
|
34
|
+
const header = typeof raw === "string" ? raw : "";
|
|
35
|
+
const values = header.split(";").map((part) => part.trim())
|
|
36
|
+
.filter((part) => part.startsWith(`${name}=`));
|
|
37
|
+
return values.length === 1 ? values[0].slice(name.length + 1) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// {§web-directory-documents} Dynamic replies contain no cacheable workspace or sign-in evidence.
|
|
41
|
+
export const privateHeaders = {
|
|
42
|
+
"cache-control": "no-store",
|
|
43
|
+
"referrer-policy": "no-referrer",
|
|
44
|
+
"x-content-type-options": "nosniff",
|
|
45
|
+
"content-security-policy": "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
|
46
|
+
"x-frame-options": "DENY",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** {§web-workspace-creation} A browser that reports where a request came from must report this origin, in either posture.
|
|
50
|
+
* @param {import("@plurnk/plurnk-contracts").RequestHeaders} req
|
|
51
|
+
*/
|
|
52
|
+
export function sameOriginFetch(req) {
|
|
53
|
+
const site = req.headers["sec-fetch-site"];
|
|
54
|
+
if (site !== undefined && site !== "same-origin") throw new RequestError(403, "Same-origin form required.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** {§web-session-approval} The person reached this page from this origin or through the browser itself (address bar, bookmark),
|
|
58
|
+
* by its Fetch Metadata; another site, a sibling site or no metadata is not their own navigation.
|
|
59
|
+
* @param {import("@plurnk/plurnk-contracts").RequestHeaders} req
|
|
60
|
+
*/
|
|
61
|
+
export const ownNavigation = (req) => req.headers["sec-fetch-site"] === "same-origin" || req.headers["sec-fetch-site"] === "none";
|
|
62
|
+
|
|
63
|
+
/** @typedef {{ setHeader(name: string, value: string | readonly string[]): unknown, writeHead(statusCode: number, headers?: import("node:http").OutgoingHttpHeaders): unknown, end(chunk?: unknown): unknown }} HttpResponse */
|
|
64
|
+
|
|
65
|
+
/** @param {Pick<HttpResponse, "writeHead" | "end">} res @param {string} location */
|
|
66
|
+
export function redirect(res, location) {
|
|
67
|
+
res.writeHead(303, { ...privateHeaders, location });
|
|
68
|
+
res.end();
|
|
69
|
+
}
|