slidemind 0.3.19 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/engine-slides.mjs +1 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
// The workspace API (contracts §3) on server.mjs's `handle` seam.
|
|
2
|
+
//
|
|
3
|
+
// Every deck route resolves a REGISTRY id — a path never travels in a URL.
|
|
4
|
+
// Reads need a credential (CLI bearer or browser cookie); mutations also need
|
|
5
|
+
// the CSRF token plus same-origin evidence (auth.mjs). Bodies are JSON ≤ 2 MiB.
|
|
6
|
+
// Network work answers 202 + a job id; nothing here blocks a save on the wire.
|
|
7
|
+
//
|
|
8
|
+
// Seams the other lanes fill (`deps`, each lazily imported when absent):
|
|
9
|
+
// tasks src/workspace/tasks.mjs listTasks / requestTask / getTask / respondTask
|
|
10
|
+
// edit src/workspace/edit.mjs savePlanLocal
|
|
11
|
+
// remote the wave-3 lanes replace the 501 placeholders below
|
|
12
|
+
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { previewBuiltFrom } from "../builders/build.mjs";
|
|
20
|
+
import { MIME } from "../builders/gate-server.mjs";
|
|
21
|
+
import { isEditRouteDeck } from "../builders/edit-route.mjs";
|
|
22
|
+
import { OVERLAY_PATH as REVIEW_OVERLAY_PATH, brandAssetsDirForDeck, injectReview, loadReviewState, normalizeReviewComments, previewHash, reviewLogoFile, routeBrandAssets } from "../builders/review.mjs";
|
|
23
|
+
import { splitPlanFrontmatter } from "../builders/resolve-skill.mjs";
|
|
24
|
+
import { deckFormat, planPageOwner, previewPath, resolveWorkspace } from "../lib/deck-paths.mjs";
|
|
25
|
+
import { pidAlive, readBusy } from "../lib/revision.mjs";
|
|
26
|
+
import { dispatchAdapter, resolveWorkspaceAdapter } from "./adapters.mjs";
|
|
27
|
+
import { deriveTaskSubject, invokeTaskAction, respondTaskAction, taskActionsFor, taskActor, taskEvidenceVerifier } from "./task-actions.mjs";
|
|
28
|
+
import { readJob, skip as skipDir, startDiscovery } from "./discovery.mjs";
|
|
29
|
+
import { openFile as defaultOpenFile, pickFolder as defaultPickFolder, revealFolder as defaultRevealFolder, trashFolder as defaultTrashFolder } from "./pick-folder.mjs";
|
|
30
|
+
import { events as defaultEvents } from "./events.mjs";
|
|
31
|
+
import { pasteMessage, receiveCommand, viewOf } from "./handback.mjs";
|
|
32
|
+
import { readOperationIntent, runDeckOperation } from "./operations.mjs";
|
|
33
|
+
// The async twins, not the spinning ones: this server has one event loop and a
|
|
34
|
+
// synchronous wait for the registry lock stalls SSE and every long poll with it.
|
|
35
|
+
import { addDeckAsync, getDeck, listDecks, listRoots, removeDeckAsync } from "./registry.mjs";
|
|
36
|
+
import { sendError, sendJson } from "./server.mjs";
|
|
37
|
+
|
|
38
|
+
export const PREFIX = "/api/v1";
|
|
39
|
+
export const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
40
|
+
export const UI_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
|
|
41
|
+
|
|
42
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
43
|
+
const ACTION_METHOD = { "plan.validate": "validate", "workbook.import": "import-workbook" };
|
|
44
|
+
const STATUS = {
|
|
45
|
+
unauthorized: 401,
|
|
46
|
+
csrf_required: 403,
|
|
47
|
+
cross_origin: 403,
|
|
48
|
+
outside_roots: 403,
|
|
49
|
+
unknown_deck: 404,
|
|
50
|
+
unknown_task: 404,
|
|
51
|
+
unknown_job: 404,
|
|
52
|
+
not_found: 404,
|
|
53
|
+
missing_path: 404,
|
|
54
|
+
method_not_allowed: 405,
|
|
55
|
+
deck_busy: 409,
|
|
56
|
+
local_conflict: 409,
|
|
57
|
+
remote_conflict: 409,
|
|
58
|
+
stale_subject: 409,
|
|
59
|
+
already_answered: 409,
|
|
60
|
+
operation_mismatch: 409,
|
|
61
|
+
registry_busy: 409,
|
|
62
|
+
path_registered: 409,
|
|
63
|
+
payload_too_large: 413,
|
|
64
|
+
bad_request: 400,
|
|
65
|
+
bad_job_id: 400,
|
|
66
|
+
not_a_deck: 422,
|
|
67
|
+
unsupported_format: 409,
|
|
68
|
+
validation_failed: 422,
|
|
69
|
+
extension_missing: 424,
|
|
70
|
+
extension_incompatible: 424,
|
|
71
|
+
approval_requires_sync: 409,
|
|
72
|
+
approval_in_progress: 409,
|
|
73
|
+
conflict_requires_resolve: 409,
|
|
74
|
+
outcome_unknown: 409,
|
|
75
|
+
recovery_required: 423,
|
|
76
|
+
not_implemented: 501,
|
|
77
|
+
// remote association (thrown by remote-links / remote-status outside a job)
|
|
78
|
+
selection_expired: 410,
|
|
79
|
+
name_exists: 409,
|
|
80
|
+
cross_tenant: 409,
|
|
81
|
+
dirty_target: 409,
|
|
82
|
+
already_bound: 409,
|
|
83
|
+
backend_ambiguous: 409,
|
|
84
|
+
login_required: 401,
|
|
85
|
+
access_denied: 403,
|
|
86
|
+
remote_missing: 404,
|
|
87
|
+
not_a_folder: 422,
|
|
88
|
+
unsupported_link: 422,
|
|
89
|
+
trash_unavailable: 422,
|
|
90
|
+
offline: 503,
|
|
91
|
+
};
|
|
92
|
+
const RETRYABLE = new Set(["deck_busy", "registry_busy"]);
|
|
93
|
+
|
|
94
|
+
export class RouteError extends Error {
|
|
95
|
+
constructor(code, message, details) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.code = code;
|
|
98
|
+
this.details = details;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const fail = (code, message, details) => new RouteError(code, message, details);
|
|
102
|
+
|
|
103
|
+
/** One JSON body, ≤ MAX_BODY_BYTES, or a typed failure. */
|
|
104
|
+
export function readJson(req, limit = MAX_BODY_BYTES) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const declared = Number(req.headers["content-length"] ?? 0);
|
|
107
|
+
if (declared > limit) {
|
|
108
|
+
req.resume();
|
|
109
|
+
return reject(fail("payload_too_large", `body exceeds ${limit} bytes`));
|
|
110
|
+
}
|
|
111
|
+
const chunks = [];
|
|
112
|
+
let bytes = 0;
|
|
113
|
+
req.on("data", (c) => {
|
|
114
|
+
bytes += c.length;
|
|
115
|
+
if (bytes > limit) {
|
|
116
|
+
req.destroy();
|
|
117
|
+
reject(fail("payload_too_large", `body exceeds ${limit} bytes`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
chunks.push(c);
|
|
121
|
+
});
|
|
122
|
+
req.on("error", reject);
|
|
123
|
+
req.on("end", () => {
|
|
124
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
125
|
+
if (!raw.trim()) return resolve({});
|
|
126
|
+
try {
|
|
127
|
+
const v = JSON.parse(raw);
|
|
128
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
|
|
129
|
+
resolve(v);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
reject(fail("bad_request", `invalid JSON body: ${err.message}`));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sha256 = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
138
|
+
|
|
139
|
+
/** A file under `root`, by realpath containment; false when it is not one. */
|
|
140
|
+
export function serveContained(root, rel, res) {
|
|
141
|
+
let base;
|
|
142
|
+
try {
|
|
143
|
+
base = fs.realpathSync(root);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const candidate = path.resolve(base, String(rel).replace(/^\/+/, ""));
|
|
148
|
+
let real;
|
|
149
|
+
try {
|
|
150
|
+
real = fs.realpathSync(candidate);
|
|
151
|
+
if (!fs.statSync(real).isFile()) return false;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (real !== base && !real.startsWith(base + path.sep)) return false;
|
|
156
|
+
res.writeHead(200, { "Content-Type": MIME[path.extname(real).toLowerCase()] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
157
|
+
res.end(fs.readFileSync(real));
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lazy = (spec) => {
|
|
162
|
+
let mod;
|
|
163
|
+
return async () => {
|
|
164
|
+
if (mod !== undefined) return mod;
|
|
165
|
+
try {
|
|
166
|
+
mod = await import(spec);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err?.code !== "ERR_MODULE_NOT_FOUND") throw err;
|
|
169
|
+
mod = null;
|
|
170
|
+
}
|
|
171
|
+
return mod;
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
function resolveDeck(id) {
|
|
176
|
+
if (!UUID.test(String(id))) throw fail("unknown_deck", "deck ids are registry UUIDs");
|
|
177
|
+
const deck = getDeck(id);
|
|
178
|
+
if (!deck) throw fail("unknown_deck", `no registered deck ${id}`);
|
|
179
|
+
if (!fs.existsSync(deck.path)) throw fail("missing_path", `${deck.path} is not there any more`, { deckId: id, path: deck.path, name: deck.name ?? null });
|
|
180
|
+
return { ...deck, workspaceDir: resolveWorkspace(deck.path) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The installed adapter a deck's plan owner names, or the blocker. Core-owned decks: null adapter. */
|
|
184
|
+
function deckAdapter(deck) {
|
|
185
|
+
const owner = planPageOwner(deck.workspaceDir);
|
|
186
|
+
if (owner.owner === "core") return { id: "core", entry: null, owner };
|
|
187
|
+
const resolved = resolveWorkspaceAdapter(owner.owner === "extension" ? null : owner.owner);
|
|
188
|
+
return { ...resolved, owner };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function adapterResult(r) {
|
|
192
|
+
if (r.status === "ok") return r.data;
|
|
193
|
+
throw fail(r.error?.code ?? (r.status === "blocked" ? "deck_busy" : "extension_incompatible"), r.error?.message ?? "adapter failed", r.error?.details);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mapCode(err) {
|
|
197
|
+
const code = err?.code && STATUS[err.code] ? err.code : null;
|
|
198
|
+
return { status: code ? STATUS[code] : 500, code: code ?? "internal", retryable: RETRYABLE.has(code) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Preview freshness for the deck view. `hash` is the source hash the last
|
|
203
|
+
* review round recorded, `sourceHash` what the slides hash to now; `stale`
|
|
204
|
+
* compares presentation.html's mtime with the newest source file, so a
|
|
205
|
+
* rebuilt-but-unreviewed preview still reads fresh.
|
|
206
|
+
*/
|
|
207
|
+
/**
|
|
208
|
+
* One stat walk over the reviewed sources (slides/, assets/): the newest mtime,
|
|
209
|
+
* and a signature of every name, size and time. Cheap — no file is read.
|
|
210
|
+
*/
|
|
211
|
+
function previewScan(ws) {
|
|
212
|
+
let newest = 0;
|
|
213
|
+
const parts = [];
|
|
214
|
+
const walk = (dir, rel) => {
|
|
215
|
+
let entries = [];
|
|
216
|
+
try {
|
|
217
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
218
|
+
} catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
222
|
+
const p = path.join(dir, e.name);
|
|
223
|
+
if (e.isDirectory()) walk(p, `${rel}/${e.name}`);
|
|
224
|
+
else if (e.isFile()) {
|
|
225
|
+
const st = fs.statSync(p);
|
|
226
|
+
newest = Math.max(newest, st.mtimeMs);
|
|
227
|
+
parts.push(`${rel}/${e.name}:${st.size}:${st.mtimeMs}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
for (const sub of ["slides", "assets"]) walk(path.join(ws, sub), sub);
|
|
232
|
+
return { newest, sig: parts.join("|") };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The content hash reads every slide and asset; a page polls every 2 s. It is
|
|
236
|
+
// recomputed only when a stat signature moved. Display paths only — every
|
|
237
|
+
// decision hashes fresh (subjectOf, respondPreview).
|
|
238
|
+
const previewHashMemo = new Map(); // ws → { sig, hash }
|
|
239
|
+
function previewHashCached(ws, scan = previewScan(ws)) {
|
|
240
|
+
const memo = previewHashMemo.get(ws);
|
|
241
|
+
if (memo && memo.sig === scan.sig) return memo.hash;
|
|
242
|
+
const hash = previewHash(ws);
|
|
243
|
+
previewHashMemo.set(ws, { sig: scan.sig, hash });
|
|
244
|
+
return hash;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function previewState(ws, { scan = null, sourceHash: known = null } = {}) {
|
|
248
|
+
const file = previewPath(ws, "presentation.html");
|
|
249
|
+
let builtAt = null;
|
|
250
|
+
try {
|
|
251
|
+
builtAt = fs.statSync(file).mtimeMs;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const seen = scan ?? previewScan(ws);
|
|
256
|
+
const newest = seen.newest;
|
|
257
|
+
const reviewed = loadReviewState(ws).deckHash;
|
|
258
|
+
let sourceHash = known;
|
|
259
|
+
if (!sourceHash) {
|
|
260
|
+
try {
|
|
261
|
+
sourceHash = previewHashCached(ws, seen);
|
|
262
|
+
} catch {
|
|
263
|
+
// no bundle resolvable: freshness by mtime only
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// The stamp the build wrote (the hash it was built from) is the truth when
|
|
267
|
+
// present — a deleted or renamed slide keeps no newer mtime; file times are the
|
|
268
|
+
// fallback for a preview built before stamps existed.
|
|
269
|
+
const builtFrom = previewBuiltFrom(ws);
|
|
270
|
+
const stale = builtFrom && sourceHash ? builtFrom !== sourceHash : newest > builtAt;
|
|
271
|
+
return { hash: reviewed ? `sha256:${reviewed}` : null, sourceHash, builtFrom, builtAt: new Date(builtAt).toISOString(), newestSourceAt: newest, stale };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The deck's built .pptx (absolute path), or null. `<name>.pptx` wins; a deck
|
|
276
|
+
* that holds several takes the newest, which is what a build just wrote.
|
|
277
|
+
*/
|
|
278
|
+
function deckPptx(deck) {
|
|
279
|
+
let names = [];
|
|
280
|
+
try {
|
|
281
|
+
names = fs.readdirSync(deck.path).filter((f) => /\.pptx$/i.test(f) && !f.startsWith("~$"));
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (!names.length) return null;
|
|
286
|
+
const preferred = names.find((f) => f.toLowerCase() === `${String(deck.name ?? "").toLowerCase()}.pptx`);
|
|
287
|
+
const pick =
|
|
288
|
+
preferred ??
|
|
289
|
+
names.sort((a, b) => fs.statSync(path.join(deck.path, b)).mtimeMs - fs.statSync(path.join(deck.path, a)).mtimeMs)[0];
|
|
290
|
+
return path.join(deck.path, pick);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Unsettled local work: an operation intent on disk or a queue with work pending. */
|
|
294
|
+
function dirtyState(ws, sync) {
|
|
295
|
+
const intent = readOperationIntent(ws);
|
|
296
|
+
return Boolean(intent) || Boolean(sync && !sync.synced && sync.exists);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `createRoutes({ auth, deps })` → `handle(req, res, ctx)` for createServer.
|
|
301
|
+
* auth from auth.mjs
|
|
302
|
+
* deps { tasks?, edit?, events?, track?, restart?, uiRoot?, adapter? }
|
|
303
|
+
* tasks/edit default to the sibling modules when they exist;
|
|
304
|
+
* adapter(deck) overrides deckAdapter (tests); track(promise) holds the
|
|
305
|
+
* drain; restart() is the daemon's re-exec.
|
|
306
|
+
*/
|
|
307
|
+
export function createRoutes({ auth, deps = {} } = {}) {
|
|
308
|
+
const loadTasks = deps.tasks ? async () => deps.tasks : lazy("./tasks.mjs");
|
|
309
|
+
const loadEdit = deps.edit ? async () => deps.edit : lazy("./edit.mjs");
|
|
310
|
+
// remote association (remote-links / remote-status); `deps.remote` is the
|
|
311
|
+
// override bag those modules take (a mock provider session in tests)
|
|
312
|
+
const loadLinks = lazy("./remote-links.mjs");
|
|
313
|
+
const loadStatus = lazy("./remote-status.mjs");
|
|
314
|
+
// autosync + the approval/conflict coordinator (sync.mjs: drainOnce, refreshOnce,
|
|
315
|
+
// resolveConflict, activateDestination, approvalInProgress, syncStatus);
|
|
316
|
+
// `deps.syncModule` injects a fake (`deps.sync` is the daemon loop's bag)
|
|
317
|
+
const loadSync = deps.syncModule ? async () => deps.syncModule : lazy("./sync.mjs");
|
|
318
|
+
const remoteDeps = deps.remote ?? {};
|
|
319
|
+
// a task answer: the coordinator entry point, or the injected fake store's respondTask
|
|
320
|
+
const respond = deps.tasks
|
|
321
|
+
? (deckRoot, id, req, { adapter, owner, verify }) =>
|
|
322
|
+
deps.tasks.respondTask(deckRoot, id, req, { owner, verify, invoke: (task, response, { token }) => invokeTaskAction({ deckRoot, adapter, task, response, token, payload: req.payload ?? {} }) })
|
|
323
|
+
: respondTaskAction;
|
|
324
|
+
|
|
325
|
+
/** One export of a lazily loaded module, or a typed 501 when this build lacks it. */
|
|
326
|
+
const fn = async (loader, name, what) => {
|
|
327
|
+
const mod = await loader();
|
|
328
|
+
if (!mod || typeof mod[name] !== "function") throw fail("not_implemented", `${what} is not available in this build`);
|
|
329
|
+
return mod[name];
|
|
330
|
+
};
|
|
331
|
+
const syncCard = async (ws) => {
|
|
332
|
+
const s = await loadSync();
|
|
333
|
+
if (!s || typeof s.syncStatus !== "function") return null;
|
|
334
|
+
try {
|
|
335
|
+
return s.syncStatus(ws);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return { state: "recovery_required", synced: false, exists: true, lastError: { code: err.code ?? "recovery_required", message: err.message } };
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const remoteCard = async (deck) => {
|
|
341
|
+
const st = await loadStatus();
|
|
342
|
+
const remote = st ? await st.remoteStatus(deck.path, remoteDeps) : null;
|
|
343
|
+
const sync = await syncCard(deck.workspaceDir);
|
|
344
|
+
if (!remote) return { remote: null, sync };
|
|
345
|
+
// the queue is the authority on "blocked": a conflict or an unknown outcome overrides the disk-only card
|
|
346
|
+
const blocked = sync && (sync.conflict || ["blocked", "outcome-unknown", "recovery_required"].includes(sync.state));
|
|
347
|
+
const plan = remote.plan.state === "shared" && blocked ? { ...remote.plan, state: "blocked", reason: sync.lastError?.code ?? (sync.conflict ? "remote_conflict" : sync.state), lastConfirmedAt: sync.lastConfirmedAt ?? remote.plan.lastConfirmedAt } : remote.plan;
|
|
348
|
+
return { remote: { ...remote, plan, sync }, sync };
|
|
349
|
+
};
|
|
350
|
+
const events = deps.events ?? defaultEvents;
|
|
351
|
+
const track = deps.track ?? ((p) => p);
|
|
352
|
+
const uiRoot = deps.uiRoot ?? UI_ROOT;
|
|
353
|
+
const adapterOf = deps.adapter ?? deckAdapter;
|
|
354
|
+
|
|
355
|
+
const need = async (loader, name) => {
|
|
356
|
+
const mod = await loader();
|
|
357
|
+
if (!mod) throw fail("not_implemented", `${name} is not available in this build`);
|
|
358
|
+
return mod;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/** A store without waitTask (an injected fake): poll it until the task settles. */
|
|
362
|
+
async function pollTask(tasksMod, deckRoot, taskId, timeoutMs, signal) {
|
|
363
|
+
const deadline = Date.now() + timeoutMs;
|
|
364
|
+
for (;;) {
|
|
365
|
+
const task = (await tasksMod.listTasks(deckRoot, {})).find((t) => t.id === taskId);
|
|
366
|
+
if (!task) throw fail("stale_subject", `no task ${taskId} on this deck`);
|
|
367
|
+
if (task.status !== "pending") return { task, pending: false };
|
|
368
|
+
if (signal.aborted || Date.now() >= deadline) return { task, pending: true };
|
|
369
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function describe(deck) {
|
|
374
|
+
const a = adapterOf(deck);
|
|
375
|
+
if (a.blocker || !a.entry) return { adapter: a, views: null, actions: [] };
|
|
376
|
+
const data = adapterResult(await dispatchAdapter(a.entry, { method: "describe", deckRoot: deck.path }));
|
|
377
|
+
return { adapter: a, views: data?.views ?? null, actions: Array.isArray(data?.actions) ? data.actions : [], record: data?.record ?? null };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function readPlan(deck) {
|
|
381
|
+
const a = adapterOf(deck);
|
|
382
|
+
if (a.blocker) throw fail(a.blocker.code, a.blocker.message);
|
|
383
|
+
if (!a.entry) {
|
|
384
|
+
const file = path.join(deck.workspaceDir, "plan.md");
|
|
385
|
+
let value = null;
|
|
386
|
+
try {
|
|
387
|
+
value = fs.readFileSync(file, "utf8");
|
|
388
|
+
} catch {
|
|
389
|
+
// a deck without a plan yet
|
|
390
|
+
}
|
|
391
|
+
return { value, hash: value === null ? null : sha256(value), format: "markdown", adapterId: "core", capabilities: { save: true }, sync: null };
|
|
392
|
+
}
|
|
393
|
+
const data = adapterResult(await dispatchAdapter(a.entry, { method: "read", deckRoot: deck.path }));
|
|
394
|
+
return { format: "json", adapterId: a.id, capabilities: {}, sync: null, ...data };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// GET /decks/:id/preview/<file>, the preview page's media and GET /ui/<file>.
|
|
398
|
+
// The page's `<base href="../">` resolves the deck's own `./assets/…` (engine
|
|
399
|
+
// downloads, converted pictures) to /decks/:id/assets/, and previewPageHtml
|
|
400
|
+
// points the brand's at /decks/:id/brand/assets/ — the two roots `slidemind
|
|
401
|
+
// review` serves the same page from. Nothing else of the workspace is served.
|
|
402
|
+
function serveFiles(pathname, res) {
|
|
403
|
+
const ui = pathname.match(/^\/ui\/(.+)$/);
|
|
404
|
+
if (ui) return serveContained(uiRoot, decodeURIComponent(ui[1]), res);
|
|
405
|
+
const pv = pathname.match(/^\/api\/v1\/decks\/([^/]+)\/(preview|assets|brand\/assets)\/(.+)$/);
|
|
406
|
+
if (pv) {
|
|
407
|
+
const ws = resolveDeck(pv[1]).workspaceDir;
|
|
408
|
+
const root = pv[2] === "preview" ? previewPath(ws) : pv[2] === "assets" ? path.join(ws, "assets") : brandAssetsDirForDeck(ws);
|
|
409
|
+
return !!root && serveContained(root, decodeURIComponent(pv[3]), res);
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** presentation.html as this server serves it: the bundle's file: URLs answered by the deck's brand route. */
|
|
415
|
+
function previewPageHtml(deck) {
|
|
416
|
+
const html = fs.readFileSync(previewPath(deck.workspaceDir, "presentation.html"), "utf8");
|
|
417
|
+
return routeBrandAssets(html, brandAssetsDirForDeck(deck.workspaceDir), `${PREFIX}/decks/${deck.id}/brand/assets`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The route table: [method, regex, handler(params, ctx)] → body | { status, body }
|
|
421
|
+
const pickFolder = deps.pickFolder ?? defaultPickFolder;
|
|
422
|
+
const revealFolder = deps.revealFolder ?? defaultRevealFolder;
|
|
423
|
+
const openFile = deps.openFile ?? defaultOpenFile;
|
|
424
|
+
const trashFolder = deps.trashFolder ?? defaultTrashFolder;
|
|
425
|
+
// The embedded gates (plan.html / the preview review inside the workspace
|
|
426
|
+
// page) POST their decision without the page's CSRF header: each rendered
|
|
427
|
+
// page carries a token in its endpoint URL instead, minted per deck. With a
|
|
428
|
+
// persisted auth (auth.gateTokenFor) the token outlives a daemon restart, so
|
|
429
|
+
// a page left open keeps working; the local map is the in-memory fallback.
|
|
430
|
+
const gateTokens = new Map(); // token → deckId
|
|
431
|
+
const gateTokenFor = (deckId) => {
|
|
432
|
+
if (typeof auth?.gateTokenFor === "function") return auth.gateTokenFor(deckId);
|
|
433
|
+
for (const [t, id] of gateTokens) if (id === deckId) return t;
|
|
434
|
+
const t = createHash("sha256").update(`${deckId}:${Math.random()}:${Date.now()}`).digest("base64url");
|
|
435
|
+
gateTokens.set(t, deckId);
|
|
436
|
+
return t;
|
|
437
|
+
};
|
|
438
|
+
const gateTokenValid = (token, deckId) =>
|
|
439
|
+
typeof auth?.gateTokenValid === "function" ? auth.gateTokenValid(token, deckId) : gateTokens.get(token) === deckId;
|
|
440
|
+
|
|
441
|
+
// Scripts waiting on a task (`slidemind tasks wait` long-polls GET …/wait; no
|
|
442
|
+
// agent does — every hand-back is a paste, doc 21). The page says nothing
|
|
443
|
+
// about them: whether some process happens to wait never changes what the
|
|
444
|
+
// user sees. Two things still read them: the daemon does not restart into new
|
|
445
|
+
// code under an open wait, and a wait's owner token lets the answer to THAT
|
|
446
|
+
// task re-enter the script's edit session instead of meeting deck_busy.
|
|
447
|
+
const waiters = new Map(); // "<deckId>:<taskId>" → { open, owner }
|
|
448
|
+
const ownerOn = (deckId, taskId) => {
|
|
449
|
+
const w = taskId ? waiters.get(`${deckId}:${taskId}`) : null;
|
|
450
|
+
return w && w.open > 0 && w.owner ? w.owner : null;
|
|
451
|
+
};
|
|
452
|
+
const openWaits = () => [...waiters.values()].reduce((n, w) => n + w.open, 0);
|
|
453
|
+
// questions an open page was told about: asking the same question again
|
|
454
|
+
// (the user said "continue" before answering) does not announce it twice
|
|
455
|
+
const attended = new Set();
|
|
456
|
+
/** Pending tasks of one kind, newest first. */
|
|
457
|
+
const pendingOf = async (deck, kind) => {
|
|
458
|
+
const tasksMod = await loadTasks();
|
|
459
|
+
return tasksMod ? (await tasksMod.listTasks(deck.path, { status: "pending" })).filter((t) => t.kind === kind).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) : [];
|
|
460
|
+
};
|
|
461
|
+
/** Every task of one kind, newest first. */
|
|
462
|
+
const tasksOf = async (deck, kind) => {
|
|
463
|
+
const tasksMod = await loadTasks();
|
|
464
|
+
return tasksMod ? (await tasksMod.listTasks(deck.path, {})).filter((t) => t.kind === kind).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) : [];
|
|
465
|
+
};
|
|
466
|
+
/** The round a gate page belongs to: the reviewed content plus the task waiting on it. */
|
|
467
|
+
// The round = the reviewed content + the NEWEST task of that kind, whatever its
|
|
468
|
+
// status: answering a task must not read as a new round (the page would reload
|
|
469
|
+
// and drop its "agent revising" state), a fresh request on the same content must.
|
|
470
|
+
const gateRound = async (deck, kind, subjectHash) => {
|
|
471
|
+
const newest = (await tasksOf(deck, kind))[0] ?? null;
|
|
472
|
+
return createHash("sha256").update(`${subjectHash}|${newest?.id ?? ""}`).digest("hex").slice(0, 16);
|
|
473
|
+
};
|
|
474
|
+
const subjectOf = async (deck, kind) => {
|
|
475
|
+
if (kind === "preview-review") return previewHash(deck.workspaceDir);
|
|
476
|
+
const { planSubjectHash } = await import("../builders/plan.mjs");
|
|
477
|
+
return planSubjectHash(deck.workspaceDir);
|
|
478
|
+
};
|
|
479
|
+
/** Who holds the deck right now — an agent's edit session or a running command — or null. */
|
|
480
|
+
const busyOf = (deck) => {
|
|
481
|
+
let b = null;
|
|
482
|
+
try {
|
|
483
|
+
b = readBusy(deck.workspaceDir);
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
if (!b) return null;
|
|
488
|
+
if (b.kind !== "session" && (b.pid === process.pid || !b.pid || !pidAlive(b.pid))) return null;
|
|
489
|
+
return { since: b.startedAt ?? null, command: b.command ?? null, session: b.kind === "session" };
|
|
490
|
+
};
|
|
491
|
+
/** When the agent last changed what this gate shows: plan.md, or the slides/assets and the built preview. */
|
|
492
|
+
const activityOf = (deck, kind, scan = null) => {
|
|
493
|
+
const ws = deck.workspaceDir;
|
|
494
|
+
let newest = 0;
|
|
495
|
+
const stat = (p) => {
|
|
496
|
+
try {
|
|
497
|
+
newest = Math.max(newest, fs.statSync(p).mtimeMs);
|
|
498
|
+
} catch {
|
|
499
|
+
// not there
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
stat(path.join(ws, "plan.md"));
|
|
503
|
+
if (kind === "preview-review" && scan) {
|
|
504
|
+
stat(previewPath(ws, "presentation.html"));
|
|
505
|
+
newest = Math.max(newest, scan.newest);
|
|
506
|
+
} else if (kind === "preview-review") {
|
|
507
|
+
stat(previewPath(ws, "presentation.html"));
|
|
508
|
+
for (const sub of ["slides", "assets"]) {
|
|
509
|
+
let names = [];
|
|
510
|
+
try {
|
|
511
|
+
names = fs.readdirSync(path.join(ws, sub));
|
|
512
|
+
} catch {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
for (const n of names) stat(path.join(ws, sub, n));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return newest ? new Date(newest).toISOString() : null;
|
|
519
|
+
};
|
|
520
|
+
const approvedNow = async (deck, kind, subjectHash) => {
|
|
521
|
+
if (kind === "preview-review") {
|
|
522
|
+
const s = loadReviewState(deck.workspaceDir);
|
|
523
|
+
return s.decision === "approve" && `sha256:${s.deckHash}` === subjectHash;
|
|
524
|
+
}
|
|
525
|
+
const { planApprovedExactly } = await import("../builders/plan.mjs");
|
|
526
|
+
return planApprovedExactly(deck.workspaceDir);
|
|
527
|
+
};
|
|
528
|
+
/** What an embedded gate's heartbeat carries (contract: heartbeat). */
|
|
529
|
+
const gateBeat = async (deck, kind, service, subjectHash, { scan = null } = {}) => {
|
|
530
|
+
const all = await tasksOf(deck, kind);
|
|
531
|
+
const pending = all.find((t) => t.status === "pending") ?? null;
|
|
532
|
+
const done = all.find((t) => t.status === "completed") ?? null;
|
|
533
|
+
return {
|
|
534
|
+
service,
|
|
535
|
+
round: createHash("sha256").update(`${subjectHash}|${all[0]?.id ?? ""}`).digest("hex").slice(0, 16),
|
|
536
|
+
taskId: pending?.id ?? null,
|
|
537
|
+
subject: subjectHash,
|
|
538
|
+
approved: await approvedNow(deck, kind, subjectHash),
|
|
539
|
+
answered: done ? { taskId: done.id, action: done.response?.action ?? null, at: done.response?.at ?? null, pickedUpAt: done.pickedUpAt ?? null, subject: done.subject?.hash ?? null } : null,
|
|
540
|
+
busy: busyOf(deck),
|
|
541
|
+
activityAt: activityOf(deck, kind, scan),
|
|
542
|
+
...(kind === "preview-review" ? { previewStale: previewState(deck.workspaceDir, { scan, sourceHash: subjectHash })?.stale === true } : {}),
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The plan the human edited inside the gate, committed BEFORE the answer, the
|
|
548
|
+
* way the Markdown editor's save commits it (PUT /plan): under the deck lock,
|
|
549
|
+
* with the stale-base check, and the pending plan task FOLLOWS the edit — so
|
|
550
|
+
* the answer is about exactly the plan the human approved, the agent learns
|
|
551
|
+
* it was edited (`subject.requestedHash`), and the task can complete. Writing
|
|
552
|
+
* the edit inside the answer instead left the task pending with a hash that
|
|
553
|
+
* no longer matched (the agent's wait never returned).
|
|
554
|
+
*/
|
|
555
|
+
async function commitGateEdit(deck, adapter, expectedHash, editedBody, owner) {
|
|
556
|
+
const file = path.join(deck.workspaceDir, "plan.md");
|
|
557
|
+
let text;
|
|
558
|
+
try {
|
|
559
|
+
text = fs.readFileSync(file, "utf8");
|
|
560
|
+
} catch {
|
|
561
|
+
throw fail("stale_subject", "this deck has no plan any more");
|
|
562
|
+
}
|
|
563
|
+
const current = sha256(text);
|
|
564
|
+
const { frontmatterRaw, body } = splitPlanFrontmatter(text);
|
|
565
|
+
if (editedBody === body) return null;
|
|
566
|
+
if (expectedHash && expectedHash !== current) {
|
|
567
|
+
throw fail("stale_subject", "your agent changed the plan while you were editing it", { current });
|
|
568
|
+
}
|
|
569
|
+
const syncMod = await loadSync();
|
|
570
|
+
if (syncMod?.approvalInProgress?.(deck.workspaceDir)) throw fail("approval_in_progress", "an approval of this plan is in flight; your edits are kept and can be saved once it settles");
|
|
571
|
+
const edit = await need(loadEdit, "plan editing");
|
|
572
|
+
const result = await edit.savePlanLocal({ workspaceDir: deck.workspaceDir, adapter, operationId: randomUUID(), base: { hash: current, value: text }, value: frontmatterRaw + editedBody, owner });
|
|
573
|
+
if (result?.code) throw fail(result.code, result.message ?? result.code, result);
|
|
574
|
+
// The agent wrote plan.md between our read and the lock: the save merged onto
|
|
575
|
+
// a plan the human never saw. Their edit is kept (a later version can show it),
|
|
576
|
+
// but this answer is not about what they looked at.
|
|
577
|
+
if (result?.ok && result.previous !== current) {
|
|
578
|
+
events.invalidate({ type: "plan", deckId: deck.id });
|
|
579
|
+
throw fail("stale_subject", "your agent changed the plan while you were editing it", { current: result.hash });
|
|
580
|
+
}
|
|
581
|
+
const tasksMod = await loadTasks();
|
|
582
|
+
if (result?.ok && result.previous !== result.hash) await tasksMod?.followPlanEdit?.(deck.path, { from: result.previous, to: result.hash }, { owner });
|
|
583
|
+
events.invalidate({ type: "plan", deckId: deck.id });
|
|
584
|
+
return { from: result.previous, to: result.hash };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const gateNoun = (kind) => (kind === "plan-approval" ? "plan" : "slides");
|
|
588
|
+
const gateCommand = (kind, deck) => `slidemind ${kind === "plan-approval" ? "plan" : "review"} ${JSON.stringify(deck.path)}`;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A decision posted by an embedded gate. Every answer lands on a TASK about
|
|
592
|
+
* exactly the content the human looked at (`expectedHash`): the pending one
|
|
593
|
+
* the agent asked, or — when the agent has not asked about this version (it
|
|
594
|
+
* never asked, or asked about an older one) — one asked on the human's behalf.
|
|
595
|
+
* The agent's next `slidemind plan|review` run hands it over — the command
|
|
596
|
+
* the ack's `message` names, which the page copies for the user to paste.
|
|
597
|
+
*/
|
|
598
|
+
async function gateDecision(deck, kind, body) {
|
|
599
|
+
const adapter = adapterOf(deck);
|
|
600
|
+
if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
|
|
601
|
+
const tasksMod = await need(loadTasks, "tasks");
|
|
602
|
+
const decision = body.decision === "comments" ? "comments" : "approve";
|
|
603
|
+
const operationId = UUID.test(String(body.operationId ?? "")) ? String(body.operationId) : randomUUID();
|
|
604
|
+
let expected = typeof body.expectedHash === "string" && /^sha256:[a-f0-9]{64}$/.test(body.expectedHash) ? body.expectedHash : null;
|
|
605
|
+
const all = await tasksOf(deck, kind);
|
|
606
|
+
// the same attempt again (a retry after a dropped response): the recorded answer, nothing new
|
|
607
|
+
const replay = all.find((t) => t.response?.operationId === operationId);
|
|
608
|
+
if (replay) return gateAck(deck, kind, replay, { decision: replay.response.action === "approve" ? "approve" : "comments", replayed: true });
|
|
609
|
+
const pending = all.find((t) => t.status === "pending") ?? null;
|
|
610
|
+
const owner = ownerOn(deck.id, pending?.id) ?? false;
|
|
611
|
+
let edited = false;
|
|
612
|
+
if (kind === "plan-approval" && !adapter.entry && typeof body.editedPlan === "string") {
|
|
613
|
+
const moved = await commitGateEdit(deck, adapter, expected, body.editedPlan, owner);
|
|
614
|
+
if (moved) {
|
|
615
|
+
edited = true;
|
|
616
|
+
expected = moved.to;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const current = await subjectOf(deck, kind);
|
|
620
|
+
// A preview older than its slides shows something other than what an approval would cover.
|
|
621
|
+
// a decision judges freshness on a FRESH hash, never the heartbeat's cached one
|
|
622
|
+
if (kind === "preview-review" && decision === "approve" && previewState(deck.workspaceDir, { sourceHash: current })?.stale === true) {
|
|
623
|
+
throw fail("stale_subject", "your agent changed the slides after this preview was made — it will show you the new version for review", { current, previewStale: true });
|
|
624
|
+
}
|
|
625
|
+
const fresh = (await tasksOf(deck, kind)).find((t) => t.status === "pending") ?? null;
|
|
626
|
+
if (expected && expected !== current) {
|
|
627
|
+
// the human's own earlier edits are theirs; anything else moved under the page
|
|
628
|
+
const own = fresh && fresh.subject?.hash === current && (fresh.subject?.followed ?? []).includes(expected);
|
|
629
|
+
if (!own) throw fail("stale_subject", `your agent changed the ${gateNoun(kind)} after this page was opened`, { current });
|
|
630
|
+
}
|
|
631
|
+
let task = fresh && fresh.subject?.hash === current ? fresh : null;
|
|
632
|
+
if (!task) {
|
|
633
|
+
// Already answered (another tab, a page left open): one answer per version. A
|
|
634
|
+
// new question about the same content (the agent asked again) is pending, so
|
|
635
|
+
// it does not land here.
|
|
636
|
+
const done = (await tasksOf(deck, kind)).find((t) => t.status === "completed" && (t.subject?.hash === current || t.evidence?.hash === current));
|
|
637
|
+
if (done) throw fail("already_answered", `this version of the ${gateNoun(kind)} was already answered`, { action: done.response?.action ?? null, at: done.response?.at ?? null, taskId: done.id });
|
|
638
|
+
// Nobody asked about exactly this version: ask now, on the human's behalf.
|
|
639
|
+
// A pending task about an older version is superseded by it; an agent
|
|
640
|
+
// waiting on that one re-asks and receives this answer at once.
|
|
641
|
+
const { adapterId, subject } = await deriveTaskSubject({ deckRoot: deck.path, adapter, kind });
|
|
642
|
+
if (subject.hash !== current) throw fail("stale_subject", `your agent changed the ${gateNoun(kind)} after this page was opened`, { current: subject.hash });
|
|
643
|
+
const asked = await tasksMod.requestTask(deck.path, { kind, adapterId, subject }, { owner, verify: taskEvidenceVerifier({ deckRoot: deck.path, adapter }) });
|
|
644
|
+
task = asked?.task ?? asked;
|
|
645
|
+
}
|
|
646
|
+
const comments = decision === "comments" ? (Array.isArray(body.comments) ? body.comments : []) : [];
|
|
647
|
+
// a design picked at the gate is applied with the answer (respondPlan)
|
|
648
|
+
const design = kind === "plan-approval" && !adapter.entry && typeof body.design === "string" ? body.design : null;
|
|
649
|
+
const payload = { comments, ...(edited ? { edited: true } : {}), ...(design ? { design } : {}) };
|
|
650
|
+
const action = decision === "comments" ? (task.actions ?? []).find((a) => a !== "approve") ?? "comment" : "approve";
|
|
651
|
+
const { actor, selfDeclared } = taskActor(payload);
|
|
652
|
+
const verify = taskEvidenceVerifier({ deckRoot: deck.path, adapter });
|
|
653
|
+
let answered;
|
|
654
|
+
try {
|
|
655
|
+
answered = await track(respond(deck.path, task.id, { operationId, expectedHash: task.subject?.hash ?? current, action, payload, actor, selfDeclared }, { adapter, owner: ownerOn(deck.id, task.id) ?? owner, verify }));
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (err?.code === "stale_subject" && err.details?.supersede) {
|
|
658
|
+
await tasksMod.supersedeTask?.(deck.path, task.id, { owner: false }).catch(() => {});
|
|
659
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId: task.id });
|
|
660
|
+
throw fail("stale_subject", `your agent changed the ${gateNoun(kind)} after this page was opened`, { current: await subjectOf(deck, kind).catch(() => null) });
|
|
661
|
+
}
|
|
662
|
+
throw err;
|
|
663
|
+
}
|
|
664
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId: task.id });
|
|
665
|
+
events.invalidate({ type: kind === "plan-approval" ? "plan" : "preview", deckId: deck.id });
|
|
666
|
+
return gateAck(deck, kind, answered?.task ?? task, { decision, edited, replayed: false });
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The decision ack (contract): the message the page copies for the user to
|
|
670
|
+
* paste into their chat — the decision plus the one command that receives it,
|
|
671
|
+
* so it works in the same chat, after a summarization and in a new one.
|
|
672
|
+
*/
|
|
673
|
+
function gateAck(deck, kind, task, { decision, edited = false, replayed }) {
|
|
674
|
+
const receive = gateCommand(kind, deck);
|
|
675
|
+
const message = pasteMessage({ kind, action: decision === "comments" ? "request-changes" : "approve", receive });
|
|
676
|
+
return { decided: decision, task: task?.id ?? null, receive, message, edited, replayed };
|
|
677
|
+
}
|
|
678
|
+
const realDir = (p) => {
|
|
679
|
+
const abs = path.resolve(p);
|
|
680
|
+
try {
|
|
681
|
+
return fs.realpathSync(abs);
|
|
682
|
+
} catch {
|
|
683
|
+
throw fail("missing_path", `${abs} does not exist`);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
const routes = [
|
|
687
|
+
["POST", /^\/bootstrap$/, async (_, { who }) => {
|
|
688
|
+
if (who?.kind !== "cli") throw fail("unauthorized", "only the CLI hands out sign-in links");
|
|
689
|
+
// the link key: the same in every link, valid every time it is clicked
|
|
690
|
+
return auth.linkToken();
|
|
691
|
+
}],
|
|
692
|
+
["POST", /^\/restart$/, async (_, { res }) => {
|
|
693
|
+
if (!deps.restart) throw fail("not_implemented", "this workspace cannot restart itself");
|
|
694
|
+
res.on("finish", () => deps.restart());
|
|
695
|
+
return { status: 202, body: { restarting: true } };
|
|
696
|
+
}],
|
|
697
|
+
["GET", /^\/decks$/, async (_, { url }) => {
|
|
698
|
+
const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 100, 1), 100);
|
|
699
|
+
const offset = Math.max(Number(url.searchParams.get("cursor")) || 0, 0);
|
|
700
|
+
const { decks, total } = listDecks({ q: url.searchParams.get("q") ?? "", limit, offset });
|
|
701
|
+
const tasksMod = await loadTasks();
|
|
702
|
+
const st = await loadStatus();
|
|
703
|
+
const rows = [];
|
|
704
|
+
for (const d of decks) {
|
|
705
|
+
let taskCount = 0;
|
|
706
|
+
let remote = null;
|
|
707
|
+
if (!d.missing) {
|
|
708
|
+
try {
|
|
709
|
+
taskCount = tasksMod ? (await tasksMod.listTasks(d.path, { status: "pending" })).length : 0;
|
|
710
|
+
} catch {
|
|
711
|
+
taskCount = null; // a malformed store shows as unknown, the deck view says why
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
const card = st ? await st.remoteStatus(d.path, remoteDeps) : null;
|
|
715
|
+
// a deck is "on SharePoint" when its plan, its PPTX or a destination folder lives there
|
|
716
|
+
const on = card && (card.plan.state !== "none" || card.pptx.state !== "none" || (card.destination && !card.destination.malformed));
|
|
717
|
+
remote = on ? { plan: { state: card.plan.state }, pptx: { state: card.pptx.state }, link: card.pptx.link ?? card.plan.link ?? card.destination?.webUrl ?? null } : null;
|
|
718
|
+
} catch {
|
|
719
|
+
remote = null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
let built = false;
|
|
723
|
+
if (!d.missing) {
|
|
724
|
+
try {
|
|
725
|
+
built = fs.readdirSync(d.path).some((f) => /\.pptx$/i.test(f));
|
|
726
|
+
} catch {
|
|
727
|
+
built = false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
rows.push({ ...d, taskCount, remote, built });
|
|
731
|
+
}
|
|
732
|
+
return { decks: rows, total, nextCursor: offset + decks.length < total ? String(offset + decks.length) : null, scanning: deps.scanning ? deps.scanning() : [], roots: listRoots(), home: process.env.HOME || os.homedir() };
|
|
733
|
+
}],
|
|
734
|
+
["POST", /^\/decks$/, async (_, { body, who }) => {
|
|
735
|
+
if (typeof body.path !== "string" || !body.path) throw fail("bad_request", "path is required");
|
|
736
|
+
const entry = await addDeckAsync(realDir(body.path));
|
|
737
|
+
return { deckId: entry.id, deck: entry };
|
|
738
|
+
}],
|
|
739
|
+
// POST /fs/pick — open the OS folder dialog on this machine and answer the choice.
|
|
740
|
+
["POST", /^\/fs\/pick$/, async () => {
|
|
741
|
+
let chosen;
|
|
742
|
+
try {
|
|
743
|
+
chosen = await pickFolder();
|
|
744
|
+
} catch (err) {
|
|
745
|
+
throw fail(err.code === "picker_unavailable" ? "picker_unavailable" : "picker_failed", err.message);
|
|
746
|
+
}
|
|
747
|
+
return { path: chosen ? realDir(chosen) : null };
|
|
748
|
+
}],
|
|
749
|
+
// POST /decks/:id/reveal — show the deck folder in the OS file manager.
|
|
750
|
+
// `parent: true` shows the folder ABOVE it: that is what the sidebar's group
|
|
751
|
+
// heading points at, and keying it to a registered deck keeps the reveal
|
|
752
|
+
// confined to paths the registry already knows (no bare path in a body).
|
|
753
|
+
["POST", /^\/decks\/([^/]+)\/reveal$/, async ([id], { body }) => {
|
|
754
|
+
const deck = resolveDeck(id);
|
|
755
|
+
const dir = body?.parent ? path.dirname(deck.path) : deck.path;
|
|
756
|
+
try {
|
|
757
|
+
await revealFolder(dir);
|
|
758
|
+
} catch (err) {
|
|
759
|
+
throw fail(err.code === "reveal_unavailable" ? "reveal_unavailable" : "reveal_failed", err.message);
|
|
760
|
+
}
|
|
761
|
+
return { revealed: dir };
|
|
762
|
+
}],
|
|
763
|
+
// POST /decks/:id/open-pptx — hand the built deck to PowerPoint on this machine
|
|
764
|
+
["POST", /^\/decks\/([^/]+)\/open-pptx$/, async ([id]) => {
|
|
765
|
+
const deck = resolveDeck(id);
|
|
766
|
+
const file = deckPptx(deck);
|
|
767
|
+
if (!file) throw fail("not_found", "this deck has no .pptx yet");
|
|
768
|
+
try {
|
|
769
|
+
await openFile(file);
|
|
770
|
+
} catch (err) {
|
|
771
|
+
throw fail(err.code === "open_unavailable" ? "open_unavailable" : "open_failed", err.message);
|
|
772
|
+
}
|
|
773
|
+
return { opened: file };
|
|
774
|
+
}],
|
|
775
|
+
["POST", /^\/discovery$/, async (_, { body }) => {
|
|
776
|
+
if (typeof body.root !== "string" || !body.root) throw fail("bad_request", "root is required");
|
|
777
|
+
const { jobId, done } = startDiscovery(body.root);
|
|
778
|
+
track(done.then(() => events.invalidate({ type: "library" })));
|
|
779
|
+
return { status: 202, body: { jobId } };
|
|
780
|
+
}],
|
|
781
|
+
["GET", /^\/decks\/([^/]+)$/, async ([id]) => {
|
|
782
|
+
const deck = resolveDeck(id);
|
|
783
|
+
const d = await describe(deck);
|
|
784
|
+
const tasksMod = await loadTasks();
|
|
785
|
+
const tasks = tasksMod ? await tasksMod.listTasks(deck.path, {}) : [];
|
|
786
|
+
const { remote, sync } = await remoteCard(deck);
|
|
787
|
+
// where the deck stands (the same ladder `slidemind ready <deck>` prints): intake → plan gate →
|
|
788
|
+
// concepts → sourcing → authoring → draft check → review → deliver; a read of the disk, never a guess
|
|
789
|
+
let stage = null;
|
|
790
|
+
try {
|
|
791
|
+
const { deckStage } = await import("../cli/status.mjs");
|
|
792
|
+
const st = await deckStage(deck.workspaceDir);
|
|
793
|
+
stage = { name: st.name, next: st.next, plan: st.plan, slides: st.slides, draftCheck: st.draftCheck, review: st.review, route: st.route };
|
|
794
|
+
} catch {
|
|
795
|
+
stage = null;
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
deck: { id: deck.id, name: deck.name, path: deck.path, missing: false, lastSeenAt: deck.lastSeenAt, format: deckFormat(deck.path) },
|
|
799
|
+
adapter: { id: d.adapter.id, blocker: d.adapter.blocker ?? null, owner: d.adapter.owner.owner },
|
|
800
|
+
views: d.views,
|
|
801
|
+
actions: d.actions,
|
|
802
|
+
tasks,
|
|
803
|
+
stage,
|
|
804
|
+
dirty: dirtyState(deck.workspaceDir, sync),
|
|
805
|
+
preview: previewState(deck.workspaceDir),
|
|
806
|
+
pptx: (() => {
|
|
807
|
+
const f = deckPptx(deck);
|
|
808
|
+
return f ? path.basename(f) : null;
|
|
809
|
+
})(),
|
|
810
|
+
remote,
|
|
811
|
+
sync,
|
|
812
|
+
};
|
|
813
|
+
}],
|
|
814
|
+
// Drop the library entry only; the deck's files are never touched.
|
|
815
|
+
// DELETE /decks/:id — drop the registry entry. `files: "trash"` also moves the
|
|
816
|
+
// deck folder to the OS trash: destructive, so it takes the deck's OWN realpath
|
|
817
|
+
// from the registry (never a path from the page), refuses a filesystem root or
|
|
818
|
+
// the home dir, and runs as a deck operation so a live agent session answers
|
|
819
|
+
// `deck_busy` instead of losing its work. Nothing here ever calls fs.rm.
|
|
820
|
+
["DELETE", /^\/decks\/([^/]+)$/, async ([id], { body }) => {
|
|
821
|
+
if (!UUID.test(String(id))) throw fail("unknown_deck", "deck ids are registry UUIDs");
|
|
822
|
+
if (body?.files !== "trash") return { removed: await removeDeckAsync(id) };
|
|
823
|
+
const deck = getDeck(id);
|
|
824
|
+
if (!deck) throw fail("unknown_deck", `no registered deck ${id}`);
|
|
825
|
+
if (!fs.existsSync(deck.path)) return { removed: await removeDeckAsync(id), trashed: null };
|
|
826
|
+
const real = fs.realpathSync(deck.path);
|
|
827
|
+
if (path.dirname(real) === real || real === os.homedir()) throw fail("not_a_deck", `${real} is not a deck folder`);
|
|
828
|
+
await runDeckOperation(resolveWorkspace(real), { kind: "trash-deck", command: "workspace delete", owner: false }, () => trashFolder(real));
|
|
829
|
+
return { removed: await removeDeckAsync(id), trashed: real };
|
|
830
|
+
}],
|
|
831
|
+
["GET", /^\/decks\/([^/]+)\/plan$/, async ([id]) => readPlan(resolveDeck(id))],
|
|
832
|
+
// The pack's plan page for the sandboxed frame: HTML from the installed template only.
|
|
833
|
+
["GET", /^\/decks\/([^/]+)\/render\/plan$/, async ([id], { res }) => {
|
|
834
|
+
const deck = resolveDeck(id);
|
|
835
|
+
const a = adapterOf(deck);
|
|
836
|
+
if (a.blocker) throw fail(a.blocker.code, a.blocker.message);
|
|
837
|
+
if (!a.entry) {
|
|
838
|
+
// Core-owned deck: the page IS the deck's plan.html, re-rendered from
|
|
839
|
+
// plan.md so it never lags a save. The gate's read-only banner names a
|
|
840
|
+
// CLI command; here the Markdown editor sits next to the page, so it goes.
|
|
841
|
+
const { writePlanPage, withPlanWriteChannel, resolvePlanLayouts, planDesignOptions, loadPlanState, normalizePlanComments, planSubjectHash, planApprovedExactly } = await import("../builders/plan.mjs");
|
|
842
|
+
const out = writePlanPage(deck.workspaceDir);
|
|
843
|
+
if (!out) throw fail("not_found", "this deck has no plan yet");
|
|
844
|
+
// the same live write channel `slidemind plan` hands the page, pointed at this daemon
|
|
845
|
+
const html = withPlanWriteChannel(fs.readFileSync(out, "utf8"), {
|
|
846
|
+
embedded: true,
|
|
847
|
+
endpoint: `${PREFIX}/decks/${deck.id}/plan-review?t=${gateTokenFor(deck.id)}`,
|
|
848
|
+
heartbeat: `${PREFIX}/decks/${deck.id}/plan-heartbeat`,
|
|
849
|
+
round: await gateRound(deck, "plan-approval", planSubjectHash(deck.workspaceDir)),
|
|
850
|
+
// the version this page shows: a decision names it, so an answer never covers a plan the human did not see
|
|
851
|
+
subjectHash: planSubjectHash(deck.workspaceDir),
|
|
852
|
+
deckName: deck.name ?? path.basename(deck.path),
|
|
853
|
+
deckId: deck.id,
|
|
854
|
+
// the picker's chrome thumbnails come off this daemon, not the gate's port
|
|
855
|
+
layouts: resolvePlanLayouts(deck.workspaceDir).layouts.map((l) => ({ ...l, previewUrl: l.previewUrl ? `${PREFIX}/decks/${deck.id}${l.previewUrl}` : null })),
|
|
856
|
+
// the design picker: every cached design, its palette's thumbnails off this daemon too;
|
|
857
|
+
// the rest of the menu (designs the backend serves) loads after the page does
|
|
858
|
+
designs: planDesignOptions(deck.workspaceDir, { mapUrl: (u) => `${PREFIX}/decks/${deck.id}${u}` }),
|
|
859
|
+
designsUrl: `${PREFIX}/decks/${deck.id}/plan-designs`,
|
|
860
|
+
designsGate: gateTokenFor(deck.id),
|
|
861
|
+
logoMark: null,
|
|
862
|
+
ledgerSeed: normalizePlanComments(loadPlanState(deck.workspaceDir).comments || []),
|
|
863
|
+
// this exact plan.md is approved (hash-bound): the gate mutes Approve and says so
|
|
864
|
+
approved: planApprovedExactly(deck.workspaceDir) ? { at: loadPlanState(deck.workspaceDir).at ?? null, by: loadPlanState(deck.workspaceDir).actor ?? null } : null,
|
|
865
|
+
// has the agent asked for this review? (a pending task) — before that, approving is premature
|
|
866
|
+
asked: (await pendingOf(deck, "plan-approval"))[0]?.id ?? null,
|
|
867
|
+
});
|
|
868
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
869
|
+
res.end(html);
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
const data = adapterResult(await dispatchAdapter(a.entry, { method: "render", deckRoot: deck.path, payload: { viewId: "plan" } }));
|
|
873
|
+
if (typeof data?.html !== "string") throw fail("extension_incompatible", "the adapter returned no html for the plan view");
|
|
874
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
875
|
+
res.end(data.html);
|
|
876
|
+
return null;
|
|
877
|
+
}],
|
|
878
|
+
// The embedded gates' wire (the standalone gates' /plan-review, /review and
|
|
879
|
+
// their heartbeats, deck-scoped): a decision lands on the waiting task.
|
|
880
|
+
["POST", /^\/decks\/([^/]+)\/plan-review$/, async ([id], { body }) => gateDecision(resolveDeck(id), "plan-approval", body)],
|
|
881
|
+
// The plan gate's design picker: the whole menu (cached designs + every design
|
|
882
|
+
// a connected backend serves), and a picked one this machine has not
|
|
883
|
+
// downloaded yet — materialized here, outside any agent sandbox.
|
|
884
|
+
["GET", /^\/decks\/([^/]+)\/plan-designs$/, async ([id]) => {
|
|
885
|
+
const deck = resolveDeck(id);
|
|
886
|
+
const { listPlanDesigns } = await import("../builders/plan.mjs");
|
|
887
|
+
return { designs: await listPlanDesigns(deck.workspaceDir, { mapUrl: (u) => `${PREFIX}/decks/${deck.id}${u}`, listBrands: deps.designs?.listBrands ?? null }) };
|
|
888
|
+
}],
|
|
889
|
+
["POST", /^\/decks\/([^/]+)\/plan-designs\/([^/]+)$/, async ([id, presFP]) => {
|
|
890
|
+
const deck = resolveDeck(id);
|
|
891
|
+
const fp = decodeURIComponent(presFP);
|
|
892
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(fp)) throw fail("bad_request", "not a design id");
|
|
893
|
+
const { downloadPlanDesign } = await import("../builders/plan.mjs");
|
|
894
|
+
try {
|
|
895
|
+
return { design: await downloadPlanDesign(deck.workspaceDir, fp, { mapUrl: (u) => `${PREFIX}/decks/${deck.id}${u}`, materialize: deps.designs?.materialize ?? null }) };
|
|
896
|
+
} catch (err) {
|
|
897
|
+
if (err?.code === "unknown_design") throw fail("not_found", err.message);
|
|
898
|
+
throw err;
|
|
899
|
+
}
|
|
900
|
+
}],
|
|
901
|
+
["POST", /^\/decks\/([^/]+)\/review$/, async ([id], { body }) => gateDecision(resolveDeck(id), "preview-review", body)],
|
|
902
|
+
// layout chrome thumbnails for the embedded plan gate (the gate's /brand/chrome/<file>,
|
|
903
|
+
// another cached design's with `?design=` for the design picker)
|
|
904
|
+
["GET", /^\/decks\/([^/]+)\/brand\/chrome\/([^/]+)$/, async ([id, name], { res, url }) => {
|
|
905
|
+
const deck = resolveDeck(id);
|
|
906
|
+
const { planChromeDir } = await import("../builders/plan.mjs");
|
|
907
|
+
const chromeDir = planChromeDir(deck.workspaceDir, url.searchParams.get("design"));
|
|
908
|
+
const file = path.basename(name);
|
|
909
|
+
if (!chromeDir || !/^chrome-[\w-]+\.(png|webp|jpe?g)$/i.test(file)) throw fail("not_found", "no such chrome image");
|
|
910
|
+
let data;
|
|
911
|
+
try {
|
|
912
|
+
data = fs.readFileSync(path.join(chromeDir, file));
|
|
913
|
+
} catch {
|
|
914
|
+
throw fail("not_found", "no such chrome image");
|
|
915
|
+
}
|
|
916
|
+
res.writeHead(200, { "Content-Type": MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream", "Cache-Control": "no-store" });
|
|
917
|
+
res.end(data);
|
|
918
|
+
return null;
|
|
919
|
+
}],
|
|
920
|
+
["GET", /^\/decks\/([^/]+)\/plan-heartbeat$/, async ([id]) => {
|
|
921
|
+
const deck = resolveDeck(id);
|
|
922
|
+
const { planSubjectHash } = await import("../builders/plan.mjs");
|
|
923
|
+
return gateBeat(deck, "plan-approval", "slidemind-plan", planSubjectHash(deck.workspaceDir));
|
|
924
|
+
}],
|
|
925
|
+
["GET", /^\/decks\/([^/]+)\/preview-heartbeat$/, async ([id]) => {
|
|
926
|
+
const deck = resolveDeck(id);
|
|
927
|
+
const scan = previewScan(deck.workspaceDir);
|
|
928
|
+
return gateBeat(deck, "preview-review", "slidemind-review", previewHashCached(deck.workspaceDir, scan), { scan });
|
|
929
|
+
}],
|
|
930
|
+
// GET /decks/:id/tasks/:taskId/wait?timeout=<s> — `slidemind tasks wait` (scripts) long-polls
|
|
931
|
+
// here; the response is the saved task once it settles. A CLI wait may carry its edit
|
|
932
|
+
// session's owner token (`X-Slidemind-Owner`): the answer to this task then re-enters that session.
|
|
933
|
+
["GET", /^\/decks\/([^/]+)\/tasks\/([^/]+)\/wait$/, async ([id, taskId], { url, req, res, who }) => {
|
|
934
|
+
const deck = resolveDeck(id);
|
|
935
|
+
const tasksMod = await need(loadTasks, "tasks");
|
|
936
|
+
const asked = Number(url.searchParams.get("timeout"));
|
|
937
|
+
// 0 is a real answer ("tell me now"), not "use the default"
|
|
938
|
+
const timeoutMs = Math.min(Math.max(url.searchParams.has("timeout") && Number.isFinite(asked) ? asked : 45, 0), 3600) * 1000;
|
|
939
|
+
const key = `${deck.id}:${taskId}`;
|
|
940
|
+
const owner = who?.kind === "cli" && typeof req.headers["x-slidemind-owner"] === "string" ? req.headers["x-slidemind-owner"] : null;
|
|
941
|
+
const bump = (d) => {
|
|
942
|
+
const w = waiters.get(key) ?? { open: 0, owner: null };
|
|
943
|
+
w.open = Math.max(0, w.open + d);
|
|
944
|
+
if (d > 0 && owner) w.owner = owner;
|
|
945
|
+
if (w.open === 0) w.owner = null;
|
|
946
|
+
waiters.set(key, w);
|
|
947
|
+
};
|
|
948
|
+
const ac = new AbortController();
|
|
949
|
+
req.on("close", () => ac.abort());
|
|
950
|
+
bump(1);
|
|
951
|
+
let out;
|
|
952
|
+
try {
|
|
953
|
+
out = typeof tasksMod.waitTask === "function" ? await tasksMod.waitTask(deck.path, taskId, { timeoutMs, signal: ac.signal }) : await pollTask(tasksMod, deck.path, taskId, timeoutMs, ac.signal);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
bump(-1);
|
|
956
|
+
throw err;
|
|
957
|
+
}
|
|
958
|
+
bump(-1);
|
|
959
|
+
if (ac.signal.aborted || res.writableEnded) return null;
|
|
960
|
+
sendJson(res, 200, { ok: true, task: out.task, pending: out.pending });
|
|
961
|
+
return null;
|
|
962
|
+
}],
|
|
963
|
+
["PUT", /^\/decks\/([^/]+)\/plan$/, async ([id], { body }) => {
|
|
964
|
+
const deck = resolveDeck(id);
|
|
965
|
+
if (!body.operationId || !body.base || typeof body.base.hash !== "string" || body.value === undefined) {
|
|
966
|
+
throw fail("bad_request", "operationId, base:{hash,value} and value are required");
|
|
967
|
+
}
|
|
968
|
+
const edit = await need(loadEdit, "plan editing");
|
|
969
|
+
const adapter = adapterOf(deck);
|
|
970
|
+
if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
|
|
971
|
+
const syncMod = await loadSync();
|
|
972
|
+
if (syncMod?.approvalInProgress?.(deck.workspaceDir)) throw fail("approval_in_progress", "an approval of this plan is in flight; your edits are kept and can be saved once it settles");
|
|
973
|
+
const result = await edit.savePlanLocal({ workspaceDir: deck.workspaceDir, adapter, operationId: body.operationId, base: body.base, value: body.value, owner: false });
|
|
974
|
+
if (result?.code) throw fail(result.code, result.message ?? result.code, result);
|
|
975
|
+
events.invalidate({ type: "plan", deckId: deck.id });
|
|
976
|
+
// An edit made here, on exactly the version the editor loaded, is the
|
|
977
|
+
// reviewer's own: a pending plan task follows it rather than going stale.
|
|
978
|
+
if (result?.ok && result.previous === body.base.hash) {
|
|
979
|
+
const tasksMod = await loadTasks();
|
|
980
|
+
const moved = (await tasksMod?.followPlanEdit?.(deck.path, { from: result.previous, to: result.hash }, { owner: false })) ?? [];
|
|
981
|
+
if (moved.length) events.invalidate({ type: "tasks", deckId: deck.id });
|
|
982
|
+
}
|
|
983
|
+
return result;
|
|
984
|
+
}],
|
|
985
|
+
["POST", /^\/decks\/([^/]+)\/actions\/([^/]+)$/, async ([id, action], { body }) => {
|
|
986
|
+
const deck = resolveDeck(id);
|
|
987
|
+
const d = await describe(deck);
|
|
988
|
+
if (d.adapter.blocker) throw fail(d.adapter.blocker.code, d.adapter.blocker.message);
|
|
989
|
+
if (!d.actions.includes(action)) throw fail("not_found", `${action} is not an action this deck offers`);
|
|
990
|
+
const method = ACTION_METHOD[action];
|
|
991
|
+
if (!method) throw fail("not_implemented", `${action} has no workspace binding yet`);
|
|
992
|
+
const result = await track(
|
|
993
|
+
runDeckOperation(deck.workspaceDir, { operationId: body.operationId, kind: action, expected: body.expectedHash ?? null, owner: false }, (op) =>
|
|
994
|
+
dispatchAdapter(d.adapter.entry, {
|
|
995
|
+
method,
|
|
996
|
+
deckRoot: deck.path,
|
|
997
|
+
payload: { expectedHash: body.expectedHash ?? null, ...(body.payload ?? {}) },
|
|
998
|
+
operationId: op.intent.id,
|
|
999
|
+
ownerToken: op.token,
|
|
1000
|
+
}),
|
|
1001
|
+
),
|
|
1002
|
+
);
|
|
1003
|
+
events.invalidate({ type: "plan", deckId: deck.id });
|
|
1004
|
+
return { result: adapterResult(result) };
|
|
1005
|
+
}],
|
|
1006
|
+
["GET", /^\/tasks$/, async (_, { url }) => {
|
|
1007
|
+
const tasksMod = await need(loadTasks, "tasks");
|
|
1008
|
+
const status = url.searchParams.get("status") ?? undefined;
|
|
1009
|
+
const deckId = url.searchParams.get("deck");
|
|
1010
|
+
const decks = deckId ? [resolveDeck(deckId)] : listDecks({ limit: 100 }).decks.filter((d) => !d.missing);
|
|
1011
|
+
const tasks = [];
|
|
1012
|
+
for (const d of decks) for (const t of await tasksMod.listTasks(d.path, { status })) tasks.push({ ...t, deckId: d.id, deckName: d.name ?? null, deckPath: d.path });
|
|
1013
|
+
return { tasks };
|
|
1014
|
+
}],
|
|
1015
|
+
["POST", /^\/decks\/([^/]+)\/tasks$/, async ([id], { body, url }) => {
|
|
1016
|
+
const deck = resolveDeck(id);
|
|
1017
|
+
if (!taskActionsFor(body.kind)) throw fail("bad_request", "kind is required (plan-edit, plan-approval, preview-review, sync-conflict)");
|
|
1018
|
+
if (deckFormat(deck.path) === "legacy") throw fail("unsupported_format", "this deck still uses the pre-workspace layout — its migration into .slidemind/ failed, see the workspace log");
|
|
1019
|
+
const tasksMod = await need(loadTasks, "tasks");
|
|
1020
|
+
const adapter = adapterOf(deck);
|
|
1021
|
+
if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
|
|
1022
|
+
const { adapterId, subject } = await deriveTaskSubject({ deckRoot: deck.path, adapter, kind: body.kind });
|
|
1023
|
+
const out = await tasksMod.requestTask(deck.path, { kind: body.kind, adapterId, subject }, { owner: false, verify: taskEvidenceVerifier({ deckRoot: deck.path, adapter }) });
|
|
1024
|
+
const task = out?.task ?? out;
|
|
1025
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId: task?.id });
|
|
1026
|
+
return { task, existing: out?.existing ?? false, url: `${url.origin}/deck/${deck.id}/${viewOf(task?.kind)}` };
|
|
1027
|
+
}],
|
|
1028
|
+
// POST /decks/:id/attention — the agent just asked (`slidemind plan|review`, `tasks request`):
|
|
1029
|
+
// tell an open page (SSE) so it shows the question. Nothing opens a browser: the user
|
|
1030
|
+
// follows the link the agent posted (doc 21).
|
|
1031
|
+
["POST", /^\/decks\/([^/]+)\/attention$/, async ([id], { body, who }) => {
|
|
1032
|
+
if (who?.kind !== "cli") throw fail("unauthorized", "only the CLI announces a question");
|
|
1033
|
+
const deck = resolveDeck(id);
|
|
1034
|
+
// `slidemind workspace <deck>` with a page already open: show that deck there
|
|
1035
|
+
if (body.focus === true) {
|
|
1036
|
+
events.invalidate({ type: "focus", deckId: deck.id });
|
|
1037
|
+
return { pages: events.clientCount ?? 0, focused: true };
|
|
1038
|
+
}
|
|
1039
|
+
const kind = body.kind === "preview-review" ? "preview-review" : "plan-approval";
|
|
1040
|
+
const taskId = typeof body.taskId === "string" ? body.taskId : null;
|
|
1041
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId });
|
|
1042
|
+
// The same question asked again (the user said "continue" before answering)
|
|
1043
|
+
// is told to the page once (it switches tab / shows a toast), never again.
|
|
1044
|
+
const key = taskId ?? deck.id;
|
|
1045
|
+
const first = !attended.has(key);
|
|
1046
|
+
if (first) {
|
|
1047
|
+
attended.add(key);
|
|
1048
|
+
events.invalidate({ type: "attention", deckId: deck.id, kind, taskId });
|
|
1049
|
+
}
|
|
1050
|
+
return { pages: events.clientCount ?? 0, first };
|
|
1051
|
+
}],
|
|
1052
|
+
// Put a question away without answering it: the human says it no longer applies.
|
|
1053
|
+
// "cancelled", not "superseded" — nothing replaced it, someone dismissed it.
|
|
1054
|
+
["POST", /^\/decks\/([^/]+)\/tasks\/([^/]+)\/dismiss$/, async ([id, taskId]) => {
|
|
1055
|
+
const deck = resolveDeck(id);
|
|
1056
|
+
const tasksMod = await need(loadTasks, "tasks");
|
|
1057
|
+
const task = await tasksMod.cancelTask(deck.path, taskId, { owner: false });
|
|
1058
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId });
|
|
1059
|
+
return { task };
|
|
1060
|
+
}],
|
|
1061
|
+
["POST", /^\/decks\/([^/]+)\/tasks\/([^/]+)\/respond$/, async ([id, taskId], { body }) => {
|
|
1062
|
+
const deck = resolveDeck(id);
|
|
1063
|
+
if (!body.operationId || typeof body.action !== "string") throw fail("bad_request", "operationId and action are required");
|
|
1064
|
+
const tasksMod = await need(loadTasks, "tasks");
|
|
1065
|
+
const adapter = adapterOf(deck);
|
|
1066
|
+
if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
|
|
1067
|
+
const payload = body.payload ?? {};
|
|
1068
|
+
const { actor, selfDeclared } = taskActor(payload);
|
|
1069
|
+
const verify = taskEvidenceVerifier({ deckRoot: deck.path, adapter });
|
|
1070
|
+
let result;
|
|
1071
|
+
try {
|
|
1072
|
+
result = await track(respond(deck.path, taskId, { operationId: body.operationId, expectedHash: body.expectedHash ?? null, action: body.action, payload, actor, selfDeclared }, { adapter, owner: ownerOn(deck.id, taskId) ?? false, verify }));
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
// the content moved on under the task: void it and tell the page to ask again about what is there now
|
|
1075
|
+
if (err?.code === "stale_subject" && err.details?.supersede) {
|
|
1076
|
+
await tasksMod.supersedeTask?.(deck.path, taskId, { owner: false });
|
|
1077
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId });
|
|
1078
|
+
}
|
|
1079
|
+
throw err;
|
|
1080
|
+
}
|
|
1081
|
+
events.invalidate({ type: "tasks", deckId: deck.id, taskId });
|
|
1082
|
+
// what the page copies for the user to paste: the answer plus the command that receives it
|
|
1083
|
+
const answered = result?.task ?? null;
|
|
1084
|
+
const receive = receiveCommand({ kind: answered?.kind, adapterId: answered?.adapterId, deckPath: deck.path, taskId });
|
|
1085
|
+
const message = answered ? pasteMessage({ kind: answered.kind, action: body.action, receive }) : null;
|
|
1086
|
+
return { ...result, receive, message };
|
|
1087
|
+
}],
|
|
1088
|
+
["GET", /^\/jobs\/([^/]+)$/, async ([id]) => {
|
|
1089
|
+
const job = readJob(id);
|
|
1090
|
+
if (!job) throw fail("unknown_job", `no job ${id}`);
|
|
1091
|
+
return { job };
|
|
1092
|
+
}],
|
|
1093
|
+
["GET", /^\/events$/, async (_, { req, res }) => {
|
|
1094
|
+
events.subscribe(req, res);
|
|
1095
|
+
return null;
|
|
1096
|
+
}],
|
|
1097
|
+
// wave 3 (remote association / autosync) replaces these; the shapes are the contract
|
|
1098
|
+
// Link intake: every network step is a job (202 + id); the page polls GET /jobs/:id.
|
|
1099
|
+
["POST", /^\/remote\/resolve$/, async (_, { body }) => {
|
|
1100
|
+
if (typeof body.url !== "string" || !body.url) throw fail("bad_request", "url is required");
|
|
1101
|
+
if (body.deckId) resolveDeck(body.deckId);
|
|
1102
|
+
const links = await need(loadLinks, "remote links");
|
|
1103
|
+
const job = await track(links.resolveRemoteLink({ url: body.url, backendURL: body.backendURL ?? null, deckId: body.deckId ?? null }, remoteDeps));
|
|
1104
|
+
if (job.result?.deckId) events.invalidate({ type: "library" });
|
|
1105
|
+
return { status: 202, body: { jobId: job.id, job } };
|
|
1106
|
+
}],
|
|
1107
|
+
["GET", /^\/remote\/children$/, async (_, { url }) => {
|
|
1108
|
+
const selectionId = url.searchParams.get("selection");
|
|
1109
|
+
if (!selectionId) throw fail("bad_request", "selection is required");
|
|
1110
|
+
const links = await need(loadLinks, "remote links");
|
|
1111
|
+
return links.listSelectionChildren({ selectionId, cursor: url.searchParams.get("cursor") }, remoteDeps);
|
|
1112
|
+
}],
|
|
1113
|
+
["POST", /^\/jobs\/([^/]+)\/select$/, async ([id], { body }) => {
|
|
1114
|
+
if (!body.selectionId || !body.choiceId) throw fail("bad_request", "selectionId and choiceId are required");
|
|
1115
|
+
if (body.localTarget) resolveDeck(body.localTarget);
|
|
1116
|
+
const links = await need(loadLinks, "remote links");
|
|
1117
|
+
const job = await track(links.selectChoice(id, { selectionId: body.selectionId, choiceId: body.choiceId, localTarget: body.localTarget ?? null }, remoteDeps));
|
|
1118
|
+
if (job.result?.deckId) events.invalidate({ type: "library" });
|
|
1119
|
+
return { status: 202, body: { jobId: job.id, job } };
|
|
1120
|
+
}],
|
|
1121
|
+
["POST", /^\/decks\/([^/]+)\/remote\/connect$/, async ([id], { body }) => {
|
|
1122
|
+
const deck = resolveDeck(id);
|
|
1123
|
+
if (!body.folderRef || (typeof body.folderRef !== "string" && !(body.folderRef.driveId && body.folderRef.itemId))) {
|
|
1124
|
+
throw fail("bad_request", "folderRef is a link or {driveId,itemId}");
|
|
1125
|
+
}
|
|
1126
|
+
const st = await need(loadStatus, "remote destinations");
|
|
1127
|
+
const result = await track(st.connectDestination({ deckRoot: deck.path, folderRef: body.folderRef, pptxName: body.pptxName ?? null, backendURL: body.backendURL ?? null }, remoteDeps));
|
|
1128
|
+
events.invalidate({ type: "plan", deckId: deck.id });
|
|
1129
|
+
// an editable plan with writes enabled: the record is created in the background (7.7)
|
|
1130
|
+
let activation = null;
|
|
1131
|
+
if (result.shouldCreatePlan) {
|
|
1132
|
+
const syncMod = await loadSync();
|
|
1133
|
+
if (typeof syncMod?.activateDestination === "function") {
|
|
1134
|
+
activation = { started: true };
|
|
1135
|
+
track(syncMod.activateDestination(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// a deck built before its folder was connected: that build goes up now;
|
|
1139
|
+
// every later build publishes itself (build-pptx)
|
|
1140
|
+
let upload = null;
|
|
1141
|
+
if (result.shouldPublish && typeof st.uploadToDestination === "function") {
|
|
1142
|
+
upload = { started: true };
|
|
1143
|
+
track(st.uploadToDestination(deck.path, remoteDeps).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
|
|
1144
|
+
}
|
|
1145
|
+
return { ...result, activation, upload, remote: (await remoteCard(deck)).remote };
|
|
1146
|
+
}],
|
|
1147
|
+
// autosync: every network step is a 202; the page re-GETs the deck for the outcome
|
|
1148
|
+
["POST", /^\/decks\/([^/]+)\/remote\/sync$/, async ([id]) => {
|
|
1149
|
+
const deck = resolveDeck(id);
|
|
1150
|
+
const drainOnce = await fn(loadSync, "drainOnce", "plan synchronization");
|
|
1151
|
+
track(drainOnce(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
|
|
1152
|
+
return { status: 202, body: { sync: await syncCard(deck.workspaceDir) } };
|
|
1153
|
+
}],
|
|
1154
|
+
["POST", /^\/decks\/([^/]+)\/remote\/refresh$/, async ([id]) => {
|
|
1155
|
+
const deck = resolveDeck(id);
|
|
1156
|
+
const refreshOnce = await fn(loadSync, "refreshOnce", "shared plan refresh");
|
|
1157
|
+
track(refreshOnce(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
|
|
1158
|
+
return { status: 202, body: { sync: await syncCard(deck.workspaceDir) } };
|
|
1159
|
+
}],
|
|
1160
|
+
["POST", /^\/decks\/([^/]+)\/remote\/activate$/, async ([id]) => {
|
|
1161
|
+
const deck = resolveDeck(id);
|
|
1162
|
+
const activateDestination = await fn(loadSync, "activateDestination", "shared plan creation");
|
|
1163
|
+
// a published PPTX without a saved folder implies one: its parent (one provider round, needs a session)
|
|
1164
|
+
const st = await loadStatus();
|
|
1165
|
+
if (st?.ensureDestination) {
|
|
1166
|
+
try {
|
|
1167
|
+
await st.ensureDestination(deck.path, {}, remoteDeps);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
if (!err?.code) throw err;
|
|
1170
|
+
throw fail(err.code, err.message, err.details);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
track(activateDestination(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
|
|
1174
|
+
return { status: 202, body: { sync: await syncCard(deck.workspaceDir) } };
|
|
1175
|
+
}],
|
|
1176
|
+
["POST", /^\/decks\/([^/]+)\/conflicts\/resolve$/, async ([id], { body }) => {
|
|
1177
|
+
const deck = resolveDeck(id);
|
|
1178
|
+
if (typeof body.conflictId !== "string" || typeof body.expectedHash !== "string" || body.value === undefined) {
|
|
1179
|
+
throw fail("bad_request", "conflictId, expectedHash and value are required");
|
|
1180
|
+
}
|
|
1181
|
+
const resolveConflict = await fn(loadSync, "resolveConflict", "conflict resolution");
|
|
1182
|
+
const result = await track(resolveConflict(deck.workspaceDir, { adapter: adapterOf(deck), owner: false, conflictId: body.conflictId, expectedHash: body.expectedHash, value: body.value }));
|
|
1183
|
+
if (result?.ok === false || result?.code) throw fail(result.code, result.message ?? result.code, { ...result, supersede: result.supersede === true });
|
|
1184
|
+
events.invalidate({ type: "plan", deckId: deck.id });
|
|
1185
|
+
events.invalidate({ type: "tasks", deckId: deck.id });
|
|
1186
|
+
return { ...result, sync: await syncCard(deck.workspaceDir) };
|
|
1187
|
+
}],
|
|
1188
|
+
];
|
|
1189
|
+
|
|
1190
|
+
const handle = async function handle(req, res, { url }) {
|
|
1191
|
+
const pathname = url.pathname;
|
|
1192
|
+
// Static surfaces (authenticated like everything else).
|
|
1193
|
+
const who = auth.authenticate(req);
|
|
1194
|
+
// The session slides inside authenticate() on ANY request — the page shell and
|
|
1195
|
+
// its assets included — so the cookie is re-sent right here, whatever answers:
|
|
1196
|
+
// an active user is never signed out on a date fixed at their first sign-in.
|
|
1197
|
+
if (who?.kind === "browser" && typeof auth.renewalHeader === "function") {
|
|
1198
|
+
const renew = auth.renewalHeader(req);
|
|
1199
|
+
if (renew) res.setHeader("Set-Cookie", renew);
|
|
1200
|
+
}
|
|
1201
|
+
const isApi = pathname.startsWith(PREFIX);
|
|
1202
|
+
if (!isApi) {
|
|
1203
|
+
if (req.method !== "GET") return false;
|
|
1204
|
+
// the app shell: `/`, `/deck/<id>…` — unauthenticated, it only holds the link-key exchange
|
|
1205
|
+
if (pathname === "/" || pathname.startsWith("/deck/")) return serveContained(uiRoot, "index.html", res) || false;
|
|
1206
|
+
if (!pathname.startsWith("/ui/")) return false;
|
|
1207
|
+
// the stylesheet is linked before the bootstrap exchange; it carries nothing private
|
|
1208
|
+
if (!who && pathname !== "/ui/styles.css") return sendError(res, 401, "unauthorized", "sign in through `slidemind workspace`"), true;
|
|
1209
|
+
return serveFiles(pathname, res);
|
|
1210
|
+
}
|
|
1211
|
+
const rel = pathname.slice(PREFIX.length) || "/";
|
|
1212
|
+
try {
|
|
1213
|
+
if (rel === "/session" && req.method === "POST") {
|
|
1214
|
+
const s = auth.exchange((await readJson(req)).bootstrap);
|
|
1215
|
+
if (!s) throw fail("unauthorized", "the link carries no key of this workspace");
|
|
1216
|
+
res.setHeader("Set-Cookie", auth.cookieHeader(s.cookie));
|
|
1217
|
+
return sendJson(res, 200, { ok: true, csrfToken: s.csrfToken }), true;
|
|
1218
|
+
}
|
|
1219
|
+
// A tab that holds the cookie but not this tab's CSRF token (a second tab,
|
|
1220
|
+
// a reload after sessionStorage was cleared) asks for it here; only the
|
|
1221
|
+
// cookie holder can read the answer, so nothing new is exposed.
|
|
1222
|
+
if (rel === "/session" && req.method === "GET") {
|
|
1223
|
+
if (who?.kind !== "browser") throw fail("unauthorized", "no browser session");
|
|
1224
|
+
return sendJson(res, 200, { ok: true, csrfToken: who.csrfToken }), true;
|
|
1225
|
+
}
|
|
1226
|
+
if (!who) throw fail("unauthorized", "missing or invalid credential");
|
|
1227
|
+
const previewPage = rel.match(/^\/decks\/([^/]+)\/preview\/presentation\.html$/);
|
|
1228
|
+
if (previewPage && req.method === "GET") {
|
|
1229
|
+
const deck = resolveDeck(previewPage[1]);
|
|
1230
|
+
let pageHtml;
|
|
1231
|
+
try {
|
|
1232
|
+
pageHtml = previewPageHtml(deck);
|
|
1233
|
+
} catch {
|
|
1234
|
+
return sendError(res, 404, "not_found", "no preview built yet"), true;
|
|
1235
|
+
}
|
|
1236
|
+
if (url.searchParams.has("thumb")) {
|
|
1237
|
+
// the rail's per-slide frames: the same page, without the gate
|
|
1238
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
1239
|
+
res.end(pageHtml);
|
|
1240
|
+
return true;
|
|
1241
|
+
}
|
|
1242
|
+
// the deck's own preview page, handed the live review gate (pins,
|
|
1243
|
+
// approve, comments) exactly as `slidemind review` serves it
|
|
1244
|
+
const logo = reviewLogoFile(deck.workspaceDir);
|
|
1245
|
+
const html = injectReview(
|
|
1246
|
+
pageHtml,
|
|
1247
|
+
{
|
|
1248
|
+
embedded: true,
|
|
1249
|
+
deck: deck.name ?? deck.id,
|
|
1250
|
+
ledgerSeed: normalizeReviewComments(loadReviewState(deck.workspaceDir).comments || []),
|
|
1251
|
+
endpoint: `${PREFIX}/decks/${deck.id}/review?t=${gateTokenFor(deck.id)}`,
|
|
1252
|
+
heartbeat: `${PREFIX}/decks/${deck.id}/preview-heartbeat`,
|
|
1253
|
+
// the deck passed its one review: the gate mutes Approve and says later changes go straight into the .pptx
|
|
1254
|
+
// (an adopted deck never needed one: settled the same way, never worded as an approval)
|
|
1255
|
+
approved: ((a) => (a ? { at: a.at, by: a.actor } : isEditRouteDeck(deck.workspaceDir) ? { at: null, by: null, notNeeded: true } : null))(loadReviewState(deck.workspaceDir).approval),
|
|
1256
|
+
asked: (await pendingOf(deck, "preview-review"))[0]?.id ?? null,
|
|
1257
|
+
// the version this page shows: a decision names it, so an approval never covers slides nobody saw
|
|
1258
|
+
subjectHash: previewBuiltFrom(deck.workspaceDir) ?? previewHash(deck.workspaceDir),
|
|
1259
|
+
deckName: deck.name ?? path.basename(deck.path),
|
|
1260
|
+
deckId: deck.id,
|
|
1261
|
+
// the slides changed after this preview was built: what the page shows is not what would be approved
|
|
1262
|
+
previewStale: previewState(deck.workspaceDir)?.stale === true,
|
|
1263
|
+
},
|
|
1264
|
+
fs.readFileSync(REVIEW_OVERLAY_PATH, "utf8"),
|
|
1265
|
+
logo ? `${PREFIX}/decks/${deck.id}/brand/assets/${logo}` : null,
|
|
1266
|
+
await gateRound(deck, "preview-review", previewHash(deck.workspaceDir)),
|
|
1267
|
+
);
|
|
1268
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
1269
|
+
res.end(html);
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
if (rel.match(/^\/decks\/[^/]+\/(?:preview|assets|brand\/assets)\//)) return serveFiles(pathname, res) || (sendError(res, 404, "not_found", "no such file"), true);
|
|
1273
|
+
let matched = null;
|
|
1274
|
+
let params = null;
|
|
1275
|
+
let methodSeen = false;
|
|
1276
|
+
for (const [method, re, fn] of routes) {
|
|
1277
|
+
const m = rel.match(re);
|
|
1278
|
+
if (!m) continue;
|
|
1279
|
+
methodSeen = true;
|
|
1280
|
+
if (method !== req.method) continue;
|
|
1281
|
+
matched = fn;
|
|
1282
|
+
params = m.slice(1);
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
if (!matched) {
|
|
1286
|
+
if (methodSeen) throw fail("method_not_allowed", `${req.method} not allowed here`);
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
const mutation = req.method !== "GET" && req.method !== "HEAD";
|
|
1290
|
+
const gateToken = url.searchParams.get("t");
|
|
1291
|
+
// Both halves must be REAL: an unknown `t` reads back undefined, and off the
|
|
1292
|
+
// two gate routes the captured deck id is undefined too — `undefined === undefined`
|
|
1293
|
+
// would wave any junk `?t=` past both the CSRF and the origin check on every
|
|
1294
|
+
// mutation route, so the deck id is required to exist before it can match.
|
|
1295
|
+
const gatedDeck = (rel.match(/^\/decks\/([^/]+)\/(?:plan-review|review|plan-designs\/[^/]+)$/) ?? [])[1];
|
|
1296
|
+
const gated = !!gateToken && gatedDeck !== undefined && gateTokenValid(gateToken, gatedDeck);
|
|
1297
|
+
if (mutation && !gated) {
|
|
1298
|
+
const denied = auth.allowMutation(req, who, `http://${req.headers.host}`);
|
|
1299
|
+
if (denied) throw fail(denied, denied === "csrf_required" ? "X-CSRF-Token missing or wrong" : "the request did not come from this workspace's page");
|
|
1300
|
+
}
|
|
1301
|
+
const body = mutation ? await readJson(req) : {};
|
|
1302
|
+
const out = await matched(params, { body, req, res, url: new URL(pathname + url.search, `http://${req.headers.host}`), who });
|
|
1303
|
+
if (out === null) return true; // the handler owns the response (SSE)
|
|
1304
|
+
if (out && typeof out === "object" && "status" in out && "body" in out && Object.keys(out).length === 2) {
|
|
1305
|
+
return sendJson(res, out.status, { ok: true, ...out.body }), true;
|
|
1306
|
+
}
|
|
1307
|
+
return sendJson(res, 200, { ok: true, ...out }), true;
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
if (res.headersSent) return true;
|
|
1310
|
+
const { status, code, retryable } = mapCode(err);
|
|
1311
|
+
const body = { ok: false, error: { code, message: err?.message ?? "internal error", retryable } };
|
|
1312
|
+
if (err?.details && typeof err.details === "object") body.error.details = err.details;
|
|
1313
|
+
// who holds the deck, so the page can say "your agent is working on it since …" and retry
|
|
1314
|
+
if (code === "deck_busy" && err?.busy && typeof err.busy === "object") {
|
|
1315
|
+
body.error.details = { ...(body.error.details ?? {}), since: err.busy.startedAt ?? null, command: err.busy.command ?? null, session: err.busy.kind === "session" };
|
|
1316
|
+
}
|
|
1317
|
+
sendJson(res, status, body);
|
|
1318
|
+
return true;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
// Every module a route imports lazily, loaded NOW: a background `npm install -g`
|
|
1322
|
+
// that swaps the package files under a running daemon cannot leave a route
|
|
1323
|
+
// half-loaded (ENOENT, or new code against old dependencies) — the daemon keeps
|
|
1324
|
+
// serving the code it started with until it restarts itself onto the new one.
|
|
1325
|
+
handle.preload = async () => {
|
|
1326
|
+
await Promise.all([loadTasks(), loadEdit(), loadLinks(), loadStatus(), loadSync(), import("../cli/status.mjs"), import("../builders/plan.mjs")].map((p) => Promise.resolve(p).catch(() => null)));
|
|
1327
|
+
};
|
|
1328
|
+
/** Agent waits open right now — the daemon restarts itself onto new code only when this is 0. */
|
|
1329
|
+
handle.openWaits = openWaits;
|
|
1330
|
+
return handle;
|
|
1331
|
+
}
|