slidemind 0.3.20 → 0.4.1
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 +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- 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,1210 @@
|
|
|
1
|
+
import { assertNoActivation, recoverActivation, stageWorkspace } from './workspace-transaction.mjs';
|
|
2
|
+
// The local revision protocol: what the deck was ACCEPTED from, what is on disk
|
|
3
|
+
// RIGHT NOW, and the immutable candidates a build produced in between.
|
|
4
|
+
//
|
|
5
|
+
// Three states, deliberately separate (design `add-pptx-first-adoption` §1):
|
|
6
|
+
//
|
|
7
|
+
// state/base.pptx the accepted snapshot, byte-identical to the file
|
|
8
|
+
// adoption took delivery of. Immutable.
|
|
9
|
+
// slides/ assets/ … the WORKING inputs. Edited freely; never cleared
|
|
10
|
+
// by a build, never advanced by one.
|
|
11
|
+
// state/candidates/<id>/ an immutable rendered candidate + the evidence
|
|
12
|
+
// (base sha, generation, input digest, order,
|
|
13
|
+
// dependencies, output sha) it was built from.
|
|
14
|
+
//
|
|
15
|
+
// A build writes a candidate. It does NOT advance the base and does NOT clear
|
|
16
|
+
// localDirty — edit → build → build keeps the first edit, which is the whole
|
|
17
|
+
// point. Only an explicit `revision accept --candidate <id>` (local-only) or a
|
|
18
|
+
// confirmed remote receipt (the next change) moves the accepted base.
|
|
19
|
+
//
|
|
20
|
+
// Everything here is SYNC and filesystem-only. This module is the contract the
|
|
21
|
+
// SharePoint pull/publish change and the pack read, so the schemas are
|
|
22
|
+
// exported as constants and pinned by fixtures under test/fixtures/revision/.
|
|
23
|
+
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { statePath, writeStateFile } from "./deck-paths.mjs";
|
|
30
|
+
import { EDIT_OPERATIONS_FILE } from "./edit-operations.mjs";
|
|
31
|
+
import { SLIDE_PRESERVATION_FILE } from "./slide-preservation.mjs";
|
|
32
|
+
import { resolveBundleDirForDeck, readDeckBundleRef } from "../bridge/bundle.mjs";
|
|
33
|
+
import { bundleOwnedFiles } from "./deck-paths.mjs";
|
|
34
|
+
import { unzip, readText } from "../pptx/zip.mjs";
|
|
35
|
+
import { relsFor, resolvePartPath, slideOrderPaths } from "../pptx/ooxml.mjs";
|
|
36
|
+
import {
|
|
37
|
+
TAG_GUID,
|
|
38
|
+
computeProjection,
|
|
39
|
+
readSlideTags,
|
|
40
|
+
referencedAssets,
|
|
41
|
+
withoutChartHashes,
|
|
42
|
+
} from "../pptx/tags.mjs";
|
|
43
|
+
|
|
44
|
+
export const SCHEMA_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
// Every file this protocol owns, relative to `<workspace>/state/`. `clean` must
|
|
47
|
+
// keep all of them (they are the only record of what was accepted).
|
|
48
|
+
export const REVISION_FILE = "revision.json";
|
|
49
|
+
export const BUSY_FILE = "busy.json";
|
|
50
|
+
export const JOURNAL_FILE = "journal.json";
|
|
51
|
+
export const CONFLICTS_FILE = "conflicts.json";
|
|
52
|
+
export const BASE_PPTX = "base.pptx";
|
|
53
|
+
export const CANDIDATES_DIR = "candidates";
|
|
54
|
+
export const STAGED_DIR = "staged";
|
|
55
|
+
export const CANDIDATE_FILE = "candidate.json";
|
|
56
|
+
export const CANDIDATE_PPTX = "deck.pptx";
|
|
57
|
+
export const INCOMING_FILE = "incoming.json";
|
|
58
|
+
export const INCOMING_PPTX = "incoming.pptx";
|
|
59
|
+
// The remote half (src/lib/remote.mjs) keeps its records in the same directory
|
|
60
|
+
// and under the same rule: they are the only copy of what was published and of
|
|
61
|
+
// what an interrupted write may have done.
|
|
62
|
+
export const REMOTE_FILE = "remote.json";
|
|
63
|
+
export const OPERATIONS_DIR = "operations";
|
|
64
|
+
export const RECEIPTS_DIR = "receipts";
|
|
65
|
+
|
|
66
|
+
/** Everything under `<workspace>/state/` this protocol owns — `clean` skips these. */
|
|
67
|
+
export const PROTECTED_STATE = [
|
|
68
|
+
REVISION_FILE,
|
|
69
|
+
BUSY_FILE,
|
|
70
|
+
JOURNAL_FILE,
|
|
71
|
+
CONFLICTS_FILE,
|
|
72
|
+
BASE_PPTX,
|
|
73
|
+
CANDIDATES_DIR,
|
|
74
|
+
STAGED_DIR,
|
|
75
|
+
REMOTE_FILE,
|
|
76
|
+
OPERATIONS_DIR,
|
|
77
|
+
RECEIPTS_DIR,
|
|
78
|
+
// What an edit was allowed to change, and what the slide still owes
|
|
79
|
+
// afterwards (src/lib/edit-state.mjs). Requirements outlive the source they
|
|
80
|
+
// were taken from, so neither is ever a build artifact.
|
|
81
|
+
EDIT_OPERATIONS_FILE,
|
|
82
|
+
SLIDE_PRESERVATION_FILE,
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const sha256 = (data) =>
|
|
86
|
+
createHash("sha256")
|
|
87
|
+
.update(typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data))
|
|
88
|
+
.digest("hex");
|
|
89
|
+
|
|
90
|
+
const nowIso = () => new Date().toISOString();
|
|
91
|
+
|
|
92
|
+
function stateDir(workspaceDir, ...sub) {
|
|
93
|
+
return path.join(workspaceDir, "state", ...sub);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJson(file) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeJson(file, data) {
|
|
105
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
106
|
+
// Write-then-rename: a kill mid-write must not leave half a JSON record where
|
|
107
|
+
// the accepted base is supposed to be.
|
|
108
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
109
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
return file;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Atomic sidecar write for any `<workspace>/state/<name>.json`: the same
|
|
116
|
+
* write-then-rename `revision.json` gets, so a kill mid-write can never leave
|
|
117
|
+
* half a record where an obligation is supposed to be. The edit-safeguard
|
|
118
|
+
* sidecars (src/lib/edit-state.mjs) ride on this rather than on a second
|
|
119
|
+
* persistence layer.
|
|
120
|
+
*/
|
|
121
|
+
export function writeStateJson(workspaceDir, name, data) {
|
|
122
|
+
return writeJson(statePath(workspaceDir, name), data);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// package inventory — read the package, NOT the source decoder
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
// Whether a slide reaches a chart part. `computeProjection` here emits
|
|
130
|
+
// `chart: null` unconditionally (the documented parity divergence in tags.mjs),
|
|
131
|
+
// so a chart-bearing slide can never be proved unchanged from its projection
|
|
132
|
+
// alone — it is reported `unverified` rather than silently "same".
|
|
133
|
+
function slideIsChartBlind(files, part) {
|
|
134
|
+
for (const rel of relsFor(files, part)) {
|
|
135
|
+
if ((rel.type ?? "").endsWith("/chart") || (rel.type ?? "").endsWith("/chartEx")) return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every part a slide reaches, hashed together with the slide itself: the
|
|
141
|
+
// evidence a projection cannot carry (native objects, embeddings, animation,
|
|
142
|
+
// notes, comments). Slides are never traversed into — a slide is a root.
|
|
143
|
+
function slideClosureSha(files, part) {
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const queue = [part];
|
|
146
|
+
const hashes = [];
|
|
147
|
+
while (queue.length) {
|
|
148
|
+
const p = queue.shift();
|
|
149
|
+
if (!p || files[p] === undefined || seen.has(p)) continue;
|
|
150
|
+
seen.add(p);
|
|
151
|
+
hashes.push(`${p}:${sha256(files[p])}`);
|
|
152
|
+
const relsPath = `${p.slice(0, p.lastIndexOf("/"))}/_rels/${p.slice(p.lastIndexOf("/") + 1)}.rels`;
|
|
153
|
+
if (files[relsPath] !== undefined && !seen.has(relsPath)) {
|
|
154
|
+
seen.add(relsPath);
|
|
155
|
+
hashes.push(`${relsPath}:${sha256(files[relsPath])}`);
|
|
156
|
+
}
|
|
157
|
+
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
|
|
158
|
+
for (const rel of relsFor(files, p)) {
|
|
159
|
+
if (rel.targetMode === "External") continue;
|
|
160
|
+
const target = resolvePartPath(dir, rel.target);
|
|
161
|
+
// Masters and layouts are SHARED: hashing them into every slide would make
|
|
162
|
+
// one theme tweak dirty the whole deck. They ride the package hash instead.
|
|
163
|
+
if (/^ppt\/slide(Masters|Layouts)\//.test(target)) continue;
|
|
164
|
+
if (/^ppt\/slides\/slide\d+\.xml$/.test(target)) continue; // links, not content
|
|
165
|
+
queue.push(target);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return sha256(hashes.sort().join("\n"));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What a .pptx actually contains, read from the package and independent of any
|
|
173
|
+
* source tag payload. `decodeSourceTags` proves a payload is INTACT, never that
|
|
174
|
+
* it agrees with the slide — so nothing here consults it.
|
|
175
|
+
*
|
|
176
|
+
* Returns `{ sha256, slideCount, packageSha256, order: [guid…], slides: [{…}], parts }`.
|
|
177
|
+
*/
|
|
178
|
+
export function inventoryPackage(bytes) {
|
|
179
|
+
const buf = Buffer.from(bytes);
|
|
180
|
+
const files = unzip(buf);
|
|
181
|
+
const order = slideOrderPaths(files);
|
|
182
|
+
const slides = order.map((part, i) => {
|
|
183
|
+
const tags = readSlideTags(files, part);
|
|
184
|
+
const sldId = sldIdForPart(files, part);
|
|
185
|
+
return {
|
|
186
|
+
// A GUID tag survives source-tag removal, so it is read on its own.
|
|
187
|
+
guid: tags[TAG_GUID] ?? null,
|
|
188
|
+
part,
|
|
189
|
+
sldId,
|
|
190
|
+
index: i + 1,
|
|
191
|
+
projection: sha256(
|
|
192
|
+
JSON.stringify(withoutChartHashes(computeProjection(files, part))),
|
|
193
|
+
),
|
|
194
|
+
package: slideClosureSha(files, part),
|
|
195
|
+
chartBlind: slideIsChartBlind(files, part),
|
|
196
|
+
tagged: Boolean(tags[TAG_GUID]),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
sha256: sha256(buf),
|
|
201
|
+
slideCount: order.length,
|
|
202
|
+
// Every part except the slides themselves — masters, layouts, themes,
|
|
203
|
+
// presProps, the section list. A theme edit lands here and nowhere else.
|
|
204
|
+
packageSha256: sha256(
|
|
205
|
+
Object.keys(files)
|
|
206
|
+
.filter((p) => !/^ppt\/slides\//.test(p))
|
|
207
|
+
.sort()
|
|
208
|
+
.map((p) => `${p}:${sha256(files[p])}`)
|
|
209
|
+
.join("\n"),
|
|
210
|
+
),
|
|
211
|
+
order: slides.map((s) => s.guid),
|
|
212
|
+
slides,
|
|
213
|
+
parts: Object.keys(files).sort(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sldIdForPart(files, part) {
|
|
218
|
+
const pres = readText(files, "ppt/presentation.xml");
|
|
219
|
+
if (!pres) return null;
|
|
220
|
+
const rels = {};
|
|
221
|
+
for (const rel of relsFor(files, "ppt/presentation.xml")) {
|
|
222
|
+
if (rel.id) rels[rel.id] = resolvePartPath("ppt", rel.target);
|
|
223
|
+
}
|
|
224
|
+
const list = /<(?:[\w.-]+:)?sldIdLst[\s\S]*?<\/(?:[\w.-]+:)?sldIdLst>/.exec(pres);
|
|
225
|
+
if (!list) return null;
|
|
226
|
+
const re = /<(?:[\w.-]+:)?sldId\b([^>]*)>/g;
|
|
227
|
+
let m;
|
|
228
|
+
while ((m = re.exec(list[0])) !== null) {
|
|
229
|
+
const rId = /\b[\w.-]+:id\s*=\s*"([^"]+)"/.exec(m[1])?.[1];
|
|
230
|
+
if (rId && rels[rId] === part) return /\bid\s*=\s*"([^"]+)"/.exec(m[1])?.[1] ?? null;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// working inputs — what is on disk right now
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function walkFiles(dir, prefix, out, skip) {
|
|
240
|
+
let entries = [];
|
|
241
|
+
try {
|
|
242
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
247
|
+
const abs = path.join(dir, e.name);
|
|
248
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
249
|
+
if (e.isDirectory()) walkFiles(abs, rel, out, skip);
|
|
250
|
+
else if (e.isFile() && !skip.has(rel)) {
|
|
251
|
+
try {
|
|
252
|
+
out[rel] = sha256(fs.readFileSync(abs));
|
|
253
|
+
} catch {
|
|
254
|
+
/* unreadable — it simply carries no baseline this round */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The slide files in play order (filename order — jsx and native .pptx share it). */
|
|
261
|
+
export function slideFileOrder(workspaceDir) {
|
|
262
|
+
try {
|
|
263
|
+
return fs
|
|
264
|
+
.readdirSync(path.join(workspaceDir, "slides"))
|
|
265
|
+
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
266
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
267
|
+
} catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Everything a render reads, hashed by BYTES: slide sources, deck-owned assets
|
|
274
|
+
* and the conversion records (which carry the preserved native packages). Plus
|
|
275
|
+
* the effective shared dependency — the design the deck is bound to — because a
|
|
276
|
+
* corporate design that moved changes every slide's output without touching a
|
|
277
|
+
* single deck file.
|
|
278
|
+
*
|
|
279
|
+
* Copies of the design bundle a pre-workspace deck still carries are skipped,
|
|
280
|
+
* exactly as `hashDeckContent` skips them, so re-materializing an unchanged
|
|
281
|
+
* design cannot flip a deck dirty.
|
|
282
|
+
*/
|
|
283
|
+
export function materializedInputs(workspaceDir) {
|
|
284
|
+
const skip = bundleOwnedFiles(workspaceDir, resolveBundleDirForDeck(workspaceDir));
|
|
285
|
+
const inputs = {};
|
|
286
|
+
for (const sub of ["slides", "assets", "conversions"]) {
|
|
287
|
+
walkFiles(path.join(workspaceDir, sub), sub, inputs, skip);
|
|
288
|
+
}
|
|
289
|
+
const ref = readDeckBundleRef(workspaceDir);
|
|
290
|
+
return {
|
|
291
|
+
inputs,
|
|
292
|
+
order: slideFileOrder(workspaceDir),
|
|
293
|
+
shared: { design: ref?.presFP ? `${ref.presFP}@${ref.etag ?? ""}` : null },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** One hash over a materialized-input set — the value a candidate binds to. */
|
|
298
|
+
export function inputDigest(materialized) {
|
|
299
|
+
const { inputs, order, shared } = materialized;
|
|
300
|
+
return sha256(
|
|
301
|
+
JSON.stringify({
|
|
302
|
+
inputs: Object.keys(inputs)
|
|
303
|
+
.sort()
|
|
304
|
+
.map((k) => [k, inputs[k]]),
|
|
305
|
+
order,
|
|
306
|
+
shared,
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// slide file -> the assets its jsx references, so a changed image dirties every
|
|
312
|
+
// slide that draws it (an image-only edit must not read as "nothing changed").
|
|
313
|
+
function assetDependents(workspaceDir, order) {
|
|
314
|
+
const assetsDir = path.join(workspaceDir, "assets");
|
|
315
|
+
const map = new Map();
|
|
316
|
+
for (const file of order) {
|
|
317
|
+
if (!file.endsWith(".jsx")) continue;
|
|
318
|
+
let jsx;
|
|
319
|
+
try {
|
|
320
|
+
jsx = fs.readFileSync(path.join(workspaceDir, "slides", file), "utf8");
|
|
321
|
+
} catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
for (const asset of referencedAssets(jsx, assetsDir)) {
|
|
325
|
+
const list = map.get(asset.name) ?? [];
|
|
326
|
+
list.push(file);
|
|
327
|
+
map.set(asset.name, list);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return map;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Local differences against the accepted working-input baseline. Content,
|
|
335
|
+
* structure and dependencies all count: changed/added/removed slide files, a
|
|
336
|
+
* changed referenced asset, a changed conversion record (the preserved native
|
|
337
|
+
* bytes live there), slide ORDER, and the effective design.
|
|
338
|
+
*
|
|
339
|
+
* Hydration is not an edit: it records a new baseline materialization against
|
|
340
|
+
* the same accepted package, which `noteHydration` folds into the baseline.
|
|
341
|
+
*/
|
|
342
|
+
export function computeDirty(
|
|
343
|
+
workspaceDir,
|
|
344
|
+
revision = readRevision(workspaceDir),
|
|
345
|
+
materialized = materializedInputs(workspaceDir),
|
|
346
|
+
) {
|
|
347
|
+
const empty = {
|
|
348
|
+
localDirty: false,
|
|
349
|
+
dirtySlides: [],
|
|
350
|
+
added: [],
|
|
351
|
+
removed: [],
|
|
352
|
+
orderChanged: false,
|
|
353
|
+
sharedChanged: false,
|
|
354
|
+
unverified: [],
|
|
355
|
+
inputDigest: inputDigest(materialized),
|
|
356
|
+
};
|
|
357
|
+
if (!revision?.baseline) return { ...empty, localDirty: Object.keys(materialized.inputs).length > 0 };
|
|
358
|
+
|
|
359
|
+
const baseline = revision.baseline.inputs ?? {};
|
|
360
|
+
const now = materialized.inputs;
|
|
361
|
+
const dirty = new Set();
|
|
362
|
+
const added = [];
|
|
363
|
+
const removed = [];
|
|
364
|
+
|
|
365
|
+
const dependents = assetDependents(workspaceDir, materialized.order);
|
|
366
|
+
const touch = (rel) => {
|
|
367
|
+
if (rel.startsWith("slides/")) {
|
|
368
|
+
dirty.add(rel.slice("slides/".length));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (rel.startsWith("conversions/")) {
|
|
372
|
+
// conversions/<stem>.json and conversions/<stem>.slide.pptx both belong to
|
|
373
|
+
// slides/<stem>.jsx.
|
|
374
|
+
const stem = path.basename(rel).replace(/\.slide\.pptx$/, "").replace(/\.json$/, "");
|
|
375
|
+
dirty.add(`${stem}.jsx`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
for (const file of dependents.get(rel) ?? []) dirty.add(file);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
for (const [rel, hash] of Object.entries(now)) {
|
|
382
|
+
if (baseline[rel] === undefined) {
|
|
383
|
+
if (rel.startsWith("slides/")) added.push(rel.slice("slides/".length));
|
|
384
|
+
touch(rel);
|
|
385
|
+
} else if (baseline[rel] !== hash) {
|
|
386
|
+
touch(rel);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const rel of Object.keys(baseline)) {
|
|
390
|
+
if (now[rel] !== undefined) continue;
|
|
391
|
+
if (rel.startsWith("slides/")) removed.push(rel.slice("slides/".length));
|
|
392
|
+
touch(rel);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const orderChanged =
|
|
396
|
+
JSON.stringify(materialized.order) !== JSON.stringify(revision.baseline.order ?? []);
|
|
397
|
+
// A design that moved changes every slide's render without touching a file —
|
|
398
|
+
// but it is NOT a local edit, so it never joins `dirtySlides` (every consumer
|
|
399
|
+
// of that list means "unpublished edits": rebase conflicts, adopt/pull
|
|
400
|
+
// refusals, the build's render set). It is reported on its own; build-pptx
|
|
401
|
+
// re-renders the untouched slides only with --refresh-design.
|
|
402
|
+
const sharedChanged =
|
|
403
|
+
JSON.stringify(materialized.shared) !== JSON.stringify(revision.baseline.shared ?? {});
|
|
404
|
+
|
|
405
|
+
// Slides whose evidence is known-incomplete here: a chart's data lives in a
|
|
406
|
+
// spec hash this repo cannot produce (see the CHART HASH note in tags.mjs), so
|
|
407
|
+
// "no input changed" is not proof for them. Reported, never silently clean.
|
|
408
|
+
const unverified = (revision.base?.slides ?? [])
|
|
409
|
+
.filter((s) => s.chartBlind)
|
|
410
|
+
.map((s) => s.guid ?? s.part);
|
|
411
|
+
|
|
412
|
+
const dirtySlides = [...dirty].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
413
|
+
return {
|
|
414
|
+
localDirty:
|
|
415
|
+
dirtySlides.length > 0 || added.length > 0 || removed.length > 0 || orderChanged,
|
|
416
|
+
dirtySlides,
|
|
417
|
+
added: added.sort(),
|
|
418
|
+
removed: removed.sort(),
|
|
419
|
+
orderChanged,
|
|
420
|
+
sharedChanged,
|
|
421
|
+
unverified,
|
|
422
|
+
inputDigest: inputDigest(materialized),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// revision.json
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
export function revisionPath(workspaceDir) {
|
|
431
|
+
return statePath(workspaceDir, REVISION_FILE);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function readRevision(workspaceDir) {
|
|
435
|
+
assertNoActivation(workspaceDir);
|
|
436
|
+
const data = readJson(revisionPath(workspaceDir));
|
|
437
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Persist and return the RECORD — callers read the generation back off it. */
|
|
441
|
+
export function writeRevision(workspaceDir, revision) {
|
|
442
|
+
const record = { ...revision, schemaVersion: SCHEMA_VERSION };
|
|
443
|
+
writeJson(revisionPath(workspaceDir), record);
|
|
444
|
+
return record;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function acceptedBasePath(workspaceDir) {
|
|
448
|
+
return stateDir(workspaceDir, BASE_PPTX);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function readAcceptedBase(workspaceDir) {
|
|
452
|
+
assertNoActivation(workspaceDir);
|
|
453
|
+
try {
|
|
454
|
+
return fs.readFileSync(acceptedBasePath(workspaceDir));
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Record a fresh accepted base: the exact bytes adoption took delivery of, the
|
|
462
|
+
* package evidence read out of them, and the materialized working inputs that
|
|
463
|
+
* correspond to that package. Generation starts at 1 and only ever advances.
|
|
464
|
+
*/
|
|
465
|
+
export function recordAcceptedBase(workspaceDir, { bytes, origin }) {
|
|
466
|
+
const inventory = inventoryPackage(bytes);
|
|
467
|
+
const previous = readRevision(workspaceDir);
|
|
468
|
+
fs.mkdirSync(stateDir(workspaceDir), { recursive: true });
|
|
469
|
+
fs.writeFileSync(acceptedBasePath(workspaceDir), Buffer.from(bytes));
|
|
470
|
+
const materialized = materializedInputs(workspaceDir);
|
|
471
|
+
return writeRevision(workspaceDir, {
|
|
472
|
+
schemaVersion: SCHEMA_VERSION,
|
|
473
|
+
generation: (previous?.generation ?? 0) + 1,
|
|
474
|
+
origin: { ...origin, at: nowIso() },
|
|
475
|
+
base: {
|
|
476
|
+
sha256: inventory.sha256,
|
|
477
|
+
acceptedAt: nowIso(),
|
|
478
|
+
slideCount: inventory.slideCount,
|
|
479
|
+
packageSha256: inventory.packageSha256,
|
|
480
|
+
order: inventory.order,
|
|
481
|
+
slides: inventory.slides,
|
|
482
|
+
},
|
|
483
|
+
baseline: {
|
|
484
|
+
inputs: materialized.inputs,
|
|
485
|
+
order: materialized.order,
|
|
486
|
+
shared: materialized.shared,
|
|
487
|
+
at: nowIso(),
|
|
488
|
+
},
|
|
489
|
+
candidate: null,
|
|
490
|
+
staged: previous?.staged ?? [],
|
|
491
|
+
conflicts: null,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Every baseline key one slide file owns: the slide itself plus its conversion
|
|
496
|
+
// record and preserved package.
|
|
497
|
+
function baselineKeysFor(file) {
|
|
498
|
+
const stem = file.replace(/\.(jsx|pptx)$/, "");
|
|
499
|
+
return [`slides/${file}`, `conversions/${stem}.json`, `conversions/${stem}.slide.pptx`];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Hydration added working inputs for slides of the SAME accepted package. It is
|
|
504
|
+
* not an edit: the new files join the baseline, so they do not read as dirty —
|
|
505
|
+
* but every other slide's pending edit stays exactly where it was.
|
|
506
|
+
*
|
|
507
|
+
* `replaced` names the files the hydration WITHDREW (the native form a slide had
|
|
508
|
+
* before it became editable jsx). They leave the baseline with it; without that
|
|
509
|
+
* the vanished native file reads as a local deletion, which is the one thing
|
|
510
|
+
* hydration must not look like. Only the named files are dropped — a slide the
|
|
511
|
+
* user actually deleted is still a local change and stays dirty.
|
|
512
|
+
*/
|
|
513
|
+
export function noteHydration(workspaceDir, hydratedFiles, replaced = []) {
|
|
514
|
+
const revision = readRevision(workspaceDir);
|
|
515
|
+
if (!revision) return null;
|
|
516
|
+
const materialized = materializedInputs(workspaceDir);
|
|
517
|
+
const baseline = { ...(revision.baseline?.inputs ?? {}) };
|
|
518
|
+
for (const file of replaced) {
|
|
519
|
+
for (const key of baselineKeysFor(file)) delete baseline[key];
|
|
520
|
+
}
|
|
521
|
+
const claim = new Set();
|
|
522
|
+
for (const file of hydratedFiles) {
|
|
523
|
+
for (const key of baselineKeysFor(file)) claim.add(key);
|
|
524
|
+
}
|
|
525
|
+
for (const rel of Object.keys(materialized.inputs)) {
|
|
526
|
+
// Assets a freshly hydrated slide brought with it join the baseline too —
|
|
527
|
+
// they came out of the accepted package, so they are not a local edit.
|
|
528
|
+
if (claim.has(rel) || (rel.startsWith("assets/") && baseline[rel] === undefined)) {
|
|
529
|
+
baseline[rel] = materialized.inputs[rel];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return writeRevision(workspaceDir, {
|
|
533
|
+
...revision,
|
|
534
|
+
generation: revision.generation + 1,
|
|
535
|
+
baseline: {
|
|
536
|
+
...revision.baseline,
|
|
537
|
+
inputs: baseline,
|
|
538
|
+
order: materialized.order,
|
|
539
|
+
shared: materialized.shared,
|
|
540
|
+
at: nowIso(),
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// ownership — an exclusive create, not a read/check/write
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
// Seconds since boot at which THIS process started. A recycled pid carries a
|
|
550
|
+
// different value, so the pair (pid, processStart) identifies a process without
|
|
551
|
+
// a platform-specific /proc read.
|
|
552
|
+
// ponytail: whole-second resolution and a reboot resets the clock — both only
|
|
553
|
+
// ever make a stale owner look MORE stale, never a live one look dead.
|
|
554
|
+
// os.uptime() throws EPERM inside some agent sandboxes (codex exec seatbelt,
|
|
555
|
+
// observed 2026-09-18: every build-pptx died with "uv_uptime returned EPERM"
|
|
556
|
+
// and left a zero-byte busy.json behind). Fall back to epoch seconds; the two
|
|
557
|
+
// call sites below share the basis, so a mark compared within one process
|
|
558
|
+
// stays consistent.
|
|
559
|
+
// ponytail: a sandboxed and an unsandboxed process judging the same lock use
|
|
560
|
+
// different bases and read each other as recycled pids — only ever releases a
|
|
561
|
+
// lock, never wedges one; fine until two harnesses share one deck live.
|
|
562
|
+
function uptimeSeconds() {
|
|
563
|
+
try {
|
|
564
|
+
return os.uptime();
|
|
565
|
+
} catch {
|
|
566
|
+
return Date.now() / 1000;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function processStartMark() {
|
|
571
|
+
return Math.max(0, Math.round(uptimeSeconds() - process.uptime()));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Is this pid a live process? EPERM means it exists but belongs to someone
|
|
575
|
+
// else — another user, or another harness sandbox (Codex seatbelt answers
|
|
576
|
+
// EPERM across instances), so treating any throw as dead made every lock
|
|
577
|
+
// under a sandbox look free. Only ESRCH (and garbage) is dead.
|
|
578
|
+
export function pidAlive(pid) {
|
|
579
|
+
try {
|
|
580
|
+
process.kill(pid, 0);
|
|
581
|
+
return true;
|
|
582
|
+
} catch (err) {
|
|
583
|
+
return err?.code === "EPERM";
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Is this record still someone's?
|
|
588
|
+
//
|
|
589
|
+
// A SESSION marker (`revision begin`) deliberately outlives the process that
|
|
590
|
+
// created it: the whole point is to protect an edit session made of many
|
|
591
|
+
// commands and raw file writes, and the command that minted the token exited a
|
|
592
|
+
// second later. It is released by `revision finish`, by its own token, or by an
|
|
593
|
+
// explicit --force when the human says the session is gone — never by a pid
|
|
594
|
+
// check, which would make every session token stale the instant it was issued.
|
|
595
|
+
//
|
|
596
|
+
// A COMMAND marker is pid-bound, so a crashed build releases its own lock.
|
|
597
|
+
function alive(busy) {
|
|
598
|
+
if (!busy) return false;
|
|
599
|
+
if (busy.kind === "session") return true;
|
|
600
|
+
if (!busy.pid) return false;
|
|
601
|
+
if (busy.host && busy.host !== os.hostname()) return true; // not ours to judge
|
|
602
|
+
try {
|
|
603
|
+
process.kill(busy.pid, 0);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
return err?.code === "EPERM"; // exists but is someone else's
|
|
606
|
+
}
|
|
607
|
+
if (busy.pid === process.pid) return true;
|
|
608
|
+
// A live pid whose recorded start time is far from any plausible current one
|
|
609
|
+
// is a recycled pid, not the original owner.
|
|
610
|
+
return Math.abs(processStartMark() - (busy.processStart ?? 0)) <= uptimeSeconds();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function readBusy(workspaceDir) {
|
|
614
|
+
return readJson(stateDir(workspaceDir, BUSY_FILE));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Take local ownership of a deck. Exclusive: the record is created with `wx`, so
|
|
619
|
+
* two processes racing can never both win. A nested call under the SAME owner
|
|
620
|
+
* token (env `SLIDEMIND_OWNER`, or an explicit `owner`) re-enters instead of
|
|
621
|
+
* deadlocking.
|
|
622
|
+
*
|
|
623
|
+
* Returns `{ token, generation, nested, release() }`. `release()` on a nested
|
|
624
|
+
* acquisition is a no-op — the outermost holder owns the lifetime.
|
|
625
|
+
*/
|
|
626
|
+
export function acquireOwner(
|
|
627
|
+
workspaceDir,
|
|
628
|
+
{ command = "unknown", owner = null, session = false } = {},
|
|
629
|
+
) {
|
|
630
|
+
const file = stateDir(workspaceDir, BUSY_FILE);
|
|
631
|
+
const wanted = owner ?? process.env.SLIDEMIND_OWNER ?? null;
|
|
632
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
633
|
+
|
|
634
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
635
|
+
let fd;
|
|
636
|
+
try {
|
|
637
|
+
fd = fs.openSync(file, "wx");
|
|
638
|
+
} catch (err) {
|
|
639
|
+
if (err?.code !== "EEXIST") throw err;
|
|
640
|
+
const busy = readBusy(workspaceDir);
|
|
641
|
+
if (busy && wanted && busy.owner === wanted) {
|
|
642
|
+
// Same session, nested command: re-enter under the existing token.
|
|
643
|
+
writeJson(file, { ...busy, depth: (busy.depth ?? 1) + 1 });
|
|
644
|
+
return {
|
|
645
|
+
token: busy.owner,
|
|
646
|
+
generation: busy.generation,
|
|
647
|
+
nested: true,
|
|
648
|
+
release: () => {
|
|
649
|
+
const cur = readBusy(workspaceDir);
|
|
650
|
+
if (cur?.owner === busy.owner) {
|
|
651
|
+
writeJson(file, { ...cur, depth: Math.max(1, (cur.depth ?? 1) - 1) });
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (!busy || alive(busy)) {
|
|
657
|
+
const e = new Error(
|
|
658
|
+
!busy ? "deck ownership is being initialized or its lock is unreadable; retry or inspect busy.json before forcing release." : busy.kind === "session"
|
|
659
|
+
? `deck is held by an edit session (\`${busy.command}\`, started ${busy.startedAt}). ` +
|
|
660
|
+
`Run every command under its token (export SLIDEMIND_OWNER), or end it with ` +
|
|
661
|
+
`\`slidemind revision finish <deck>\`.`
|
|
662
|
+
: `deck is in use by ${busy.command ?? "another command"} (pid ${busy.pid}, since ${busy.startedAt}). ` +
|
|
663
|
+
`Wait for it, or run \`slidemind revision finish <deck> --force\` if that process is gone.`,
|
|
664
|
+
);
|
|
665
|
+
e.code = "deck_busy";
|
|
666
|
+
e.busy = busy;
|
|
667
|
+
throw e;
|
|
668
|
+
}
|
|
669
|
+
// A dead owner: release ONLY the ownership. Dirty files and any journaled
|
|
670
|
+
// operation survive untouched — a crash is not a reason to lose work.
|
|
671
|
+
// Serialize stale removers. A second remover must inspect the current
|
|
672
|
+
// lock again, not unlink a replacement using its earlier observation.
|
|
673
|
+
const guard = `${file}.reap`;
|
|
674
|
+
try { fs.mkdirSync(guard); }
|
|
675
|
+
catch (error) {
|
|
676
|
+
if (error.code !== "EEXIST") throw error;
|
|
677
|
+
throw Object.assign(new Error("deck ownership recovery is in progress"), { code: "deck_busy" });
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const current = readBusy(workspaceDir);
|
|
681
|
+
if (current?.owner === busy.owner && !alive(current)) fs.rmSync(file, { force: true });
|
|
682
|
+
} finally { fs.rmdirSync(guard); }
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
let revision;
|
|
686
|
+
try { recoverActivation(workspaceDir); revision = readRevision(workspaceDir); }
|
|
687
|
+
catch (error) { fs.closeSync(fd); fs.rmSync(file, { force: true }); throw error; }
|
|
688
|
+
const record = {
|
|
689
|
+
schemaVersion: SCHEMA_VERSION,
|
|
690
|
+
owner: `own-${randomUUID()}`,
|
|
691
|
+
// "session" outlives its process (see alive); "command" is pid-bound.
|
|
692
|
+
kind: session ? "session" : "command",
|
|
693
|
+
pid: process.pid,
|
|
694
|
+
processStart: processStartMark(),
|
|
695
|
+
host: os.hostname(),
|
|
696
|
+
command,
|
|
697
|
+
generation: revision?.generation ?? 0,
|
|
698
|
+
startedAt: nowIso(),
|
|
699
|
+
depth: 1,
|
|
700
|
+
};
|
|
701
|
+
fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
|
|
702
|
+
fs.closeSync(fd);
|
|
703
|
+
// `fail()` ends the process with process.exit(), which skips every
|
|
704
|
+
// `finally` — so a command that dies that way used to leave its marker
|
|
705
|
+
// behind and the next one waited on a lock nobody held. It self-healed
|
|
706
|
+
// (the pid is dead, so `alive()` reaps it), but an operation lease reads a
|
|
707
|
+
// leaked marker as a live claim, so the release happens here instead of at
|
|
708
|
+
// each of the ten call sites. Fully synchronous, as an exit handler must be.
|
|
709
|
+
//
|
|
710
|
+
// A SESSION marker is deliberately excluded: `revision begin` prints a
|
|
711
|
+
// token precisely so the marker outlives the process that minted it.
|
|
712
|
+
const onExit = session
|
|
713
|
+
? null
|
|
714
|
+
: () => {
|
|
715
|
+
const cur = readBusy(workspaceDir);
|
|
716
|
+
// Only ever this process's own command marker — never a replacement
|
|
717
|
+
// somebody else acquired after we let go.
|
|
718
|
+
if (cur?.owner === record.owner && cur.kind === "command" && cur.pid === process.pid) {
|
|
719
|
+
fs.rmSync(file, { force: true });
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
if (onExit) process.on("exit", onExit);
|
|
723
|
+
return {
|
|
724
|
+
token: record.owner,
|
|
725
|
+
generation: record.generation,
|
|
726
|
+
nested: false,
|
|
727
|
+
release: () => {
|
|
728
|
+
// Idempotent: a normal release retires the hook, so exiting afterwards
|
|
729
|
+
// is a no-op even if the marker has since been re-acquired.
|
|
730
|
+
if (onExit) process.off("exit", onExit);
|
|
731
|
+
const cur = readBusy(workspaceDir);
|
|
732
|
+
if (!cur || cur.owner !== record.owner) return;
|
|
733
|
+
if ((cur.depth ?? 1) > 1) {
|
|
734
|
+
writeJson(file, { ...cur, depth: cur.depth - 1 });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
fs.rmSync(file, { force: true });
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
throw new Error("could not acquire deck ownership");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Drop a stale owner record. Returns what was released, or null when the owner
|
|
746
|
+
* is alive (the caller must not steal it) or there was none. NEVER touches
|
|
747
|
+
* working files or journals.
|
|
748
|
+
*/
|
|
749
|
+
export function releaseStaleOwner(workspaceDir, { owner = null, force = false } = {}) {
|
|
750
|
+
const busy = readBusy(workspaceDir);
|
|
751
|
+
if (!busy) return null;
|
|
752
|
+
if (owner && busy.owner !== owner) return null;
|
|
753
|
+
if (!force && !owner && alive(busy)) return null;
|
|
754
|
+
fs.rmSync(stateDir(workspaceDir, BUSY_FILE), { force: true });
|
|
755
|
+
return busy;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The safe checkpoint every activation passes: ownership still ours, and the
|
|
760
|
+
* generation + working inputs unchanged since they were captured. Throws
|
|
761
|
+
* `generation_moved` otherwise, so a snapshot whose inputs changed mid-capture
|
|
762
|
+
* is never certified.
|
|
763
|
+
*/
|
|
764
|
+
export function assertCheckpoint(workspaceDir, { owner, generation, digest }) {
|
|
765
|
+
const busy = readBusy(workspaceDir);
|
|
766
|
+
if (!busy || busy.owner !== owner) {
|
|
767
|
+
const e = new Error("lost deck ownership while the operation was running");
|
|
768
|
+
e.code = "ownership_lost";
|
|
769
|
+
throw e;
|
|
770
|
+
}
|
|
771
|
+
const revision = readRevision(workspaceDir);
|
|
772
|
+
const current = computeDirty(workspaceDir, revision);
|
|
773
|
+
if ((revision?.generation ?? 0) !== generation || current.inputDigest !== digest) {
|
|
774
|
+
const e = new Error(
|
|
775
|
+
"the deck changed while this operation ran — nothing was activated. Re-run it.",
|
|
776
|
+
);
|
|
777
|
+
e.code = "generation_moved";
|
|
778
|
+
throw e;
|
|
779
|
+
}
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// journal — a half-applied activation must be recoverable, never valid
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
export function readJournal(workspaceDir) {
|
|
788
|
+
const data = readJson(stateDir(workspaceDir, JOURNAL_FILE));
|
|
789
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function journalBegin(workspaceDir, { op, stagingDir = null, detail = null }) {
|
|
793
|
+
const journal = {
|
|
794
|
+
schemaVersion: SCHEMA_VERSION,
|
|
795
|
+
id: `jr-${randomUUID()}`,
|
|
796
|
+
op,
|
|
797
|
+
status: "staged",
|
|
798
|
+
startedAt: nowIso(),
|
|
799
|
+
stagingDir,
|
|
800
|
+
detail,
|
|
801
|
+
steps: [],
|
|
802
|
+
};
|
|
803
|
+
writeJson(stateDir(workspaceDir, JOURNAL_FILE), journal);
|
|
804
|
+
return journal;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export function journalStep(workspaceDir, journal, step) {
|
|
808
|
+
const next = { ...journal, status: "activating", steps: [...journal.steps, { step, at: nowIso() }] };
|
|
809
|
+
writeJson(stateDir(workspaceDir, JOURNAL_FILE), next);
|
|
810
|
+
return next;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function journalComplete(workspaceDir) {
|
|
814
|
+
fs.rmSync(stateDir(workspaceDir, JOURNAL_FILE), { force: true });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** An unfinished activation, or null. Only the absence of a journal means done. */
|
|
818
|
+
export function journalIncomplete(workspaceDir) {
|
|
819
|
+
const journal = readJournal(workspaceDir);
|
|
820
|
+
return journal && journal.status !== "complete" ? journal : null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// candidates — immutable rendered output plus the evidence it came from
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
export function candidateDir(workspaceDir, candidateId) {
|
|
828
|
+
return stateDir(workspaceDir, CANDIDATES_DIR, candidateId);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Persist a rendered deck as an immutable candidate. Does NOT advance the base
|
|
833
|
+
* and does NOT clear dirty — building twice keeps both edits, and neither build
|
|
834
|
+
* counts as acceptance.
|
|
835
|
+
*
|
|
836
|
+
* An identical candidate (same base, generation, inputs and output bytes) is
|
|
837
|
+
* reused rather than duplicated.
|
|
838
|
+
*/
|
|
839
|
+
export function saveCandidate(workspaceDir, { bytes, validation = null, review = null, renderedAll = true }) {
|
|
840
|
+
let revision = readRevision(workspaceDir);
|
|
841
|
+
const materialized = materializedInputs(workspaceDir);
|
|
842
|
+
const digest = inputDigest(materialized);
|
|
843
|
+
const inventory = inventoryPackage(bytes);
|
|
844
|
+
if (!revision) revision = writeRevision(workspaceDir, { generation: 0, origin: { kind: 'authored' }, base: null, baseline: null, candidate: null, conflicts: null });
|
|
845
|
+
const evidence = {
|
|
846
|
+
baseSha256: revision?.base?.sha256 ?? null,
|
|
847
|
+
generation: revision?.generation ?? 0,
|
|
848
|
+
inputDigest: digest,
|
|
849
|
+
outputSha256: inventory.sha256,
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
for (const existing of listCandidates(workspaceDir)) {
|
|
853
|
+
if (
|
|
854
|
+
existing.baseSha256 === evidence.baseSha256 &&
|
|
855
|
+
existing.generation === evidence.generation &&
|
|
856
|
+
existing.inputDigest === evidence.inputDigest &&
|
|
857
|
+
existing.outputSha256 === evidence.outputSha256
|
|
858
|
+
) {
|
|
859
|
+
return existing;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const candidateId = `cand-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
864
|
+
const dir = candidateDir(workspaceDir, candidateId);
|
|
865
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
866
|
+
fs.writeFileSync(path.join(dir, CANDIDATE_PPTX), Buffer.from(bytes));
|
|
867
|
+
const record = {
|
|
868
|
+
schemaVersion: SCHEMA_VERSION,
|
|
869
|
+
candidateId,
|
|
870
|
+
createdAt: nowIso(),
|
|
871
|
+
...evidence,
|
|
872
|
+
outputBytes: Buffer.byteLength(Buffer.from(bytes)),
|
|
873
|
+
slideCount: inventory.slideCount,
|
|
874
|
+
order: inventory.order,
|
|
875
|
+
inputs: materialized.inputs,
|
|
876
|
+
// A build that left untouched slides on the design they were last rendered
|
|
877
|
+
// with must not claim the current design for them: the accepted base then
|
|
878
|
+
// keeps the old `shared`, so `sharedChanged` stays true and the next
|
|
879
|
+
// --refresh-design still knows those slides are behind.
|
|
880
|
+
dependencies: renderedAll ? materialized.shared : (revision.baseline?.shared ?? materialized.shared),
|
|
881
|
+
validation,
|
|
882
|
+
review,
|
|
883
|
+
};
|
|
884
|
+
writeJson(path.join(dir, CANDIDATE_FILE), record);
|
|
885
|
+
if (revision) writeRevision(workspaceDir, { ...revision, candidate: candidateId });
|
|
886
|
+
return record;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export function readCandidate(workspaceDir, candidateId) {
|
|
890
|
+
const data = readJson(path.join(candidateDir(workspaceDir, candidateId), CANDIDATE_FILE));
|
|
891
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export function candidateBytes(workspaceDir, candidateId) {
|
|
895
|
+
try {
|
|
896
|
+
return fs.readFileSync(path.join(candidateDir(workspaceDir, candidateId), CANDIDATE_PPTX));
|
|
897
|
+
} catch {
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export function listCandidates(workspaceDir) {
|
|
903
|
+
let names = [];
|
|
904
|
+
try {
|
|
905
|
+
names = fs.readdirSync(stateDir(workspaceDir, CANDIDATES_DIR));
|
|
906
|
+
} catch {
|
|
907
|
+
return [];
|
|
908
|
+
}
|
|
909
|
+
return names
|
|
910
|
+
.map((id) => readCandidate(workspaceDir, id))
|
|
911
|
+
.filter(Boolean)
|
|
912
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Why a candidate is no longer a valid thing to accept, or null. */
|
|
916
|
+
export function candidateStale(workspaceDir, candidate, revision = readRevision(workspaceDir)) {
|
|
917
|
+
if (!candidate) return "no such candidate";
|
|
918
|
+
if (!revision) return "the deck has no accepted base";
|
|
919
|
+
if (candidate.baseSha256 !== (revision.base?.sha256 ?? null)) {
|
|
920
|
+
return "it was built against a different accepted base";
|
|
921
|
+
}
|
|
922
|
+
if (candidate.generation !== revision.generation) {
|
|
923
|
+
return "the deck's accepted state moved since it was built";
|
|
924
|
+
}
|
|
925
|
+
const digest = inputDigest(materializedInputs(workspaceDir));
|
|
926
|
+
if (candidate.inputDigest !== digest) {
|
|
927
|
+
return "the deck's inputs changed since it was built";
|
|
928
|
+
}
|
|
929
|
+
const bytes = candidateBytes(workspaceDir, candidate.candidateId);
|
|
930
|
+
if (!bytes || sha256(bytes) !== candidate.outputSha256) {
|
|
931
|
+
return "its recorded output bytes are missing or no longer match";
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Accept a validated LOCAL-ONLY candidate: its bytes become the accepted base
|
|
938
|
+
* and its inputs become the baseline, journaled so a crash resolves to one
|
|
939
|
+
* complete generation. Refuses a remote-bound deck (only a confirmed publish
|
|
940
|
+
* receipt may advance THAT base), a stale candidate, an unresolved conflict and
|
|
941
|
+
* a candidate whose required gates did not pass.
|
|
942
|
+
*/
|
|
943
|
+
export function acceptCandidate(workspaceDir, candidateId, { owner }) {
|
|
944
|
+
const revision = readRevision(workspaceDir);
|
|
945
|
+
if (revision?.remote || readJson(path.join(workspaceDir, "state", "remote.json"))) {
|
|
946
|
+
const e = new Error(
|
|
947
|
+
"this deck is bound to a remote copy — acceptance there comes from a confirmed publish receipt, not from `revision accept`.",
|
|
948
|
+
);
|
|
949
|
+
e.code = "remote_bound";
|
|
950
|
+
throw e;
|
|
951
|
+
}
|
|
952
|
+
if (readConflicts(workspaceDir)) {
|
|
953
|
+
const e = new Error(
|
|
954
|
+
"unresolved rebase conflicts — resolve them (`slidemind rebase <deck> --resolve <file>`) before accepting.",
|
|
955
|
+
);
|
|
956
|
+
e.code = "conflicted";
|
|
957
|
+
throw e;
|
|
958
|
+
}
|
|
959
|
+
const candidate = readCandidate(workspaceDir, candidateId);
|
|
960
|
+
const why = candidateStale(workspaceDir, candidate, revision);
|
|
961
|
+
if (why) {
|
|
962
|
+
const e = new Error(`candidate ${candidateId} cannot be accepted: ${why}.`);
|
|
963
|
+
e.code = "candidate_stale";
|
|
964
|
+
throw e;
|
|
965
|
+
}
|
|
966
|
+
if (candidate.validation?.ok !== true) {
|
|
967
|
+
const e = new Error(
|
|
968
|
+
`candidate ${candidateId} did not pass validation: ${(candidate.validation?.errors ?? ["validation evidence is missing"]).join("; ")}`,
|
|
969
|
+
);
|
|
970
|
+
e.code = "candidate_invalid";
|
|
971
|
+
throw e;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return advanceBaseToCandidate(workspaceDir, candidate, { owner, origin: "local", revision });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Move the accepted base onto a candidate's bytes and its inputs onto the
|
|
979
|
+
* baseline, journaled so a crash resolves to one complete generation.
|
|
980
|
+
*
|
|
981
|
+
* Shared by the two acceptance doors — `revision accept` locally and a CONFIRMED
|
|
982
|
+
* publish receipt remotely. The guards differ (that is the whole point of having
|
|
983
|
+
* two doors); what happens once a caller has earned the right is identical, and
|
|
984
|
+
* a second copy of it would be a second chance to get the baseline wrong.
|
|
985
|
+
*/
|
|
986
|
+
function advanceBaseToCandidate(workspaceDir, candidate, { owner, origin, revision, receipt = null }) {
|
|
987
|
+
const candidateId = candidate.candidateId;
|
|
988
|
+
const bytes = candidateBytes(workspaceDir, candidateId);
|
|
989
|
+
assertCheckpoint(workspaceDir, {
|
|
990
|
+
owner,
|
|
991
|
+
generation: revision.generation,
|
|
992
|
+
digest: candidate.inputDigest,
|
|
993
|
+
});
|
|
994
|
+
const inventory = inventoryPackage(bytes);
|
|
995
|
+
const tx = stageWorkspace(workspaceDir);
|
|
996
|
+
try {
|
|
997
|
+
fs.writeFileSync(acceptedBasePath(tx.workspace), bytes);
|
|
998
|
+
const next = writeRevision(tx.workspace, {
|
|
999
|
+
...revision,
|
|
1000
|
+
generation: revision.generation + 1,
|
|
1001
|
+
base: {
|
|
1002
|
+
sha256: inventory.sha256,
|
|
1003
|
+
acceptedAt: nowIso(),
|
|
1004
|
+
slideCount: inventory.slideCount,
|
|
1005
|
+
packageSha256: inventory.packageSha256,
|
|
1006
|
+
order: inventory.order,
|
|
1007
|
+
// The per-slide file map is what a later rebase matches a colleague's
|
|
1008
|
+
// change against; rebuilding it from the candidate would lose it.
|
|
1009
|
+
slides: inventory.slides.map((s, i) => ({
|
|
1010
|
+
...s,
|
|
1011
|
+
file: slideFileOrder(workspaceDir)[i] ?? null,
|
|
1012
|
+
})),
|
|
1013
|
+
},
|
|
1014
|
+
baseline: {
|
|
1015
|
+
inputs: candidate.inputs,
|
|
1016
|
+
order: slideFileOrder(workspaceDir),
|
|
1017
|
+
shared: candidate.dependencies,
|
|
1018
|
+
at: nowIso(),
|
|
1019
|
+
},
|
|
1020
|
+
candidate: null,
|
|
1021
|
+
acceptedCandidate: {
|
|
1022
|
+
candidateId,
|
|
1023
|
+
at: nowIso(),
|
|
1024
|
+
origin,
|
|
1025
|
+
...(receipt ? { receipt: { operationId: receipt.operationId, versionId: receipt.version?.versionId ?? null } } : {}),
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
tx.activate({ checkpoint: () => assertCheckpoint(workspaceDir, {
|
|
1029
|
+
owner, generation: revision.generation, digest: candidate.inputDigest,
|
|
1030
|
+
}) });
|
|
1031
|
+
return next;
|
|
1032
|
+
} finally { tx.dispose(); }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The REMOTE acceptance door: a confirmed publish receipt, and nothing else,
|
|
1037
|
+
* moves a remote-bound deck's accepted base.
|
|
1038
|
+
*
|
|
1039
|
+
* `acceptCandidate` refuses these decks deliberately — a local decision cannot
|
|
1040
|
+
* certify that a server stored anything. This is the same operation with the
|
|
1041
|
+
* opposite precondition: the receipt must name the candidate whose bytes the
|
|
1042
|
+
* server confirmed, so the base can only ever advance to what is actually there.
|
|
1043
|
+
*/
|
|
1044
|
+
export function acceptCandidateFromReceipt(workspaceDir, candidateId, { owner, receipt }) {
|
|
1045
|
+
const revision = readRevision(workspaceDir);
|
|
1046
|
+
const candidate = readCandidate(workspaceDir, candidateId);
|
|
1047
|
+
if (!candidate) {
|
|
1048
|
+
const e = new Error(`no such candidate: ${candidateId}`);
|
|
1049
|
+
e.code = "candidate_stale";
|
|
1050
|
+
throw e;
|
|
1051
|
+
}
|
|
1052
|
+
if (!receipt?.candidateSha256 || receipt.candidateSha256 !== candidate.outputSha256) {
|
|
1053
|
+
const e = new Error(
|
|
1054
|
+
"the receipt does not name this candidate's bytes — the base is not advanced on evidence about a different build.",
|
|
1055
|
+
);
|
|
1056
|
+
e.code = "receipt_mismatch";
|
|
1057
|
+
throw e;
|
|
1058
|
+
}
|
|
1059
|
+
return advanceBaseToCandidate(workspaceDir, candidate, {
|
|
1060
|
+
owner,
|
|
1061
|
+
origin: "remote",
|
|
1062
|
+
revision,
|
|
1063
|
+
receipt,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
// staged incoming snapshots + the conflict manifest
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
|
|
1071
|
+
export function stagedDir(workspaceDir, revisionId) {
|
|
1072
|
+
return stateDir(workspaceDir, STAGED_DIR, revisionId);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Put incoming bytes somewhere IMMUTABLE and revision-specific. Staging never
|
|
1077
|
+
* touches working inputs, so a pull that lands mid-edit cannot disturb the edit.
|
|
1078
|
+
*/
|
|
1079
|
+
export function stageIncoming(workspaceDir, { bytes, source }) {
|
|
1080
|
+
const inventory = inventoryPackage(bytes);
|
|
1081
|
+
// The same bytes staged twice (a pull after a publish that already staged
|
|
1082
|
+
// them) are one incoming revision, not two to reconcile.
|
|
1083
|
+
for (const id of readRevision(workspaceDir)?.staged ?? []) {
|
|
1084
|
+
const existing = readStaged(workspaceDir, id);
|
|
1085
|
+
if (existing?.sha256 === inventory.sha256) return existing;
|
|
1086
|
+
}
|
|
1087
|
+
const revisionId = `in-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1088
|
+
const dir = stagedDir(workspaceDir, revisionId);
|
|
1089
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1090
|
+
fs.writeFileSync(path.join(dir, INCOMING_PPTX), Buffer.from(bytes));
|
|
1091
|
+
const record = {
|
|
1092
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1093
|
+
revisionId,
|
|
1094
|
+
stagedAt: nowIso(),
|
|
1095
|
+
source,
|
|
1096
|
+
sha256: inventory.sha256,
|
|
1097
|
+
slideCount: inventory.slideCount,
|
|
1098
|
+
packageSha256: inventory.packageSha256,
|
|
1099
|
+
order: inventory.order,
|
|
1100
|
+
slides: inventory.slides,
|
|
1101
|
+
};
|
|
1102
|
+
writeJson(path.join(dir, INCOMING_FILE), record);
|
|
1103
|
+
const revision = readRevision(workspaceDir);
|
|
1104
|
+
if (revision) {
|
|
1105
|
+
writeRevision(workspaceDir, {
|
|
1106
|
+
...revision,
|
|
1107
|
+
staged: [...new Set([...(revision.staged ?? []), revisionId])],
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
return record;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
export function readStaged(workspaceDir, revisionId) {
|
|
1114
|
+
const data = readJson(path.join(stagedDir(workspaceDir, revisionId), INCOMING_FILE));
|
|
1115
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export function stagedBytes(workspaceDir, revisionId) {
|
|
1119
|
+
try {
|
|
1120
|
+
return fs.readFileSync(path.join(stagedDir(workspaceDir, revisionId), INCOMING_PPTX));
|
|
1121
|
+
} catch {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
export function listStaged(workspaceDir) {
|
|
1127
|
+
try {
|
|
1128
|
+
return fs
|
|
1129
|
+
.readdirSync(stateDir(workspaceDir, STAGED_DIR))
|
|
1130
|
+
.map((id) => readStaged(workspaceDir, id))
|
|
1131
|
+
.filter(Boolean)
|
|
1132
|
+
.sort((a, b) => (a.stagedAt < b.stagedAt ? 1 : -1));
|
|
1133
|
+
} catch {
|
|
1134
|
+
return [];
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export function readConflicts(workspaceDir) {
|
|
1139
|
+
const data = readJson(stateDir(workspaceDir, CONFLICTS_FILE));
|
|
1140
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function writeConflicts(workspaceDir, manifest) {
|
|
1144
|
+
const file = writeJson(stateDir(workspaceDir, CONFLICTS_FILE), {
|
|
1145
|
+
...manifest,
|
|
1146
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1147
|
+
});
|
|
1148
|
+
const revision = readRevision(workspaceDir);
|
|
1149
|
+
if (revision) writeRevision(workspaceDir, { ...revision, conflicts: manifest.revisionId });
|
|
1150
|
+
return file;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
export function clearConflicts(workspaceDir) {
|
|
1154
|
+
fs.rmSync(stateDir(workspaceDir, CONFLICTS_FILE), { force: true });
|
|
1155
|
+
const revision = readRevision(workspaceDir);
|
|
1156
|
+
if (revision) writeRevision(workspaceDir, { ...revision, conflicts: null });
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ---------------------------------------------------------------------------
|
|
1160
|
+
// the one shape ready/status report
|
|
1161
|
+
// ---------------------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
/** Everything `ready --json` and `revision status` expose, read from disk. */
|
|
1164
|
+
export function revisionStatus(workspaceDir) {
|
|
1165
|
+
const revision = readRevision(workspaceDir);
|
|
1166
|
+
const dirty = computeDirty(workspaceDir, revision);
|
|
1167
|
+
const busy = readBusy(workspaceDir);
|
|
1168
|
+
const candidateId = revision?.candidate ?? null;
|
|
1169
|
+
const candidate = candidateId ? readCandidate(workspaceDir, candidateId) : null;
|
|
1170
|
+
return {
|
|
1171
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1172
|
+
adopted: Boolean(revision),
|
|
1173
|
+
origin: revision?.origin ?? null,
|
|
1174
|
+
generation: revision?.generation ?? 0,
|
|
1175
|
+
base: revision?.base
|
|
1176
|
+
? {
|
|
1177
|
+
sha256: revision.base.sha256,
|
|
1178
|
+
acceptedAt: revision.base.acceptedAt,
|
|
1179
|
+
slideCount: revision.base.slideCount,
|
|
1180
|
+
}
|
|
1181
|
+
: null,
|
|
1182
|
+
localDirty: dirty.localDirty,
|
|
1183
|
+
dirtySlides: dirty.dirtySlides,
|
|
1184
|
+
added: dirty.added,
|
|
1185
|
+
removed: dirty.removed,
|
|
1186
|
+
orderChanged: dirty.orderChanged,
|
|
1187
|
+
designMoved: dirty.sharedChanged,
|
|
1188
|
+
unverified: dirty.unverified,
|
|
1189
|
+
inputDigest: dirty.inputDigest,
|
|
1190
|
+
candidate: candidate
|
|
1191
|
+
? {
|
|
1192
|
+
candidateId: candidate.candidateId,
|
|
1193
|
+
createdAt: candidate.createdAt,
|
|
1194
|
+
outputSha256: candidate.outputSha256,
|
|
1195
|
+
stale: candidateStale(workspaceDir, candidate, revision),
|
|
1196
|
+
}
|
|
1197
|
+
: null,
|
|
1198
|
+
candidates: listCandidates(workspaceDir).map((c) => c.candidateId),
|
|
1199
|
+
staged: listStaged(workspaceDir).map((s) => ({
|
|
1200
|
+
revisionId: s.revisionId,
|
|
1201
|
+
stagedAt: s.stagedAt,
|
|
1202
|
+
sha256: s.sha256,
|
|
1203
|
+
})),
|
|
1204
|
+
conflicts: readConflicts(workspaceDir)?.conflicts?.length ?? 0,
|
|
1205
|
+
owner: busy
|
|
1206
|
+
? { kind: busy.kind ?? "command", command: busy.command, pid: busy.pid, startedAt: busy.startedAt }
|
|
1207
|
+
: null,
|
|
1208
|
+
journal: journalIncomplete(workspaceDir)?.op ?? null,
|
|
1209
|
+
};
|
|
1210
|
+
}
|