slidemind 0.3.20 → 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/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,360 @@
|
|
|
1
|
+
// The workspace process. Run detached by lifecycle.mjs (`node daemon.mjs`) or
|
|
2
|
+
// in-process for `--foreground`. Owns the descriptor for its lifetime, serves
|
|
3
|
+
// the loopback API, logs to a bounded, redacted file, and drains on stop.
|
|
4
|
+
//
|
|
5
|
+
// Stop = refuse new mutations, wait up to 5s for in-flight local commits, let
|
|
6
|
+
// registered hooks mark unsettled network work for readback, remove OUR
|
|
7
|
+
// descriptor (pid + nonce match — never someone else's), close the listener.
|
|
8
|
+
// The process then ends on its own: nothing here calls process.exit.
|
|
9
|
+
//
|
|
10
|
+
// The config dir is read ONCE, at start: the descriptor, the log, the session
|
|
11
|
+
// store and the remembered port all live there for this process's lifetime,
|
|
12
|
+
// whatever the environment says later (a test that restores XDG_CONFIG_HOME
|
|
13
|
+
// while an in-process daemon is still draining must not write the real one).
|
|
14
|
+
//
|
|
15
|
+
// It looks after itself while it runs:
|
|
16
|
+
// every 30 s the descriptor still names us — another live workspace owns it
|
|
17
|
+
// → drain and leave; missing or unreadable → write ours back;
|
|
18
|
+
// every 60 s (detached only) newer code on disk (an npm auto-update, a
|
|
19
|
+
// release) → restart into it once idle. Sessions and the port
|
|
20
|
+
// survive, so an open page reconnects by itself.
|
|
21
|
+
|
|
22
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import { scrub } from "../bridge/secrets.mjs";
|
|
28
|
+
import { readPackageJson } from "../cli/common.mjs";
|
|
29
|
+
import { planPageOwner, resolveWorkspace } from "../lib/deck-paths.mjs";
|
|
30
|
+
import { resolveWorkspaceAdapter } from "./adapters.mjs";
|
|
31
|
+
import { autoDiscover } from "./discovery.mjs";
|
|
32
|
+
import { codeStamp, compareCode, descriptorPath, lastPortPath, logPath, pidAlive, probe, readDescriptor, rotateLogs } from "./lifecycle.mjs";
|
|
33
|
+
import { listDecks, workspaceConfigDir, writeJsonAtomic } from "./registry.mjs";
|
|
34
|
+
import { createAuth } from "./auth.mjs";
|
|
35
|
+
import { events as defaultEvents } from "./events.mjs";
|
|
36
|
+
import { createRoutes } from "./routes.mjs";
|
|
37
|
+
import { createServer, homePort, PROTOCOL_VERSION } from "./server.mjs";
|
|
38
|
+
import { startSyncLoop } from "./sync.mjs";
|
|
39
|
+
|
|
40
|
+
const DRAIN_MS = 5_000;
|
|
41
|
+
// newer code this process already failed to restart into: not retried until the disk moves again
|
|
42
|
+
let failedStamp = null;
|
|
43
|
+
const SELF_CHECK_MS = 30_000;
|
|
44
|
+
const CODE_CHECK_MS = 60_000;
|
|
45
|
+
const REDACT = [
|
|
46
|
+
[/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1«redacted»"],
|
|
47
|
+
[/([?&](?:sig|signature|token|access_token|tempauth|code)=)[^&\s"']+/gi, "$1«redacted»"],
|
|
48
|
+
[/(sk-|eyJ)[A-Za-z0-9._-]{16,}/g, "«redacted»"],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Append one redacted line to the daemon log (`file`, default the current config dir's), rotating at the 5 MiB cap. */
|
|
52
|
+
export function log(line, file = logPath()) {
|
|
53
|
+
let text = scrub(String(line));
|
|
54
|
+
for (const [re, rep] of REDACT) text = text.replace(re, rep);
|
|
55
|
+
try {
|
|
56
|
+
rotateLogs(file);
|
|
57
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${text}\n`);
|
|
58
|
+
} catch {
|
|
59
|
+
// logging never takes the daemon down
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readLastPort(dir) {
|
|
64
|
+
try {
|
|
65
|
+
const port = JSON.parse(fs.readFileSync(lastPortPath(dir), "utf8"))?.port;
|
|
66
|
+
return Number.isInteger(port) ? port : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Start the workspace in this process. Resolves `{ url, descriptor, stopped, stop() }`
|
|
74
|
+
* once listening; `stopped` resolves after a drain completes (stop route, SIGTERM/SIGINT,
|
|
75
|
+
* or `stop()`). Without `handle` the full API (routes.mjs) is served; `deps` are
|
|
76
|
+
* its injection points (tests). `restart()` drains, closes and re-execs a fresh
|
|
77
|
+
* detached process through lifecycle — modules are never reloaded in place.
|
|
78
|
+
* `deps.storeDir` (default the config dir; null = memory only) keeps browser
|
|
79
|
+
* sessions across restarts; `deps.watchCode` (default: only the detached
|
|
80
|
+
* child) restarts into newer code on disk; `deps.selfCheckMs` /
|
|
81
|
+
* `deps.codeCheckMs` tune the two timers.
|
|
82
|
+
*/
|
|
83
|
+
export async function runDaemon({ handle = null, hooks = {}, deps = {} } = {}) {
|
|
84
|
+
const configDir = workspaceConfigDir();
|
|
85
|
+
const descriptorFile = descriptorPath(configDir);
|
|
86
|
+
const logFile = logPath(configDir);
|
|
87
|
+
const dlog = (line) => log(line, logFile);
|
|
88
|
+
const bus = deps.events ?? defaultEvents;
|
|
89
|
+
const inflight = new Set();
|
|
90
|
+
const stopHooks = [];
|
|
91
|
+
const timers = [];
|
|
92
|
+
const track = (p) => {
|
|
93
|
+
inflight.add(p);
|
|
94
|
+
// the caller handles the outcome; the tracker only counts
|
|
95
|
+
p.finally(() => inflight.delete(p)).catch(() => {});
|
|
96
|
+
return p;
|
|
97
|
+
};
|
|
98
|
+
let resolveStopped;
|
|
99
|
+
const stopped = new Promise((r) => (resolveStopped = r));
|
|
100
|
+
let draining = null;
|
|
101
|
+
|
|
102
|
+
const descriptor = {
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
105
|
+
pid: process.pid,
|
|
106
|
+
nonce: randomUUID(),
|
|
107
|
+
url: null,
|
|
108
|
+
startedAt: new Date().toISOString(),
|
|
109
|
+
cliVersion: readPackageJson().version,
|
|
110
|
+
code: deps.codeStamp ?? codeStamp(),
|
|
111
|
+
cliBearer: randomBytes(32).toString("base64url"),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const drain = async () => {
|
|
115
|
+
if (draining) return draining;
|
|
116
|
+
draining = (async () => {
|
|
117
|
+
// /health says so from now on: nobody reuses a workspace on its way out
|
|
118
|
+
api.state.draining = true;
|
|
119
|
+
for (const t of timers) clearInterval(t);
|
|
120
|
+
dlog(`stop requested; ${inflight.size} local operation(s) in flight`);
|
|
121
|
+
let cap;
|
|
122
|
+
const timer = new Promise((r) => (cap = setTimeout(r, DRAIN_MS)));
|
|
123
|
+
await Promise.race([Promise.allSettled([...inflight]), timer]);
|
|
124
|
+
clearTimeout(cap); // a pending cap would hold the process for its full 5 s after the drain
|
|
125
|
+
for (const hook of stopHooks) {
|
|
126
|
+
try {
|
|
127
|
+
await hook();
|
|
128
|
+
} catch (err) {
|
|
129
|
+
dlog(`stop hook failed: ${err?.message ?? err}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const current = readDescriptor(configDir);
|
|
133
|
+
if (current?.pid === descriptor.pid && current?.nonce === descriptor.nonce) {
|
|
134
|
+
fs.rmSync(descriptorFile, { force: true });
|
|
135
|
+
}
|
|
136
|
+
dlog("stopped");
|
|
137
|
+
})();
|
|
138
|
+
return draining;
|
|
139
|
+
};
|
|
140
|
+
// Signal / programmatic stop: drain, then close. The stop ROUTE drains via
|
|
141
|
+
// onStop and closes itself after its response has left.
|
|
142
|
+
const shutdown = () => drain().then(() => bus.close()).then(() => api.close());
|
|
143
|
+
// `{ resume }`: when the new process cannot start, the detached workspace
|
|
144
|
+
// comes back up on the code it already has loaded rather than leave the page
|
|
145
|
+
// with nothing (the code watcher passes it; a requested restart reports).
|
|
146
|
+
const restart = async ({ resume = false } = {}) => {
|
|
147
|
+
await shutdown();
|
|
148
|
+
// an in-process workspace whose environment moved (a test restoring its env)
|
|
149
|
+
// must not start a workspace for some other config dir
|
|
150
|
+
if (workspaceConfigDir() !== configDir) return dlog("restart skipped: the config dir changed under this process");
|
|
151
|
+
const { ensureRunning } = await import("./lifecycle.mjs");
|
|
152
|
+
try {
|
|
153
|
+
const next = await ensureRunning({ note: dlog });
|
|
154
|
+
dlog(`restarted as pid ${next.pid} at ${next.url}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
dlog(`restart failed: ${err?.message ?? err}`);
|
|
157
|
+
if (!resume) return;
|
|
158
|
+
try {
|
|
159
|
+
// it still runs the old code: say so, and the next CLI replaces it when it can
|
|
160
|
+
await runDaemon({ handle, hooks, deps: { ...deps, codeStamp: descriptor.code } });
|
|
161
|
+
dlog("resumed on the code already loaded");
|
|
162
|
+
} catch (again) {
|
|
163
|
+
dlog(`could not resume: ${again?.message ?? again}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let auth;
|
|
169
|
+
try {
|
|
170
|
+
auth = createAuth({ bearer: descriptor.cliBearer, storeDir: deps.storeDir === undefined ? configDir : deps.storeDir });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// an unwritable store costs persistence, never the workspace
|
|
173
|
+
dlog(`session store unavailable (${err?.code ?? err?.message ?? err}); sessions last until this process ends`);
|
|
174
|
+
auth = createAuth({ bearer: descriptor.cliBearer });
|
|
175
|
+
}
|
|
176
|
+
const scanning = new Set(); // auto-discovery job ids still running (the library page says "Scanning…")
|
|
177
|
+
const routes = handle ?? createRoutes({ auth, deps: { track, restart, events: bus, scanning: () => [...scanning], ...deps } });
|
|
178
|
+
// Load every lazily imported route module now: a background `npm install -g`
|
|
179
|
+
// that swaps the files under a running workspace must not break its next request.
|
|
180
|
+
if (typeof routes.preload === "function") {
|
|
181
|
+
try {
|
|
182
|
+
await routes.preload();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
dlog(`preload failed: ${err?.message ?? err}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const lastPort = readLastPort(configDir);
|
|
188
|
+
const api = createServer({
|
|
189
|
+
bearer: descriptor.cliBearer,
|
|
190
|
+
nonce: descriptor.nonce,
|
|
191
|
+
pid: descriptor.pid,
|
|
192
|
+
pendingJobs: () => (hooks.pendingJobs ? hooks.pendingJobs() : 0) + inflight.size,
|
|
193
|
+
pages: () => bus.clientCount,
|
|
194
|
+
onStop: drain,
|
|
195
|
+
handle: routes,
|
|
196
|
+
ports: [lastPort, homePort()],
|
|
197
|
+
});
|
|
198
|
+
api.server.on("close", () => resolveStopped());
|
|
199
|
+
// The sender for every registered pack-owned deck; a stop marks what is on
|
|
200
|
+
// the wire as outcome-unknown so the next start reads back before resending.
|
|
201
|
+
if (deps.sync !== false) {
|
|
202
|
+
// a drain that applied something, or settled a crashed approval, is news for an open page
|
|
203
|
+
const observe = (p) => {
|
|
204
|
+
p.then((r) => {
|
|
205
|
+
if (!r || typeof r !== "object") return;
|
|
206
|
+
if (r.action === "applied" || r.action === "recovered" || r.action === "created") bus.invalidate({ type: "plan" });
|
|
207
|
+
if (r.approval?.ok) bus.invalidate({ type: "tasks" });
|
|
208
|
+
}).catch(() => {});
|
|
209
|
+
return track(p);
|
|
210
|
+
};
|
|
211
|
+
const sync = startSyncLoop({
|
|
212
|
+
// the registry of OUR config dir only (see the header)
|
|
213
|
+
decks: () => (workspaceConfigDir() === configDir ? listDecks({ limit: 1000 }).decks.filter((d) => !d.missing).map((d) => resolveWorkspace(d.path)) : []),
|
|
214
|
+
adapterFor: (ws) => {
|
|
215
|
+
const owner = planPageOwner(ws).owner;
|
|
216
|
+
if (!owner || owner === "core") return null;
|
|
217
|
+
const r = resolveWorkspaceAdapter(owner === "extension" ? null : owner);
|
|
218
|
+
return r.entry ? r : null;
|
|
219
|
+
},
|
|
220
|
+
track: observe,
|
|
221
|
+
log: dlog,
|
|
222
|
+
...(deps.sync ?? {}),
|
|
223
|
+
});
|
|
224
|
+
stopHooks.push(sync.stop);
|
|
225
|
+
}
|
|
226
|
+
descriptor.url = await api.listen();
|
|
227
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
228
|
+
writeJsonAtomic(descriptorFile, descriptor, { mode: 0o600 });
|
|
229
|
+
// The remembered port is the page's home: the browser's cookie and every link
|
|
230
|
+
// an agent already handed out name it. A start pushed off it (something held
|
|
231
|
+
// it for a moment) serves elsewhere but keeps the home, so the next start goes
|
|
232
|
+
// back; only a workspace with no home yet records where it landed.
|
|
233
|
+
const boundPort = Number(new URL(descriptor.url).port);
|
|
234
|
+
if (lastPort === null || boundPort === lastPort) {
|
|
235
|
+
try {
|
|
236
|
+
writeJsonAtomic(lastPortPath(configDir), { port: boundPort });
|
|
237
|
+
} catch {
|
|
238
|
+
// the next start just tries the preferred port first
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
dlog(`port ${lastPort} is taken; serving on ${boundPort} until the next start goes back to ${lastPort}`);
|
|
242
|
+
}
|
|
243
|
+
dlog(`listening on ${descriptor.url} (pid ${process.pid}, cli ${descriptor.cliVersion})`);
|
|
244
|
+
// A new CLI fills the library by itself: remembered roots + the decks root of
|
|
245
|
+
// the command that started us. Tests pass `autoDiscover:false`.
|
|
246
|
+
let discovery = null;
|
|
247
|
+
if (deps.autoDiscover !== false) {
|
|
248
|
+
discovery = autoDiscover({ version: descriptor.cliVersion, decksDir: deps.decksDir ?? process.env.SLIDEMIND_DECKS ?? null, log: dlog });
|
|
249
|
+
if (discovery) {
|
|
250
|
+
for (const id of discovery.jobIds) scanning.add(id);
|
|
251
|
+
track(
|
|
252
|
+
discovery.done.then((jobs) => {
|
|
253
|
+
scanning.clear();
|
|
254
|
+
dlog(`auto-discovery done: ${jobs.reduce((n, j) => n + j.found.length, 0)} deck(s) registered`);
|
|
255
|
+
bus.invalidate({ type: "library" });
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Is the descriptor still ours? Another live workspace owning it means we are
|
|
262
|
+
// an orphan (a hung process that woke up after being replaced): leave. A
|
|
263
|
+
// missing or broken one is written back — the workspace is running, and every
|
|
264
|
+
// CLI finds it only through that file.
|
|
265
|
+
let checking = false;
|
|
266
|
+
const selfCheck = async () => {
|
|
267
|
+
if (draining || checking) return;
|
|
268
|
+
checking = true;
|
|
269
|
+
try {
|
|
270
|
+
const cur = readDescriptor(configDir);
|
|
271
|
+
if (!cur || cur.malformed) {
|
|
272
|
+
writeJsonAtomic(descriptorFile, descriptor, { mode: 0o600 });
|
|
273
|
+
dlog("descriptor was missing; wrote it back");
|
|
274
|
+
} else if (cur.nonce !== descriptor.nonce) {
|
|
275
|
+
const p = await probe(cur);
|
|
276
|
+
if (p.live) {
|
|
277
|
+
dlog(`another workspace (pid ${cur.pid}) owns the descriptor; stopping this one`);
|
|
278
|
+
await shutdown();
|
|
279
|
+
} else if (p.draining || (p.unreachable === "timeout" && (cur.pid === process.pid || pidAlive(cur.pid)))) {
|
|
280
|
+
// busy (a timeout is not an absence: it may be the newer workspace at work)
|
|
281
|
+
// or on its way out (it removes its own descriptor): look again next tick
|
|
282
|
+
} else if (!draining) {
|
|
283
|
+
// definitively gone: refused or reset, its pid dead, or something else answering
|
|
284
|
+
writeJsonAtomic(descriptorFile, descriptor, { mode: 0o600 });
|
|
285
|
+
dlog(`descriptor named a workspace that is gone (pid ${cur.pid}); wrote ours back`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
dlog(`self-check failed: ${err?.message ?? err}`);
|
|
290
|
+
} finally {
|
|
291
|
+
checking = false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const every = (ms, fn) => {
|
|
295
|
+
const t = setInterval(fn, ms);
|
|
296
|
+
t.unref?.();
|
|
297
|
+
timers.push(t);
|
|
298
|
+
};
|
|
299
|
+
every(deps.selfCheckMs ?? SELF_CHECK_MS, selfCheck);
|
|
300
|
+
// Newer code on disk: restart into it once nothing is in flight and no agent
|
|
301
|
+
// is waiting on an answer through us. Only the detached process does this —
|
|
302
|
+
// a foreground or in-process workspace belongs to whoever runs it.
|
|
303
|
+
if (deps.watchCode ?? deps.isChild ?? false) {
|
|
304
|
+
let restarting = false;
|
|
305
|
+
let seen = null; // the newer stamp of the previous tick: an install still copying files moves it
|
|
306
|
+
every(deps.codeCheckMs ?? CODE_CHECK_MS, () => {
|
|
307
|
+
if (draining || restarting) return;
|
|
308
|
+
let disk;
|
|
309
|
+
try {
|
|
310
|
+
disk = codeStamp();
|
|
311
|
+
} catch {
|
|
312
|
+
return; // mid-install: the next tick sees the finished tree
|
|
313
|
+
}
|
|
314
|
+
if (compareCode(disk, descriptor.code) <= 0 || disk === failedStamp) return;
|
|
315
|
+
const settled = disk === seen;
|
|
316
|
+
seen = disk;
|
|
317
|
+
if (!settled) return; // the same newer tree on two ticks in a row, or wait
|
|
318
|
+
if (inflight.size > 0 || (routes.openWaits?.() ?? 0) > 0) return;
|
|
319
|
+
restarting = true;
|
|
320
|
+
failedStamp = disk; // should the new process fail to start, this process does not try the same tree again
|
|
321
|
+
dlog(`newer code on disk (${descriptor.code} → ${disk}); restarting into it`);
|
|
322
|
+
restart({ resume: true });
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const onSignal = () => {
|
|
327
|
+
shutdown();
|
|
328
|
+
};
|
|
329
|
+
process.once("SIGTERM", onSignal);
|
|
330
|
+
process.once("SIGINT", onSignal);
|
|
331
|
+
stopped.then(() => {
|
|
332
|
+
for (const t of timers) clearInterval(t);
|
|
333
|
+
process.off("SIGTERM", onSignal);
|
|
334
|
+
process.off("SIGINT", onSignal);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
url: descriptor.url,
|
|
339
|
+
descriptor,
|
|
340
|
+
api,
|
|
341
|
+
track,
|
|
342
|
+
auth,
|
|
343
|
+
onStop: (fn) => stopHooks.push(fn),
|
|
344
|
+
discovery,
|
|
345
|
+
stop: shutdown,
|
|
346
|
+
restart,
|
|
347
|
+
stopped,
|
|
348
|
+
selfCheck,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Child entry: `node src/workspace/daemon.mjs`. Uncaught errors go to the
|
|
353
|
+
// inherited log fd; a crash leaves the descriptor behind, and the next start
|
|
354
|
+
// finds it dead (health probe) and replaces it without touching any pid.
|
|
355
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
356
|
+
runDaemon({ deps: { isChild: true } }).catch((err) => {
|
|
357
|
+
log(`startup failed: ${err?.stack ?? err}`);
|
|
358
|
+
process.exitCode = 1;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Bounded deck discovery under a configured root.
|
|
2
|
+
//
|
|
3
|
+
// Breadth-first, four directory reads in flight, never through a symlink,
|
|
4
|
+
// never into hidden directories (that covers `.git`, `.cache`, `.slidemind`),
|
|
5
|
+
// `node_modules` or cache dirs. A directory that stands as a deck (see
|
|
6
|
+
// deck-paths `isDeckDir`) is registered and NOT descended into — so a deck's
|
|
7
|
+
// `.slidemind/scratch` is never reached, and a pack's deck shows up exactly once.
|
|
8
|
+
// Ordinary `.pptx` files are not decks: only the existing markers count.
|
|
9
|
+
//
|
|
10
|
+
// Job records live at `<sessionConfigDir>/workspace/jobs/<id>.json` and carry
|
|
11
|
+
// no tokens. Cancelling keeps every entry already registered.
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { isDeckDir } from "../lib/deck-paths.mjs";
|
|
18
|
+
import { addRoot, coded, readRegistry, recordRegistryVersion, registerDeckBestEffort, workspaceConfigDir, writeJsonAtomic } from "./registry.mjs";
|
|
19
|
+
|
|
20
|
+
const CONCURRENCY = 4;
|
|
21
|
+
// ponytail: fixed depth cap keeps a mistaken `discover ~` bounded; make it a root option if a real tree needs more.
|
|
22
|
+
const MAX_DEPTH = 8;
|
|
23
|
+
const SKIP_NAMES = new Set(["node_modules", "cache", "caches", "__pycache__", "dist", "build"]);
|
|
24
|
+
|
|
25
|
+
export function jobsDir() {
|
|
26
|
+
return path.join(workspaceConfigDir(), "jobs");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function jobPath(id) {
|
|
30
|
+
if (!/^[0-9a-f-]{36}$/.test(String(id))) throw coded("bad_job_id", "job ids are UUIDs");
|
|
31
|
+
return path.join(jobsDir(), `${id}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readJob(id) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(jobPath(id), "utf8"));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code === "ENOENT") return null;
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeJob(job) {
|
|
44
|
+
writeJsonAtomic(jobPath(job.id), job);
|
|
45
|
+
return job;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Directories the scan and the folder picker both leave out. */
|
|
49
|
+
export function skip(entry) {
|
|
50
|
+
return (
|
|
51
|
+
!entry.isDirectory() ||
|
|
52
|
+
entry.isSymbolicLink() ||
|
|
53
|
+
entry.name.startsWith(".") ||
|
|
54
|
+
SKIP_NAMES.has(entry.name)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remember `root` and scan it. Returns `{ jobId, done }`; `done` resolves to the
|
|
60
|
+
* final job record (`state` completed | cancelled | failed) and never rejects
|
|
61
|
+
* for a scan problem — those land in `warnings`. Pass an AbortSignal to cancel.
|
|
62
|
+
*/
|
|
63
|
+
export function startDiscovery(root, { signal = null } = {}) {
|
|
64
|
+
const job = {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
id: randomUUID(),
|
|
67
|
+
kind: "discovery",
|
|
68
|
+
root: path.resolve(root),
|
|
69
|
+
state: "queued",
|
|
70
|
+
found: [],
|
|
71
|
+
warnings: [],
|
|
72
|
+
startedAt: new Date().toISOString(),
|
|
73
|
+
finishedAt: null,
|
|
74
|
+
};
|
|
75
|
+
let real;
|
|
76
|
+
try {
|
|
77
|
+
real = addRoot(root);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
job.state = "failed";
|
|
80
|
+
job.finishedAt = job.startedAt;
|
|
81
|
+
job.warnings.push({ path: job.root, code: err.code ?? "inaccessible", message: err.message });
|
|
82
|
+
writeJob(job);
|
|
83
|
+
return { jobId: job.id, done: Promise.resolve(job) };
|
|
84
|
+
}
|
|
85
|
+
job.root = real;
|
|
86
|
+
job.state = "running";
|
|
87
|
+
writeJob(job);
|
|
88
|
+
return { jobId: job.id, done: scan(job, signal) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function scan(job, signal) {
|
|
92
|
+
const queue = [{ dir: job.root, depth: 0 }];
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
let active = 0;
|
|
95
|
+
await new Promise((resolve) => {
|
|
96
|
+
const pump = () => {
|
|
97
|
+
if (signal?.aborted) {
|
|
98
|
+
if (active === 0) resolve();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
while (active < CONCURRENCY && queue.length) {
|
|
102
|
+
const item = queue.shift();
|
|
103
|
+
active++;
|
|
104
|
+
visit(item).finally(() => {
|
|
105
|
+
active--;
|
|
106
|
+
pump();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (active === 0 && queue.length === 0) resolve();
|
|
110
|
+
};
|
|
111
|
+
const visit = async ({ dir, depth }) => {
|
|
112
|
+
let real;
|
|
113
|
+
try {
|
|
114
|
+
real = await fs.promises.realpath(dir);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
job.warnings.push({ path: dir, code: err.code ?? "inaccessible", message: err.message });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (seen.has(real)) return;
|
|
120
|
+
seen.add(real);
|
|
121
|
+
// A deck is registered and its inside is never walked: `.slidemind/` and
|
|
122
|
+
// whatever lives under it (the scratch deck) belong to this host deck.
|
|
123
|
+
if (isDeckDir(real)) {
|
|
124
|
+
const entry = registerDeckBestEffort(real);
|
|
125
|
+
if (entry) job.found.push(entry.id);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (depth >= MAX_DEPTH) return;
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await fs.promises.readdir(real, { withFileTypes: true });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
job.warnings.push({ path: real, code: err.code ?? "inaccessible", message: err.message });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
if (!skip(e)) queue.push({ dir: path.join(real, e.name), depth: depth + 1 });
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
pump();
|
|
141
|
+
});
|
|
142
|
+
job.state = signal?.aborted ? "cancelled" : "completed";
|
|
143
|
+
job.finishedAt = new Date().toISOString();
|
|
144
|
+
return writeJob(job);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The library fills itself on a new CLI: when the registry is missing or was
|
|
149
|
+
* populated by another version, record this one and scan every remembered
|
|
150
|
+
* root plus the invoking command's decks root (`decksDir`, lifecycle passes it
|
|
151
|
+
* as SLIDEMIND_DECKS). Roots nested under another are skipped — one scan
|
|
152
|
+
* covers them. Null when the library is current or the registry is
|
|
153
|
+
* unreadable (a malformed file is a recovery case, not a rescan).
|
|
154
|
+
* Returns `{ previousVersion, roots, jobIds, done }`.
|
|
155
|
+
*/
|
|
156
|
+
export function autoDiscover({ version, decksDir = null, log = () => {} }) {
|
|
157
|
+
let reg;
|
|
158
|
+
try {
|
|
159
|
+
reg = readRegistry();
|
|
160
|
+
} catch (err) {
|
|
161
|
+
log(`auto-discovery skipped: ${err.message}`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (reg.cliVersion === version) return null;
|
|
165
|
+
const roots = new Set(reg.roots);
|
|
166
|
+
if (decksDir) {
|
|
167
|
+
try {
|
|
168
|
+
const real = fs.realpathSync(decksDir);
|
|
169
|
+
if (fs.statSync(real).isDirectory()) roots.add(real);
|
|
170
|
+
} catch {
|
|
171
|
+
// the invoking command had no decks dir yet
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const sorted = [...roots].sort();
|
|
175
|
+
const top = sorted.filter((r) => !sorted.some((o) => o !== r && r.startsWith(o + path.sep)));
|
|
176
|
+
recordRegistryVersion(version);
|
|
177
|
+
const jobs = top.map((r) => startDiscovery(r));
|
|
178
|
+
log(`library populated by cli ${reg.cliVersion ?? "none"}, now ${version}: scanning ${top.length} root(s)`);
|
|
179
|
+
return { previousVersion: reg.cliVersion ?? null, roots: top, jobIds: jobs.map((j) => j.jobId), done: Promise.all(jobs.map((j) => j.done)) };
|
|
180
|
+
}
|