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,1557 @@
|
|
|
1
|
+
import { stageWorkspace } from './workspace-transaction.mjs';
|
|
2
|
+
// Pull and publish: the protocol between a deck's local revision state and the
|
|
3
|
+
// item it was opened from.
|
|
4
|
+
//
|
|
5
|
+
// The provider is INJECTED (`provider` — src/bridge/sharepoint.mjs in
|
|
6
|
+
// production, a mock in the suite) so every branch that matters can be driven
|
|
7
|
+
// offline: a 412 mid-publish, a context upload that lands before a deck write
|
|
8
|
+
// that does not, a process death between the two, an equal-size download race, a
|
|
9
|
+
// colleague's save arriving while local work is dirty.
|
|
10
|
+
//
|
|
11
|
+
// The order of operations is the specification, and it is not negotiable:
|
|
12
|
+
//
|
|
13
|
+
// own → recover → pull → rebase → assess → manifest → artifacts → finalize
|
|
14
|
+
// → validate → recheck → conditional replace → receipt → advance → callback
|
|
15
|
+
//
|
|
16
|
+
// Four rules the code exists to enforce rather than describe:
|
|
17
|
+
//
|
|
18
|
+
// 1. **Journal before the effect, receipt after the confirmation.** Everything
|
|
19
|
+
// between those two is `unknown_outcome`, which is inspected, never retried.
|
|
20
|
+
// 2. **Pending refs are not committed refs.** An uploaded context is paired
|
|
21
|
+
// with nothing until the deck version that names it is the stored one.
|
|
22
|
+
// 3. **Only a confirmed receipt advances the base.** Not a 200 we half-read,
|
|
23
|
+
// not a later save by someone else, not an optimistic local decision.
|
|
24
|
+
// 4. **Evidence is bound to hashes.** A rebase during the run invalidates the
|
|
25
|
+
// validation that ran before it; the run re-validates or it stops.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
CANDIDATE_PPTX,
|
|
33
|
+
acceptCandidateFromReceipt,
|
|
34
|
+
assertCheckpoint,
|
|
35
|
+
candidateBytes,
|
|
36
|
+
candidateDir,
|
|
37
|
+
candidateStale,
|
|
38
|
+
computeDirty,
|
|
39
|
+
readCandidate,
|
|
40
|
+
readRevision,
|
|
41
|
+
writeRevision,
|
|
42
|
+
saveCandidate,
|
|
43
|
+
stageIncoming,
|
|
44
|
+
stagedDir,
|
|
45
|
+
} from "./revision.mjs";
|
|
46
|
+
import { applyRebase, classifyRebase, recordConflicts } from "./rebase.mjs";
|
|
47
|
+
import {
|
|
48
|
+
abandonOperation,
|
|
49
|
+
bindRemote,
|
|
50
|
+
acceptReceipt,
|
|
51
|
+
beginOperation,
|
|
52
|
+
computeMode,
|
|
53
|
+
documentInstance,
|
|
54
|
+
noteObservation,
|
|
55
|
+
operationOutcome,
|
|
56
|
+
pendingOperations,
|
|
57
|
+
readRemote,
|
|
58
|
+
savedDestination,
|
|
59
|
+
stepOperation,
|
|
60
|
+
writeReceipt,
|
|
61
|
+
writeRemote,
|
|
62
|
+
} from "./remote.mjs";
|
|
63
|
+
import { collectManifest, materializeArtifact, runDeclared } from "./extensions.mjs";
|
|
64
|
+
import {
|
|
65
|
+
bakePresentationContext,
|
|
66
|
+
contextDigest,
|
|
67
|
+
readPresentationContext,
|
|
68
|
+
} from "./presentation-context.mjs";
|
|
69
|
+
import { unzip, zip } from "../pptx/zip.mjs";
|
|
70
|
+
|
|
71
|
+
export { savedDestination };
|
|
72
|
+
|
|
73
|
+
const nowIso = () => new Date().toISOString();
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// shared reads
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function itemRef(remote) {
|
|
80
|
+
return { driveId: remote.driveId, itemId: remote.itemId };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fold the provider's route answers together with this deck's access
|
|
85
|
+
* evidence and local gates.
|
|
86
|
+
*
|
|
87
|
+
* The three inputs stay three inputs all the way down: deployment policy, the
|
|
88
|
+
* user's grant, and whether this item is writable (usually `unknown`, and that
|
|
89
|
+
* is fine — the publish itself resolves it).
|
|
90
|
+
*/
|
|
91
|
+
export function decideMode({ provider, client, remote, gates = {}, policy = {} }) {
|
|
92
|
+
return computeMode({
|
|
93
|
+
capabilities: provider.capabilities(client.host).routes,
|
|
94
|
+
access: { read: remote?.access?.read ?? "unknown", write: remote?.access?.write ?? "unknown" },
|
|
95
|
+
policy,
|
|
96
|
+
gates,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The context these accepted bytes carry, in the three-answer decode shape. */
|
|
101
|
+
export function contextOfBytes(bytes) {
|
|
102
|
+
try {
|
|
103
|
+
return readPresentationContext(unzip(Buffer.from(bytes)));
|
|
104
|
+
} catch {
|
|
105
|
+
return { corrupt: "the package could not be read" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The extension declarations that apply to this deck.
|
|
111
|
+
*
|
|
112
|
+
* A WARM deck keeps the requirements its binding recorded even when the file
|
|
113
|
+
* came back with its tags stripped — an Office save that drops a custom tag part
|
|
114
|
+
* must not be able to turn a gated deck into an ordinary deck. A COLD deck
|
|
115
|
+
* with no binding and no context has nothing to fall back on, which is reported
|
|
116
|
+
* as unknown origin rather than resolved to "ordinary".
|
|
117
|
+
*/
|
|
118
|
+
export function declaredExtensions(workspaceDir, context) {
|
|
119
|
+
const remote = readRemote(workspaceDir);
|
|
120
|
+
const fromContext = context?.context?.extensions ?? null;
|
|
121
|
+
let owner = null;
|
|
122
|
+
try { owner = JSON.parse(fs.readFileSync(path.join(workspaceDir, 'state', 'plan-owner.json'), 'utf8'))?.owner; } catch {}
|
|
123
|
+
const local = typeof owner === 'string' && owner && owner !== 'core' ? [{ id: owner, protocolVersion: 1, required: true }] : [];
|
|
124
|
+
const remembered = [...(remote?.extensions ?? []), ...local];
|
|
125
|
+
const declarations = new Map();
|
|
126
|
+
for (const declaration of [...(fromContext ?? []), ...remembered]) {
|
|
127
|
+
const existing = declarations.get(declaration.id);
|
|
128
|
+
declarations.set(declaration.id, existing ? { ...declaration, ...existing, required: existing.required !== false || declaration.required !== false } : declaration);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
declarations: [...declarations.values()],
|
|
132
|
+
source: fromContext ? 'context' : remembered.length ? (local.length ? 'owner' : 'binding') : readRevision(workspaceDir)?.origin?.classification === 'ordinary' ? 'classification' : remote && !remote.originUnknown ? 'binding' : 'none',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The dependency refs currently paired with the accepted deck. */
|
|
137
|
+
export function pairedRefs(workspaceDir, context) {
|
|
138
|
+
const fromContext = (context?.context?.extensions ?? []).flatMap((e) => e.refs ?? []);
|
|
139
|
+
if (fromContext.length) return fromContext;
|
|
140
|
+
return readRemote(workspaceDir)?.committedRefs ?? [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function revisionSummary(workspaceDir) {
|
|
144
|
+
const revision = readRevision(workspaceDir);
|
|
145
|
+
const dirty = computeDirty(workspaceDir, revision);
|
|
146
|
+
return {
|
|
147
|
+
generation: revision?.generation ?? 0,
|
|
148
|
+
baseSha256: revision?.base?.sha256 ?? null,
|
|
149
|
+
inputDigest: dirty.inputDigest,
|
|
150
|
+
localDirty: dirty.localDirty,
|
|
151
|
+
dirtySlides: dirty.dirtySlides,
|
|
152
|
+
unverified: dirty.unverified,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// recovery — always first, before anything new leaves this machine
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve every operation a previous run left open.
|
|
162
|
+
*
|
|
163
|
+
* The three outcomes, and why none of them is "try again":
|
|
164
|
+
* - a receipt already exists → the commit landed; only local finalization is
|
|
165
|
+
* pending, so finish THAT.
|
|
166
|
+
* - the phase could have left an effect → ask the provider whether our exact
|
|
167
|
+
* bytes are in the item's history. A later save by someone else proves
|
|
168
|
+
* nothing about ours.
|
|
169
|
+
* - nothing can have happened yet → close the record and move on.
|
|
170
|
+
*/
|
|
171
|
+
// The byte length that pairs with a journalled candidate's sha256 — `saveCandidate`
|
|
172
|
+
// records both from the same buffer, so they cannot disagree. Used only to let a
|
|
173
|
+
// version-history scan skip versions that cannot possibly hash to ours.
|
|
174
|
+
function candidateSize(workspaceDir, operation) {
|
|
175
|
+
const id = operation?.evidence?.candidateId;
|
|
176
|
+
const size = id ? readCandidate(workspaceDir, id)?.outputBytes : null;
|
|
177
|
+
return typeof size === "number" ? size : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function recoverOperations(workspaceDir, { provider, client, owner, note = () => {} }) {
|
|
181
|
+
const resolved = [];
|
|
182
|
+
for (const operation of pendingOperations(workspaceDir)) {
|
|
183
|
+
const outcome = operationOutcome(workspaceDir, operation);
|
|
184
|
+
if (outcome.action === "discard") {
|
|
185
|
+
abandonOperation(workspaceDir, operation.operationId, "nothing had left this machine");
|
|
186
|
+
resolved.push({ operationId: operation.operationId, action: "discarded" });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (outcome.action === "resolved") {
|
|
190
|
+
const finished = await finalizeCommitted(workspaceDir, {
|
|
191
|
+
operation,
|
|
192
|
+
receipt: outcome.receipt,
|
|
193
|
+
owner,
|
|
194
|
+
note,
|
|
195
|
+
});
|
|
196
|
+
resolved.push({ operationId: operation.operationId, action: "finalized", ...finished });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// `inspect`: the only honest question is whether our bytes are there.
|
|
200
|
+
const remote = readRemote(workspaceDir);
|
|
201
|
+
if (operation.kind === "rename") {
|
|
202
|
+
const current = remote ? await provider.getItem(client, itemRef(remote)).catch(() => null) : null;
|
|
203
|
+
if (current?.name === operation.evidence?.to) {
|
|
204
|
+
const receipt = writeReceipt(workspaceDir, operation.operationId, { item: current, refs: remote.committedRefs ?? [], route: "rename-recovered" });
|
|
205
|
+
const finished = await finalizeCommitted(workspaceDir, { operation, receipt, owner, note });
|
|
206
|
+
resolved.push({ operationId: operation.operationId, action: "recovered", ...finished });
|
|
207
|
+
} else resolved.push({ operationId: operation.operationId, action: "unresolved", why: "rename outcome is not yet confirmed" });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (operation.phase === "artifacts") {
|
|
211
|
+
// Artifact writes cannot be adjudicated by inspecting the presentation.
|
|
212
|
+
// Keep older/incomplete journals blocked rather than wrongly clearing them.
|
|
213
|
+
const pending = operation.artifactWrite;
|
|
214
|
+
if (!pending) {
|
|
215
|
+
if (["artifacts-done", "validation-blocked", "finalize-candidate"].includes(operation.steps?.at(-1)?.step)) {
|
|
216
|
+
abandonOperation(workspaceDir, operation.operationId, "all artifact receipts are recorded; presentation write never started");
|
|
217
|
+
resolved.push({ operationId: operation.operationId, action: "discarded", pendingRefs: operation.pendingRefs ?? [] });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
resolved.push({ operationId: operation.operationId, action: "unresolved", why: "artifact outcome lacks target evidence" });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
let target = pending.target;
|
|
224
|
+
if (!target?.itemId) target = await provider.getItemByPath(client, pending.folder, pending.name).catch(() => null);
|
|
225
|
+
if (!target?.itemId) {
|
|
226
|
+
resolved.push({ operationId: operation.operationId, action: "unresolved", why: "artifact creation outcome is not confirmed" });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const inspected = await provider.inspectWriteOutcome(client, { driveId: target.driveId, itemId: target.itemId }, { expectedSha256: pending.sha256 });
|
|
230
|
+
// An IMMUTABLE artifact's name is content-addressed by its own bytes, so the
|
|
231
|
+
// journal reserved this exact name in this deck's own folder and an item sits
|
|
232
|
+
// under it: that is the write, whatever the server did to the bytes on the way
|
|
233
|
+
// in. A MUTABLE one (an owned workbook) keeps a stable name that already
|
|
234
|
+
// existed before the replace, so the name proves nothing — only the hash does.
|
|
235
|
+
// Pinning the newest version regardless would pin the version our write was
|
|
236
|
+
// meant to REPLACE, or a colleague's later save, and report it as ours.
|
|
237
|
+
if (pending.mutable && !(inspected.landed === true && inspected.superseded !== true)) {
|
|
238
|
+
const why = inspected.landed === true
|
|
239
|
+
? "the artifact's bytes landed but have already been superseded, so no version is honestly ours"
|
|
240
|
+
: inspected.landed === false
|
|
241
|
+
? "the artifact replacement never landed"
|
|
242
|
+
: `the artifact's outcome could not be read (${inspected.why ?? "unknown"})`;
|
|
243
|
+
resolved.push({ operationId: operation.operationId, action: "unresolved", why });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// `written` is what arms pinAfterWrite's eTag-moved guard. For a mutable
|
|
247
|
+
// artifact that is the item we just confirmed holds our bytes, so a save
|
|
248
|
+
// landing between the check and the pin refuses instead of pinning theirs.
|
|
249
|
+
const pinned = await pinAfterWrite(provider, client, { driveId: target.driveId, itemId: target.itemId }, pending.mutable ? (inspected.current ?? null) : null);
|
|
250
|
+
if (pinned.error) {
|
|
251
|
+
resolved.push({ operationId: operation.operationId, action: "unresolved", why: `artifact found by name but ${pinned.error}` });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const recoveredRef = refFor(client, pending.artifact, target, { versionId: pending.mutable ? pinned.versionId : (inspected.version?.versionId ?? null), mutability: pending.mutable ? "mutable" : "immutable", storedSha256: pinned.storedSha256 });
|
|
255
|
+
stepOperation(workspaceDir, operation.operationId, { pendingRefs: [...(operation.pendingRefs ?? []), recoveredRef], artifactWrite: null });
|
|
256
|
+
abandonOperation(workspaceDir, operation.operationId, "artifact outcome recovered; presentation was never written");
|
|
257
|
+
resolved.push({ operationId: operation.operationId, action: "artifact-recovered", pendingRef: recoveredRef });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const expected = operation.evidence?.candidateSha256;
|
|
261
|
+
if (!remote && operation.kind === 'create' && operation.destination) {
|
|
262
|
+
const target = await provider.getItemByPath(client, operation.destination, operation.destination.name).catch(() => null);
|
|
263
|
+
if (target?.itemId) {
|
|
264
|
+
const inspected = await provider.inspectWriteOutcome(client, { driveId: target.driveId, itemId: target.itemId }, { expectedSha256: expected, expectedBytes: candidateSize(workspaceDir, operation) });
|
|
265
|
+
if (inspected.landed === true) {
|
|
266
|
+
const candidateContext = contextOfBytes(candidateBytes(workspaceDir, operation.evidence.candidateId));
|
|
267
|
+
bindRemote(workspaceDir, { identity: { provider: 'sharepoint', tenant: new URL(client.host).hostname, driveId: target.driveId, itemId: target.itemId }, labels: { name: target.name, parentId: target.parentId, webUrl: target.webUrl }, extensions: candidateContext?.context?.extensions ?? [] });
|
|
268
|
+
const receipt = writeReceipt(workspaceDir, operation.operationId, { item: target, version: inspected.version, candidateId: operation.evidence.candidateId, candidateSha256: expected, refs: operation.pendingRefs ?? [], route: 'create-recovered' });
|
|
269
|
+
const finished = await finalizeCommitted(workspaceDir, { operation, receipt, owner, note });
|
|
270
|
+
resolved.push({ operationId: operation.operationId, action: 'recovered', receipt, ...finished });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!remote || !expected) {
|
|
276
|
+
note(
|
|
277
|
+
`operation ${operation.operationId} cannot be resolved (no binding or no candidate hash on record) — ` +
|
|
278
|
+
`it is left open and blocks publishing.`,
|
|
279
|
+
);
|
|
280
|
+
resolved.push({ operationId: operation.operationId, action: "unresolved" });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const inspection = await provider.inspectWriteOutcome(client, itemRef(remote), {
|
|
284
|
+
expectedSha256: expected,
|
|
285
|
+
expectedBytes: candidateSize(workspaceDir, operation),
|
|
286
|
+
});
|
|
287
|
+
if (inspection.landed === true) {
|
|
288
|
+
const receipt = writeReceipt(workspaceDir, operation.operationId, {
|
|
289
|
+
item: inspection.current ?? null,
|
|
290
|
+
version: inspection.version ?? null,
|
|
291
|
+
candidateId: operation.evidence?.candidateId ?? null,
|
|
292
|
+
candidateSha256: expected,
|
|
293
|
+
refs: operation.pendingRefs ?? [],
|
|
294
|
+
route: operation.evidence?.route ?? "recovered",
|
|
295
|
+
supersededByNewerRevision: Boolean(inspection.superseded),
|
|
296
|
+
});
|
|
297
|
+
const finished = await finalizeCommitted(workspaceDir, { operation, receipt, owner, note });
|
|
298
|
+
note(
|
|
299
|
+
inspection.superseded
|
|
300
|
+
? `the interrupted publish DID land; a newer revision has since replaced it, and that newer ` +
|
|
301
|
+
`revision is staged for you to reconcile.`
|
|
302
|
+
: `the interrupted publish had already landed — nothing was uploaded again.`,
|
|
303
|
+
);
|
|
304
|
+
resolved.push({ operationId: operation.operationId, action: "recovered", receipt, ...finished });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (inspection.landed === false) {
|
|
308
|
+
abandonOperation(
|
|
309
|
+
workspaceDir,
|
|
310
|
+
operation.operationId,
|
|
311
|
+
"inspection found our bytes in no stored version",
|
|
312
|
+
);
|
|
313
|
+
note(`the interrupted publish never landed — the candidate is intact and can be published again.`);
|
|
314
|
+
resolved.push({ operationId: operation.operationId, action: "never-landed" });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
note(
|
|
318
|
+
`the outcome of operation ${operation.operationId} could not be determined (${inspection.why}). ` +
|
|
319
|
+
`Nothing will be re-uploaded until it is.`,
|
|
320
|
+
);
|
|
321
|
+
resolved.push({ operationId: operation.operationId, action: "unknown", why: inspection.why });
|
|
322
|
+
}
|
|
323
|
+
return resolved;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Everything that happens locally AFTER the server confirmed: the base advances
|
|
328
|
+
* to the bytes that were stored, committed refs replace pending refs, and the
|
|
329
|
+
* extensions are told. The callback is idempotent and its failure never causes a
|
|
330
|
+
* re-upload — "saved remotely, local finalization pending" is a real state and
|
|
331
|
+
* says so.
|
|
332
|
+
*/
|
|
333
|
+
export async function finalizeCommitted(workspaceDir, { operation, receipt, owner, note = () => {} }) {
|
|
334
|
+
const candidateId = receipt.candidateId ?? operation?.evidence?.candidateId ?? null;
|
|
335
|
+
let advanced = null;
|
|
336
|
+
const revision = readRevision(workspaceDir);
|
|
337
|
+
if (candidateId && revision?.acceptedCandidate?.candidateId !== candidateId) {
|
|
338
|
+
try {
|
|
339
|
+
advanced = acceptCandidateFromReceipt(workspaceDir, candidateId, { owner, receipt });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// The remote copy IS saved. Failing to move the local base is a local
|
|
342
|
+
// problem with a local remedy, and saying "publish failed" here would send
|
|
343
|
+
// the user to re-upload bytes that are already there.
|
|
344
|
+
note(
|
|
345
|
+
`saved remotely, but the local accepted base could not be advanced (${err?.message ?? err}). ` +
|
|
346
|
+
`The receipt is on disk; re-run publish to finish the local half.`,
|
|
347
|
+
);
|
|
348
|
+
return { advanced: false, callbackFailed: true, finalizationPending: true };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
acceptReceipt(workspaceDir, receipt);
|
|
352
|
+
const context = contextOfBytes(candidateId ? candidateBytes(workspaceDir, candidateId) ?? [] : []);
|
|
353
|
+
const { declarations } = declaredExtensions(workspaceDir, context);
|
|
354
|
+
const callback = runDeclared(workspaceDir, declarations, "committed", {
|
|
355
|
+
operationId: operation?.operationId ?? receipt.operationId,
|
|
356
|
+
revision: revisionSummary(workspaceDir),
|
|
357
|
+
receipt,
|
|
358
|
+
context: context?.context ?? null,
|
|
359
|
+
});
|
|
360
|
+
const callbackFailed = callback.blockers.length > 0;
|
|
361
|
+
stepOperation(workspaceDir, receipt.operationId, {
|
|
362
|
+
phase: callbackFailed ? "committed" : "finalized",
|
|
363
|
+
step: callbackFailed ? "callback-failed" : "finalized",
|
|
364
|
+
committedRefs: receipt.refs ?? [],
|
|
365
|
+
pendingRefs: [],
|
|
366
|
+
});
|
|
367
|
+
if (callbackFailed) {
|
|
368
|
+
note(
|
|
369
|
+
`saved remotely; the extension callback did not complete (${callback.blockers[0]?.message}). ` +
|
|
370
|
+
`It is idempotent — re-running publish retries the callback, not the upload.`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return { advanced: Boolean(advanced), callbackFailed };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// pull
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Has anything changed remotely?
|
|
382
|
+
*
|
|
383
|
+
* Deliberately metadata-only. Ten unchanged pulls of an ordinary deck make ten
|
|
384
|
+
* metadata calls and download nothing — no content request, no conversion, no
|
|
385
|
+
* derived context version, no remote write of any kind.
|
|
386
|
+
*
|
|
387
|
+
* A rename is detected separately from a content change: same content identity,
|
|
388
|
+
* different name. It updates labels and nothing else, because re-hydrating a
|
|
389
|
+
* deck because somebody retitled the file is pure waste.
|
|
390
|
+
*/
|
|
391
|
+
export async function checkFreshness(workspaceDir, { provider, client }) {
|
|
392
|
+
const remote = readRemote(workspaceDir);
|
|
393
|
+
if (!remote) {
|
|
394
|
+
const e = new Error("this deck is not bound to a remote copy");
|
|
395
|
+
e.code = "identity_unresolved";
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
398
|
+
let item;
|
|
399
|
+
try {
|
|
400
|
+
item = await provider.getItem(client, itemRef(remote));
|
|
401
|
+
} catch (err) {
|
|
402
|
+
// A failed observation NEVER refreshes lastPulledAt: a timestamp that says
|
|
403
|
+
// "checked just now" when the check failed is worse than a stale one.
|
|
404
|
+
noteObservation(workspaceDir, { failure: { code: err?.code ?? "offline", message: err?.message } });
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
const contentSame = remote.acceptedCTag
|
|
408
|
+
? item.cTag === remote.acceptedCTag
|
|
409
|
+
: item.eTag === remote.acceptedETag;
|
|
410
|
+
const renamed = item.name !== remote.name;
|
|
411
|
+
const dependencies = await checkDependencies(workspaceDir, { provider, client });
|
|
412
|
+
noteObservation(workspaceDir, {
|
|
413
|
+
revision: {
|
|
414
|
+
eTag: item.eTag,
|
|
415
|
+
cTag: item.cTag,
|
|
416
|
+
size: item.size,
|
|
417
|
+
lastModifiedDateTime: item.lastModifiedDateTime,
|
|
418
|
+
},
|
|
419
|
+
labels: renamed && contentSame ? { name: item.name, webUrl: item.webUrl } : {},
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
item,
|
|
423
|
+
changed: !contentSame,
|
|
424
|
+
renameOnly: contentSame && renamed,
|
|
425
|
+
dependencies,
|
|
426
|
+
dependencyChanges: dependencies.filter((d) => d.changed),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Independently mutable inputs are checked EVEN WHEN the deck's own eTag is
|
|
432
|
+
* unchanged. A workbook someone edited this morning is an incoming business
|
|
433
|
+
* change; the deck bytes that reference it did not move, and a check that only
|
|
434
|
+
* looked at the deck would report "current" about content that no longer is.
|
|
435
|
+
*
|
|
436
|
+
* The pinned version stays pinned. What this produces is an OBSERVATION of the
|
|
437
|
+
* latest version, never a replacement of the approved one.
|
|
438
|
+
*/
|
|
439
|
+
export async function checkDependencies(workspaceDir, { provider, client, refs: pendingRefs = null }) {
|
|
440
|
+
const remote = readRemote(workspaceDir);
|
|
441
|
+
const refs = (pendingRefs ?? remote?.committedRefs ?? []).filter((r) => r.mutability === "mutable");
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const ref of refs) {
|
|
444
|
+
if (!ref.driveId || !ref.itemId) continue;
|
|
445
|
+
let item = null;
|
|
446
|
+
try {
|
|
447
|
+
item = await provider.getItem(client, { driveId: ref.driveId, itemId: ref.itemId });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
out.push({ ref, error: err?.code ?? "offline", changed: false, unreadable: true });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const latest = { eTag: item.eTag, cTag: item.cTag, size: item.size, name: item.name };
|
|
453
|
+
// Changed against the stored bytes this deck paired with (the ref's sha256
|
|
454
|
+
// is the server's hash, not the local upload's — see refFor).
|
|
455
|
+
let contentChanged = true;
|
|
456
|
+
if (ref.sha256) {
|
|
457
|
+
try {
|
|
458
|
+
const snapshot = await provider.downloadSnapshot(client, { driveId: ref.driveId, itemId: ref.itemId });
|
|
459
|
+
contentChanged = snapshot.sha256 !== ref.sha256;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
out.push({ ref, error: err?.code ?? "offline", changed: true, unreadable: true });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
out.push({
|
|
466
|
+
ref,
|
|
467
|
+
latest,
|
|
468
|
+
// Changed against the version this deck is PAIRED with, not against the
|
|
469
|
+
// last thing we happened to see.
|
|
470
|
+
changed: contentChanged,
|
|
471
|
+
pinnedVersionId: ref.versionId ?? null,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (out.length && readRemote(workspaceDir)) {
|
|
475
|
+
const remoteNow = readRemote(workspaceDir);
|
|
476
|
+
// Only ever ANNOTATE an existing binding. `{...null}` would write a record
|
|
477
|
+
// carrying nothing but observations; bindRemote then reads that stub as "bound
|
|
478
|
+
// to a different item" and throws rebind_required, so a first publish that
|
|
479
|
+
// happens to carry a mutable dependency could never write its receipt and the
|
|
480
|
+
// next attempt would 404 on the stub and re-create into a name_conflict.
|
|
481
|
+
writeRemote(workspaceDir, {
|
|
482
|
+
...remoteNow,
|
|
483
|
+
dependencyObservations: Object.fromEntries(
|
|
484
|
+
out.filter((d) => d.latest).map((d) => [d.ref.artifactId, { ...d.latest, at: nowIso() }]),
|
|
485
|
+
),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Pull: check, stage, and merge at an owned checkpoint.
|
|
493
|
+
*
|
|
494
|
+
* Staging is unconditional and harmless — immutable bytes under a
|
|
495
|
+
* revision-specific path, never a working file. Activation is the part that
|
|
496
|
+
* needs everything to line up: no slide changed on both sides, nothing
|
|
497
|
+
* half-written, ownership still ours, and the business side content with what
|
|
498
|
+
* arrived. Unpublished local edits are merged AROUND, never touched.
|
|
499
|
+
*/
|
|
500
|
+
export async function pullDeck(workspaceDir, { provider, client, owner, note = () => {} }) {
|
|
501
|
+
const freshness = await checkFreshness(workspaceDir, { provider, client });
|
|
502
|
+
if (!freshness.changed) {
|
|
503
|
+
return {
|
|
504
|
+
status: freshness.renameOnly ? "renamed" : "current",
|
|
505
|
+
...freshness,
|
|
506
|
+
activated: false,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const snapshot = await provider.downloadSnapshot(client, itemRef(readRemote(workspaceDir)));
|
|
511
|
+
const staged = stageIncoming(workspaceDir, {
|
|
512
|
+
bytes: snapshot.bytes,
|
|
513
|
+
source: {
|
|
514
|
+
kind: "remote",
|
|
515
|
+
provider: readRemote(workspaceDir)?.provider ?? "sharepoint",
|
|
516
|
+
itemId: readRemote(workspaceDir)?.itemId ?? null,
|
|
517
|
+
eTag: snapshot.revision?.eTag ?? null,
|
|
518
|
+
cTag: snapshot.revision?.cTag ?? null,
|
|
519
|
+
versionId: snapshot.revision?.versionId ?? null,
|
|
520
|
+
at: nowIso(),
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
// The incoming PPTX names its own exact context. It is fetched and staged with
|
|
524
|
+
// the bytes, as ONE candidate incoming set — pairing the new deck with the old
|
|
525
|
+
// deck's approved context would be a quieter version of the same loss this
|
|
526
|
+
// whole change exists to stop.
|
|
527
|
+
const incomingContext = contextOfBytes(snapshot.bytes);
|
|
528
|
+
const stagedContext = await stageIncomingContext(workspaceDir, staged.revisionId, incomingContext, {
|
|
529
|
+
provider,
|
|
530
|
+
client,
|
|
531
|
+
note,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const requiredErrors = stagedContext.filter(a => a.error);
|
|
535
|
+
if (requiredErrors.length || incomingContext?.corrupt || ((readRemote(workspaceDir)?.extensions ?? []).some(e => e.required !== false && !(incomingContext?.context?.extensions ?? []).some(i => i.id === e.id && i.required !== false)))) {
|
|
536
|
+
return { status: 'staged', ...freshness, staged, stagedContext, activated: false, reason: 'artifact_missing' };
|
|
537
|
+
}
|
|
538
|
+
const summary = revisionSummary(workspaceDir);
|
|
539
|
+
if (summary.localDirty) {
|
|
540
|
+
// Unpublished work here does not hold a merge up; a slide BOTH sides
|
|
541
|
+
// changed does. The rebase below never touches a local edit — it takes
|
|
542
|
+
// their slides around it and leaves every local change unpublished.
|
|
543
|
+
const classified = classifyRebase(workspaceDir, staged.revisionId);
|
|
544
|
+
if (classified.conflicts.length) {
|
|
545
|
+
recordConflicts(workspaceDir, classified);
|
|
546
|
+
note(
|
|
547
|
+
`${classified.conflicts.length} slide(s) changed on both sides, so the incoming save was staged ` +
|
|
548
|
+
`as ${staged.revisionId} and nothing local was touched.`,
|
|
549
|
+
);
|
|
550
|
+
return {
|
|
551
|
+
status: "staged",
|
|
552
|
+
...freshness,
|
|
553
|
+
staged,
|
|
554
|
+
stagedContext,
|
|
555
|
+
conflicts: classified.conflicts,
|
|
556
|
+
activated: false,
|
|
557
|
+
reason: "needs_reconcile",
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
assertCheckpoint(workspaceDir, {
|
|
563
|
+
owner,
|
|
564
|
+
generation: summary.generation,
|
|
565
|
+
digest: summary.inputDigest,
|
|
566
|
+
});
|
|
567
|
+
const tx = stageWorkspace(workspaceDir);
|
|
568
|
+
try {
|
|
569
|
+
tx.copySnapshot(staged.revisionId);
|
|
570
|
+
const rebase = applyRebase(tx.workspace, staged.revisionId, { owner, staged: true });
|
|
571
|
+
const remote = readRemote(workspaceDir);
|
|
572
|
+
writeRemote(tx.workspace, {
|
|
573
|
+
...remote,
|
|
574
|
+
acceptedETag: snapshot.revision?.eTag ?? remote.acceptedETag,
|
|
575
|
+
acceptedCTag: snapshot.revision?.cTag ?? remote.acceptedCTag,
|
|
576
|
+
acceptedSha256: snapshot.sha256,
|
|
577
|
+
name: freshness.item.name, webUrl: freshness.item.webUrl, lastPulledAt: nowIso(),
|
|
578
|
+
extensions: incomingContext?.context?.extensions ?? remote.extensions ?? [],
|
|
579
|
+
committedRefs: incomingContext?.context?.extensions?.flatMap(e => e.refs ?? []) ?? remote.committedRefs ?? [],
|
|
580
|
+
});
|
|
581
|
+
const { declarations } = declaredExtensions(tx.workspace, incomingContext);
|
|
582
|
+
// Resolve artifacts from the exact pinned snapshots already verified above.
|
|
583
|
+
const pinnedProvider = { ...provider,
|
|
584
|
+
downloadVersion: async (_client, ref, version) => {
|
|
585
|
+
const pin = declarations.flatMap(e => e.refs ?? []).find(r => r.driveId === ref.driveId && r.itemId === ref.itemId && r.versionId === version);
|
|
586
|
+
const artifact = stagedContext.find(a => a.artifactId === pin?.artifactId && a.sha256 === pin?.sha256);
|
|
587
|
+
if (!artifact?.file) throw new Error('Required pinned artifact is not staged');
|
|
588
|
+
return fs.readFileSync(artifact.file);
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
const hydrated = await hydrateContext(tx.workspace, { provider: pinnedProvider, client, context: incomingContext, note, staged: true });
|
|
592
|
+
if (hydrated.blockers?.length) return { status: 'staged', ...freshness, staged, stagedContext, activated: false, reason: 'validation_required', blockers: hydrated.blockers };
|
|
593
|
+
const assessment = runDeclared(tx.workspace, declarations, 'assess', {
|
|
594
|
+
revision: revisionSummary(tx.workspace), dependencies: pairedRefs(tx.workspace, incomingContext),
|
|
595
|
+
context: incomingContext?.context ?? null, incoming: { revisionId: staged.revisionId, sha256: snapshot.sha256 },
|
|
596
|
+
});
|
|
597
|
+
if (assessment.blockers.length) return { status: 'staged', ...freshness, staged, stagedContext, assessment, activated: false, reason: 'validation_required' };
|
|
598
|
+
tx.activate({ checkpoint: () => assertCheckpoint(workspaceDir, { owner, generation: summary.generation, digest: summary.inputDigest }) });
|
|
599
|
+
return { status: 'activated', ...freshness, staged, stagedContext, rebase, assessment, activated: true };
|
|
600
|
+
} finally { tx.dispose(); }
|
|
601
|
+
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Fetch and stage the exact artifacts an incoming deck's context pins. */
|
|
605
|
+
async function stageIncomingContext(workspaceDir, revisionId, context, { provider, client, note }) {
|
|
606
|
+
const refs = (context?.context?.extensions ?? []).flatMap((e) => e.refs ?? []);
|
|
607
|
+
if (!refs.length) return [];
|
|
608
|
+
const dir = path.join(stagedDir(workspaceDir, revisionId), "context");
|
|
609
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
610
|
+
const out = [];
|
|
611
|
+
for (const ref of refs) {
|
|
612
|
+
if (!ref.driveId || !ref.itemId) {
|
|
613
|
+
out.push({ artifactId: ref.artifactId, error: 'artifact_missing' });
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const bytes = ref.versionId
|
|
618
|
+
? await provider.downloadVersion(client, { driveId: ref.driveId, itemId: ref.itemId }, ref.versionId)
|
|
619
|
+
: (await provider.downloadSnapshot(client, { driveId: ref.driveId, itemId: ref.itemId })).bytes;
|
|
620
|
+
const file = path.join(dir, `${ref.sha256}-${ref.artifactId}`.replace(/[^A-Za-z0-9._-]/g, "-"));
|
|
621
|
+
if (!ref.sha256 || createHash("sha256").update(bytes).digest("hex") !== ref.sha256) {
|
|
622
|
+
throw Object.assign(new Error("pinned artifact hash mismatch"), { code: "artifact_hash_mismatch" });
|
|
623
|
+
}
|
|
624
|
+
fs.writeFileSync(file, bytes);
|
|
625
|
+
out.push({ artifactId: ref.artifactId, sha256: ref.sha256, file, bytes: bytes.length });
|
|
626
|
+
} catch (err) {
|
|
627
|
+
// An unreadable or deleted pinned version BLOCKS the dependent edit rather
|
|
628
|
+
// than substituting the latest — substituting is how a deck quietly starts
|
|
629
|
+
// citing numbers nobody approved.
|
|
630
|
+
note(
|
|
631
|
+
`the exact version of '${ref.artifactId}' this save was approved against could not be read ` +
|
|
632
|
+
`(${err?.message ?? err}); work depending on it is blocked rather than paired with the latest.`,
|
|
633
|
+
);
|
|
634
|
+
out.push({ artifactId: ref.artifactId, error: err?.code ?? "unreadable" });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// publish
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Publish a validated candidate back to the bound item.
|
|
646
|
+
*
|
|
647
|
+
* Returns a discriminated result rather than throwing for the expected outcomes,
|
|
648
|
+
* because every one of them is a state the user needs told about precisely:
|
|
649
|
+
* `committed`, `needs_reconcile`, `blocked`, `manual`, `unknown`.
|
|
650
|
+
*/
|
|
651
|
+
export async function publishDeck(
|
|
652
|
+
workspaceDir,
|
|
653
|
+
{
|
|
654
|
+
provider,
|
|
655
|
+
client,
|
|
656
|
+
owner,
|
|
657
|
+
candidateId = null,
|
|
658
|
+
manual = false,
|
|
659
|
+
destination = null,
|
|
660
|
+
sharedWorkbookAuthority = false,
|
|
661
|
+
note = () => {},
|
|
662
|
+
onProgress = null,
|
|
663
|
+
},
|
|
664
|
+
) {
|
|
665
|
+
const binding = readRemote(workspaceDir);
|
|
666
|
+
if (binding && destination) throw new Error('A bound deck cannot use --folder/--name; explicitly rebind it first.');
|
|
667
|
+
// First publish without --folder/--name: the folder the workspace saved
|
|
668
|
+
// (contracts §2 destination.json) stands in for the flags. Explicit flags
|
|
669
|
+
// win; a bound deck never consults it; every gate below runs unchanged.
|
|
670
|
+
let destinationSource = destination ? 'explicit' : null;
|
|
671
|
+
if (!binding && !destination) {
|
|
672
|
+
const saved = savedDestination(workspaceDir);
|
|
673
|
+
if (saved) {
|
|
674
|
+
destination = { driveId: saved.driveId, parentId: saved.parentId, name: saved.name };
|
|
675
|
+
destinationSource = 'saved';
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const remote = binding ?? (destination ? { provider: 'sharepoint', tenant: new URL(client.host).hostname, driveId: destination.driveId, parentId: destination.parentId, name: destination.name, deckId: `deck-${randomUUID()}`, documentInstance: null, extensions: [], committedRefs: [] } : null);
|
|
679
|
+
// The deck that CREATES its workbook owns it: authority is recorded in the
|
|
680
|
+
// binding on the first create and inherited by every later publish, so the
|
|
681
|
+
// workbook is one SharePoint item with native version history, like the
|
|
682
|
+
// deck. A cold copy of the deck gets a fork binding that records no
|
|
683
|
+
// authority and keeps forking — the case the default exists to protect.
|
|
684
|
+
const workbookAuthority = Boolean(destination) || sharedWorkbookAuthority || Boolean(binding?.sharedWorkbookAuthority);
|
|
685
|
+
if (!remote) {
|
|
686
|
+
const e = new Error("this deck is not bound to a remote copy — `slidemind open <link>` binds one.");
|
|
687
|
+
e.code = "identity_unresolved";
|
|
688
|
+
throw e;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 1. Recover anything a previous run left open. A deck with an unresolved
|
|
692
|
+
// outcome does not get to start a second write.
|
|
693
|
+
const recovered = await recoverOperations(workspaceDir, { provider, client, owner, note });
|
|
694
|
+
const unresolved = recovered.filter((r) => r.action === "unknown" || r.action === "unresolved");
|
|
695
|
+
if (unresolved.length) {
|
|
696
|
+
return { status: "blocked", reason: "unknown_outcome", recovered, blockers: [] };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!binding && readRemote(workspaceDir)) {
|
|
700
|
+
const committed = recovered.find(r => r.receipt);
|
|
701
|
+
if (committed) return { status: 'committed', receipt: committed.receipt, recovered };
|
|
702
|
+
return { status: 'blocked', reason: 'unknown_outcome', recovered };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 2. Pull and reconcile FIRST. Publishing onto a base somebody else has moved
|
|
706
|
+
// is the overwrite this protocol exists to make impossible.
|
|
707
|
+
const pull = destination ? { status: "current", item: {} } : await pullDeck(workspaceDir, { provider, client, owner, note });
|
|
708
|
+
const dependencies = await checkDependencies(workspaceDir, { provider, client });
|
|
709
|
+
if (dependencies.some((d) => d.changed || d.unreadable)) {
|
|
710
|
+
return { status: "needs_reconcile", reason: "needs_reconcile", dependencies };
|
|
711
|
+
}
|
|
712
|
+
if (pull.status === "staged") {
|
|
713
|
+
// `staged` rides at the top level so the renderer can name the rebase.
|
|
714
|
+
return { status: "needs_reconcile", reason: "needs_reconcile", pull, staged: pull.staged ?? null };
|
|
715
|
+
}
|
|
716
|
+
if (pull.status === "activated") {
|
|
717
|
+
// Their save is merged in, so every candidate was built on a base that is
|
|
718
|
+
// no longer current. Publish never renders: the next build does.
|
|
719
|
+
return { status: "needs_reconcile", reason: "merged", pull };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 3. The candidate. `build-pptx` produces it; publish never renders, so there
|
|
723
|
+
// is exactly one place a deck's bytes come from.
|
|
724
|
+
const revision = readRevision(workspaceDir);
|
|
725
|
+
const chosen = candidateId ?? revision?.candidate ?? null;
|
|
726
|
+
const candidate = chosen ? readCandidate(workspaceDir, chosen) : null;
|
|
727
|
+
const why = candidateStale(workspaceDir, candidate, revision);
|
|
728
|
+
if (why) {
|
|
729
|
+
return {
|
|
730
|
+
status: "blocked",
|
|
731
|
+
reason: "validation_required",
|
|
732
|
+
message: candidate
|
|
733
|
+
? `candidate ${chosen} cannot be published: ${why}. Re-run \`slidemind build-pptx\`.`
|
|
734
|
+
: `this deck has no candidate to publish — run \`slidemind build-pptx\` first.`,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// 4. Mode. Policy, grant, route proof and item access are four separate
|
|
739
|
+
// questions and this is where they meet.
|
|
740
|
+
const bytes = candidateBytes(workspaceDir, candidate.candidateId);
|
|
741
|
+
const sizing = provider.routeForSize(client.host, bytes.length);
|
|
742
|
+
const context = contextOfBytes(bytes);
|
|
743
|
+
const { declarations, source: contextSource } = declaredExtensions(workspaceDir, context);
|
|
744
|
+
|
|
745
|
+
// 5. The required gates, on the CURRENT content. A generic publish cannot
|
|
746
|
+
// walk past an extension's release checks, and there is no headless flag
|
|
747
|
+
// that skips them.
|
|
748
|
+
const manifest = runDeclared(workspaceDir, declarations, "manifest", {
|
|
749
|
+
revision: revisionSummary(workspaceDir),
|
|
750
|
+
dependencies: pairedRefs(workspaceDir, context),
|
|
751
|
+
context: context?.context ?? null,
|
|
752
|
+
});
|
|
753
|
+
const gates = {
|
|
754
|
+
extensionMissing: manifest.missing.some((m) => m.required && m.blocker?.code === "extension_missing"),
|
|
755
|
+
extensionIncompatible: manifest.blockers.some((b) => b.code === "extension_incompatible"),
|
|
756
|
+
contextMissing:
|
|
757
|
+
Boolean(context?.corrupt) || (contextSource === "none" && (revision?.origin?.kind ?? "") !== "authored"),
|
|
758
|
+
};
|
|
759
|
+
let decision = decideMode({ provider, client, remote, gates,
|
|
760
|
+
policy: manual ? { remoteSavingDisabled: true } : {} });
|
|
761
|
+
if (destination && !manual && !gates.contextMissing && !manifest.blockers.length) decision = { ...decision, mode: 'automatic', canPublish: true, reason: null };
|
|
762
|
+
if (decision.mode === "blocked" || manifest.blockers.length || gates.contextMissing) {
|
|
763
|
+
return {
|
|
764
|
+
status: "blocked",
|
|
765
|
+
reason: decision.reason ?? "extension_missing",
|
|
766
|
+
decision,
|
|
767
|
+
blockers: manifest.blockers,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const collected = collectManifest(workspaceDir, mergeArtifacts(manifest), { extensionId: null });
|
|
771
|
+
if (collected.problems.length) {
|
|
772
|
+
return { status: "blocked", reason: "validation_required", blockers: collected.problems };
|
|
773
|
+
}
|
|
774
|
+
if (decision.mode !== "automatic") {
|
|
775
|
+
const unresolved = collected.artifacts.filter(a => a.uploadable).map(a => ({ artifactId: a.artifactId, purpose: a.purpose ?? 'attachment', required: a.required !== false, mutability: 'immutable', sha256: a.sha256, bytes: a.bytes }));
|
|
776
|
+
const manualContext = finalizeContext({ context, remote, refs: unresolved, owners: new Map(collected.artifacts.map(a => [a.artifactId, a.extensionId])), declarations, operationId: context?.context?.operationId ?? `manual-${candidate.candidateId}` });
|
|
777
|
+
const manualCandidate = saveCandidate(workspaceDir, { bytes: withContext(bytes, manualContext), validation: candidate.validation, review: candidate.review });
|
|
778
|
+
const validation = runDeclared(workspaceDir, declarations, "validate", {
|
|
779
|
+
revision: revisionSummary(workspaceDir),
|
|
780
|
+
dependencies: pairedRefs(workspaceDir, context),
|
|
781
|
+
context: context?.context ?? null,
|
|
782
|
+
candidate: { candidateId: manualCandidate.candidateId, sha256: manualCandidate.outputSha256,
|
|
783
|
+
bytes: manualCandidate.outputBytes, path: path.join(candidateDir(workspaceDir, manualCandidate.candidateId), CANDIDATE_PPTX) },
|
|
784
|
+
});
|
|
785
|
+
if (validation.blockers.length) return { status: "blocked", reason: "validation_required", blockers: validation.blockers };
|
|
786
|
+
const handoff = await prepareManualHandoff(workspaceDir, {
|
|
787
|
+
candidate: manualCandidate,
|
|
788
|
+
manifest,
|
|
789
|
+
context: { context: manualContext },
|
|
790
|
+
decision,
|
|
791
|
+
note,
|
|
792
|
+
});
|
|
793
|
+
return { status: "manual", reason: decision.reason, decision, handoff };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 6. What will be uploaded, decided before anything leaves. The allowlist is
|
|
797
|
+
// exactly the declared artifacts — never a glob, never the workspace.
|
|
798
|
+
const changed = collected.artifacts.filter(
|
|
799
|
+
(a) => a.uploadable && !alreadyCommitted(remote, a),
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
const operation = beginOperation(workspaceDir, {
|
|
803
|
+
kind: destination ? "create" : "publish",
|
|
804
|
+
destination,
|
|
805
|
+
deck: {
|
|
806
|
+
provider: remote.provider,
|
|
807
|
+
driveId: remote.driveId,
|
|
808
|
+
itemId: remote.itemId,
|
|
809
|
+
expectedETag: pull.item.eTag,
|
|
810
|
+
},
|
|
811
|
+
evidence: {
|
|
812
|
+
candidateId: candidate.candidateId,
|
|
813
|
+
candidateSha256: candidate.outputSha256,
|
|
814
|
+
baseSha256: candidate.baseSha256,
|
|
815
|
+
inputDigest: candidate.inputDigest,
|
|
816
|
+
generation: candidate.generation,
|
|
817
|
+
route: destination ? "create" : sizing.large ? "conditionalLarge" : "conditionalSmall",
|
|
818
|
+
},
|
|
819
|
+
plannedArtifacts: changed.map((a) => ({
|
|
820
|
+
artifactId: a.artifactId,
|
|
821
|
+
purpose: a.purpose,
|
|
822
|
+
mutability: a.mutability,
|
|
823
|
+
sha256: a.sha256,
|
|
824
|
+
bytes: a.bytes,
|
|
825
|
+
})),
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// 7. Artifacts, before the deck. A context that exists without a deck naming
|
|
829
|
+
// it is harmless; a deck naming a context that does not exist is not.
|
|
830
|
+
stepOperation(workspaceDir, operation.operationId, { phase: "artifacts", step: "artifacts" });
|
|
831
|
+
const uploaded = await uploadArtifacts(workspaceDir, {
|
|
832
|
+
provider,
|
|
833
|
+
client,
|
|
834
|
+
remote,
|
|
835
|
+
artifacts: changed,
|
|
836
|
+
sharedWorkbookAuthority: workbookAuthority,
|
|
837
|
+
operationId: operation.operationId,
|
|
838
|
+
note,
|
|
839
|
+
});
|
|
840
|
+
if (uploaded.failed.length) {
|
|
841
|
+
stepOperation(workspaceDir, operation.operationId, {
|
|
842
|
+
step: "artifacts-failed",
|
|
843
|
+
pendingRefs: uploaded.refs,
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
status: "blocked",
|
|
847
|
+
reason: uploaded.failed[0].reason ?? "target_access_denied",
|
|
848
|
+
message: `an artifact could not be written: ${uploaded.failed[0].why}`,
|
|
849
|
+
operation,
|
|
850
|
+
partial: uploaded,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
stepOperation(workspaceDir, operation.operationId, { pendingRefs: uploaded.refs, step: "artifacts-done" });
|
|
854
|
+
|
|
855
|
+
// 8. Finalize the tag against the refs that actually resolved, in a NEW
|
|
856
|
+
// immutable candidate — the bytes that are validated are the bytes that
|
|
857
|
+
// are uploaded.
|
|
858
|
+
const movedInputs = candidateStale(workspaceDir, candidate);
|
|
859
|
+
if (movedInputs) {
|
|
860
|
+
abandonOperation(workspaceDir, operation.operationId, "candidate inputs changed during artifact preparation");
|
|
861
|
+
return { status: "blocked", reason: "validation_required", message: movedInputs, pendingRefs: uploaded.refs };
|
|
862
|
+
}
|
|
863
|
+
const finalContext = finalizeContext({
|
|
864
|
+
context,
|
|
865
|
+
remote,
|
|
866
|
+
refs: uploaded.refs,
|
|
867
|
+
owners: uploaded.owners,
|
|
868
|
+
declarations,
|
|
869
|
+
operationId: operation.operationId,
|
|
870
|
+
});
|
|
871
|
+
const finalBytes = withContext(bytes, finalContext);
|
|
872
|
+
// Same reason the journal restates the hash below: the ROUTE has to be judged
|
|
873
|
+
// on the bytes that go on the wire. Baking the finalized context grows the
|
|
874
|
+
// package, so a candidate sitting just under the single-PUT ceiling crosses it
|
|
875
|
+
// here — and the small route answers an oversized PUT with a 413, which is an
|
|
876
|
+
// `unknown` outcome that blocks publishing and repeats on every retry.
|
|
877
|
+
const finalSizing = provider.routeForSize(client.host, finalBytes.length);
|
|
878
|
+
const finalCandidate = saveCandidate(workspaceDir, {
|
|
879
|
+
bytes: finalBytes,
|
|
880
|
+
validation: candidate.validation,
|
|
881
|
+
review: candidate.review,
|
|
882
|
+
});
|
|
883
|
+
// The journal must name the bytes that will actually be UPLOADED. Baking the
|
|
884
|
+
// finalized context changed them, so leaving the pre-finalization hash here
|
|
885
|
+
// would send crash recovery looking for a package that was never sent — and it
|
|
886
|
+
// would answer "never landed" about a write that did.
|
|
887
|
+
stepOperation(workspaceDir, operation.operationId, {
|
|
888
|
+
step: "finalize-candidate",
|
|
889
|
+
pendingRefs: finalContext.extensions.flatMap((e) => e.refs ?? []),
|
|
890
|
+
evidence: {
|
|
891
|
+
...operation.evidence,
|
|
892
|
+
candidateId: finalCandidate.candidateId,
|
|
893
|
+
candidateSha256: finalCandidate.outputSha256,
|
|
894
|
+
contextDigest: contextDigest(finalContext),
|
|
895
|
+
route: destination ? "create" : finalSizing.large ? "conditionalLarge" : "conditionalSmall",
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// 9. Required release checks on the FINAL bytes and the FINAL dependencies.
|
|
900
|
+
// Evidence from before the refs resolved describes different content.
|
|
901
|
+
const validation = runDeclared(workspaceDir, declarations, "validate", {
|
|
902
|
+
operationId: operation.operationId,
|
|
903
|
+
revision: revisionSummary(workspaceDir),
|
|
904
|
+
dependencies: finalContext.extensions.flatMap((e) => e.refs ?? []),
|
|
905
|
+
context: finalContext,
|
|
906
|
+
candidate: {
|
|
907
|
+
candidateId: finalCandidate.candidateId,
|
|
908
|
+
sha256: finalCandidate.outputSha256,
|
|
909
|
+
bytes: finalBytes.length,
|
|
910
|
+
path: path.join(candidateDir(workspaceDir, finalCandidate.candidateId), CANDIDATE_PPTX),
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
if (validation.blockers.length) {
|
|
914
|
+
stepOperation(workspaceDir, operation.operationId, { step: "validation-blocked" });
|
|
915
|
+
return {
|
|
916
|
+
status: "blocked",
|
|
917
|
+
reason: "validation_required",
|
|
918
|
+
blockers: validation.blockers,
|
|
919
|
+
operation,
|
|
920
|
+
pendingRefs: uploaded.refs,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 10. Recheck immediately before the write. The If-Match is the eTag observed
|
|
925
|
+
// NOW — never one silently carried from step 2.
|
|
926
|
+
const fresh = destination ? {} : await provider.getItem(client, itemRef(remote));
|
|
927
|
+
const finalDependencies = await checkDependencies(workspaceDir, { provider, client, refs: finalContext.extensions.flatMap((e) => e.refs ?? []) });
|
|
928
|
+
if (finalDependencies.some((d) => d.changed || d.unreadable)) {
|
|
929
|
+
abandonOperation(workspaceDir, operation.operationId, "a paired dependency changed before the write");
|
|
930
|
+
return { status: "needs_reconcile", reason: "needs_reconcile", dependencies: finalDependencies, pendingRefs: uploaded.refs };
|
|
931
|
+
}
|
|
932
|
+
if (fresh.eTag !== pull.item.eTag || fresh.cTag !== pull.item.cTag) {
|
|
933
|
+
note("the item changed while this publish was preparing — nothing was written.");
|
|
934
|
+
abandonOperation(workspaceDir, operation.operationId, "the item moved before the write");
|
|
935
|
+
return { status: "needs_reconcile", reason: "needs_reconcile", observed: fresh, pendingRefs: uploaded.refs };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
stepOperation(workspaceDir, operation.operationId, { phase: "deck-write", step: "replace" });
|
|
939
|
+
const changedCandidate = candidateStale(workspaceDir, finalCandidate);
|
|
940
|
+
if (changedCandidate) {
|
|
941
|
+
abandonOperation(workspaceDir, operation.operationId, "candidate inputs changed before replacement");
|
|
942
|
+
return { status: "blocked", reason: "validation_required", message: changedCandidate, pendingRefs: uploaded.refs };
|
|
943
|
+
}
|
|
944
|
+
const write = destination ? await provider.createConflictFail(client, destination, destination.name, finalBytes) : finalSizing.large
|
|
945
|
+
? await provider.replaceLarge(client, itemRef(remote), finalBytes, {
|
|
946
|
+
ifMatch: fresh.eTag,
|
|
947
|
+
onProgress,
|
|
948
|
+
})
|
|
949
|
+
: await provider.replaceSmall(client, itemRef(remote), finalBytes, { ifMatch: fresh.eTag });
|
|
950
|
+
|
|
951
|
+
if (write.outcome === "unknown") {
|
|
952
|
+
// The one state that must never resolve itself optimistically. The record
|
|
953
|
+
// stays at `deck-write`; the next run inspects.
|
|
954
|
+
stepOperation(workspaceDir, operation.operationId, { step: "unknown-outcome", why: write.why });
|
|
955
|
+
return { status: "unknown", reason: "unknown_outcome", why: write.why, operation, pendingRefs: uploaded.refs };
|
|
956
|
+
}
|
|
957
|
+
if (write.outcome === "refused") {
|
|
958
|
+
if (write.reason === "needs_reconcile") {
|
|
959
|
+
// A 412 is this mechanism working. Candidate and accepted base are kept;
|
|
960
|
+
// the newer save is staged for reconciliation.
|
|
961
|
+
const snapshot = await provider.downloadSnapshot(client, itemRef(remote));
|
|
962
|
+
const staged = stageIncoming(workspaceDir, {
|
|
963
|
+
bytes: snapshot.bytes,
|
|
964
|
+
source: { kind: "remote", reason: "412", at: nowIso(), eTag: snapshot.revision?.eTag ?? null },
|
|
965
|
+
});
|
|
966
|
+
abandonOperation(workspaceDir, operation.operationId, "the expected revision no longer matched");
|
|
967
|
+
return {
|
|
968
|
+
status: "needs_reconcile",
|
|
969
|
+
reason: "needs_reconcile",
|
|
970
|
+
staged,
|
|
971
|
+
pendingRefs: uploaded.refs,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
abandonOperation(workspaceDir, operation.operationId, `refused: ${write.reason}`);
|
|
975
|
+
return { status: "blocked", reason: write.reason, operation, pendingRefs: uploaded.refs };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// 11. Confirmed. Identity is checked before anything advances — a 200 that
|
|
979
|
+
// describes a different item is not our commit.
|
|
980
|
+
if (!destination && write.item?.itemId && write.item.itemId !== remote.itemId) {
|
|
981
|
+
return {
|
|
982
|
+
status: "unknown",
|
|
983
|
+
reason: "unknown_outcome",
|
|
984
|
+
why: `the write was confirmed against item ${write.item.itemId}, not the bound ${remote.itemId}`,
|
|
985
|
+
operation,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
if (destination) {
|
|
989
|
+
if (!write.item?.itemId) return { status: 'unknown', reason: 'unknown_outcome', operation };
|
|
990
|
+
// Same evidence `open` records: the create just proved read AND write on
|
|
991
|
+
// this item, and `ready` reports the mode from disk, so a binding without
|
|
992
|
+
// capabilities would read as "manual" until the next open.
|
|
993
|
+
bindRemote(workspaceDir, {
|
|
994
|
+
identity: { provider: 'sharepoint', tenant: remote.tenant, driveId: write.item.driveId, itemId: write.item.itemId },
|
|
995
|
+
labels: { name: write.item.name, parentId: write.item.parentId, webUrl: write.item.webUrl },
|
|
996
|
+
deckId: remote.deckId,
|
|
997
|
+
extensions: finalContext.extensions,
|
|
998
|
+
access: { read: 'allowed', write: 'allowed' },
|
|
999
|
+
capabilities: provider.capabilities(client.host).routes,
|
|
1000
|
+
// This deck created its shared inputs, so it owns them from here on.
|
|
1001
|
+
sharedWorkbookAuthority: true,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
const receipt = writeReceipt(workspaceDir, operation.operationId, {
|
|
1005
|
+
item: write.item,
|
|
1006
|
+
version: write.version,
|
|
1007
|
+
candidateId: finalCandidate.candidateId,
|
|
1008
|
+
candidateSha256: finalCandidate.outputSha256,
|
|
1009
|
+
refs: finalContext.extensions.flatMap((e) => e.refs ?? []),
|
|
1010
|
+
route: write.route,
|
|
1011
|
+
});
|
|
1012
|
+
const finished = await finalizeCommitted(workspaceDir, { operation, receipt, owner, note });
|
|
1013
|
+
// `--shared-workbook` on a binding that lacked authority grants it for good:
|
|
1014
|
+
// the workbook this publish replaced or created is now this deck's to keep
|
|
1015
|
+
// replacing, without the flag on every later run.
|
|
1016
|
+
if (workbookAuthority && !readRemote(workspaceDir)?.sharedWorkbookAuthority) {
|
|
1017
|
+
writeRemote(workspaceDir, { ...readRemote(workspaceDir), sharedWorkbookAuthority: true });
|
|
1018
|
+
}
|
|
1019
|
+
return { status: "committed", receipt, operation, destination: destinationSource, ...finished };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Every extension's artifacts, in one list the collector can walk — each tagged
|
|
1023
|
+
// with the extension that declared it, so the finalized context puts each ref
|
|
1024
|
+
// back under its own owner rather than under whoever happened to be first.
|
|
1025
|
+
function mergeArtifacts(manifest) {
|
|
1026
|
+
return {
|
|
1027
|
+
artifacts: manifest.results.flatMap((r) =>
|
|
1028
|
+
(r.response.artifacts ?? []).map((a) => ({ ...a, extensionId: r.id })),
|
|
1029
|
+
),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// An artifact whose exact bytes are already committed needs no upload. Local
|
|
1034
|
+
// timestamps and dirty flags deliberately play no part: they change on every
|
|
1035
|
+
// build, and letting them cause a context version would republish a deck's
|
|
1036
|
+
// approved plan because someone opened it.
|
|
1037
|
+
function alreadyCommitted(remote, artifact) {
|
|
1038
|
+
return (remote?.committedRefs ?? []).some(
|
|
1039
|
+
(ref) => ref.artifactId === artifact.artifactId && ref.sha256 === artifact.sha256 && ref.driveId && ref.itemId && (ref.mutability !== "mutable" || ref.versionId),
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Write the changed durable artifacts and return the portable refs that pair
|
|
1045
|
+
* with them.
|
|
1046
|
+
*
|
|
1047
|
+
* Immutable artifacts are content-addressed and created with conflict-fail: an
|
|
1048
|
+
* existing object with the SAME bytes is reused, and one with different bytes is
|
|
1049
|
+
* never overwritten — a published immutable context is somebody's evidence.
|
|
1050
|
+
*
|
|
1051
|
+
* A mutable artifact (the shared workbook) is replaced only with explicit
|
|
1052
|
+
* authority, and only when the exact version it becomes can be pinned. The
|
|
1053
|
+
* default is fork-on-change, because silently editing the origin's workbook from
|
|
1054
|
+
* a copy of the deck is the worst outcome in this whole protocol.
|
|
1055
|
+
*/
|
|
1056
|
+
export async function uploadArtifacts(
|
|
1057
|
+
workspaceDir,
|
|
1058
|
+
{ provider, client, remote, artifacts, sharedWorkbookAuthority, operationId, note },
|
|
1059
|
+
) {
|
|
1060
|
+
const refs = [];
|
|
1061
|
+
const failed = [];
|
|
1062
|
+
// Which extension owns each ref. Kept BESIDE the refs rather than on them:
|
|
1063
|
+
// a portable ref carries storage identity and nothing else, and adding a
|
|
1064
|
+
// convenience field to it would fail the whitelist that keeps it portable.
|
|
1065
|
+
const owners = new Map();
|
|
1066
|
+
const folder = { driveId: remote.driveId, parentId: remote.parentId };
|
|
1067
|
+
for (const artifact of artifacts) {
|
|
1068
|
+
owners.set(artifact.artifactId, artifact.extensionId ?? null);
|
|
1069
|
+
const bytes = fs.readFileSync(artifact.file);
|
|
1070
|
+
if (createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) {
|
|
1071
|
+
failed.push({ artifactId: artifact.artifactId, why: "artifact changed after manifest validation", reason: "validation_required" });
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
const owned = artifact.mutability === "mutable" && sharedWorkbookAuthority;
|
|
1075
|
+
const bound = owned ? (remote.committedRefs ?? []).find((r) => r.artifactId === artifact.artifactId) : null;
|
|
1076
|
+
const name = artifactName(remote, artifact, owned ? "owned" : artifact.mutability === "mutable" ? "fork" : "immutable");
|
|
1077
|
+
stepOperation(workspaceDir, operationId, { artifactWrite: {
|
|
1078
|
+
artifact: { artifactId: artifact.artifactId, purpose: artifact.purpose, required: artifact.required, sha256: artifact.sha256, bytes: artifact.bytes },
|
|
1079
|
+
sha256: artifact.sha256, mutable: owned, folder, name,
|
|
1080
|
+
target: bound ?? null,
|
|
1081
|
+
}, pendingRefs: refs });
|
|
1082
|
+
if (artifact.mutability === "mutable") {
|
|
1083
|
+
if (!owned) {
|
|
1084
|
+
note(
|
|
1085
|
+
`'${artifact.artifactId}' is a shared mutable input and this deck has no declared authority ` +
|
|
1086
|
+
`over it, so it is forked rather than overwritten. Pass --shared-workbook to change that.`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
// Owned: the first publish creates the item under its stable name, every
|
|
1090
|
+
// later one replaces that same item under If-Match. Not owned: a
|
|
1091
|
+
// content-addressed fork, reused when the bytes did not change.
|
|
1092
|
+
const result = owned
|
|
1093
|
+
? bound
|
|
1094
|
+
? await replaceMutable(provider, client, remote, artifact, bytes)
|
|
1095
|
+
: await createMutable(provider, client, folder, artifact, bytes, name)
|
|
1096
|
+
: await createImmutable(provider, client, folder, artifact, bytes, name);
|
|
1097
|
+
if (result.error) {
|
|
1098
|
+
failed.push({ artifactId: artifact.artifactId, why: result.error, reason: result.reason });
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
refs.push(result.ref);
|
|
1102
|
+
stepOperation(workspaceDir, operationId, { pendingRefs: refs, artifactWrite: null });
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const result = await createImmutable(provider, client, folder, artifact, bytes, name);
|
|
1106
|
+
if (result.error) {
|
|
1107
|
+
failed.push({ artifactId: artifact.artifactId, why: result.error, reason: result.reason });
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
refs.push(result.ref);
|
|
1111
|
+
stepOperation(workspaceDir, operationId, { pendingRefs: refs, artifactWrite: null });
|
|
1112
|
+
}
|
|
1113
|
+
return { refs, failed, owners };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* What an artifact is called next to the deck. The deck's own file stem leads,
|
|
1118
|
+
* so a folder reads as one deck rather than a pile of hashes:
|
|
1119
|
+
*
|
|
1120
|
+
* owned <deck>-calculation-workbook.xlsx one item, versioned in place
|
|
1121
|
+
* immutable <deck>-<artifact>-1a2b3c4d.json content-addressed, reused on equal bytes
|
|
1122
|
+
* fork <deck>-calculation-workbook-fork-1a2b3c4d.xlsx ditto — a republish of unchanged
|
|
1123
|
+
* bytes must not add a file
|
|
1124
|
+
*/
|
|
1125
|
+
export function artifactName(remote, artifact, kind) {
|
|
1126
|
+
const deck = String(remote?.name ?? "deck").replace(/\.pptx$/i, "").replace(/[^A-Za-z0-9._-]/g, "-");
|
|
1127
|
+
const id = String(artifact.artifactId).replace(/[^A-Za-z0-9._-]/g, "-");
|
|
1128
|
+
const ext = path.extname(artifact.file || "") || "";
|
|
1129
|
+
const hash = artifact.sha256.slice(0, 8);
|
|
1130
|
+
if (kind === "owned") return `${deck}-${id}${ext}`;
|
|
1131
|
+
if (kind === "fork") return `${deck}-${id}-fork-${hash}${ext}`;
|
|
1132
|
+
return `${deck}-${id}-${hash}${ext}`;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// The exact version an item holds these bytes under — the only honest pairing
|
|
1136
|
+
// for a mutable artifact, since an eTag says "unchanged since" and is not a
|
|
1137
|
+
// content address.
|
|
1138
|
+
// A ref's `sha256` is the hash of what the SERVER stores, read back after the
|
|
1139
|
+
// write. SharePoint rewrites an Office package on upload (observed live
|
|
1140
|
+
// 2026-09-17: a workbook came back 1.9 KB larger, with `[trash]/*.dat` parts and
|
|
1141
|
+
// custom XML added), so the bytes we sent are not a usable identity for any
|
|
1142
|
+
// artifact of any type — and a cold machine, which only has the ref, must be
|
|
1143
|
+
// able to verify what it downloads against it. The extension keeps its own
|
|
1144
|
+
// local hash; the `committed` callback hands it the stored one.
|
|
1145
|
+
async function storedIdentity(provider, client, target) {
|
|
1146
|
+
const stored = await provider.downloadSnapshot(client, target).catch(() => null);
|
|
1147
|
+
return stored?.sha256 ?? null;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function pinAfterWrite(provider, client, target, written, { attempts = 5, delayMs = 1500 } = {}) {
|
|
1151
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1152
|
+
const current = await provider.getItem(client, target).catch(() => null);
|
|
1153
|
+
if (current && written?.eTag && current.eTag !== written.eTag) {
|
|
1154
|
+
return { error: "the item moved on before its version could be pinned", reason: "needs_reconcile" };
|
|
1155
|
+
}
|
|
1156
|
+
// The newest version IS ours as long as the eTag still matches the write.
|
|
1157
|
+
// SharePoint lists it a moment after the write returns, hence the retry.
|
|
1158
|
+
const versions = await provider.listVersions(client, target).catch(() => []);
|
|
1159
|
+
if (versions.length) {
|
|
1160
|
+
return { versionId: versions[0].versionId, storedSha256: await storedIdentity(provider, client, target) };
|
|
1161
|
+
}
|
|
1162
|
+
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1163
|
+
}
|
|
1164
|
+
return { error: "no version was listed for the write, so it cannot be pinned", reason: "conditional_write_unsupported" };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async function createMutable(provider, client, folder, artifact, bytes, name) {
|
|
1168
|
+
const created = await createImmutable(provider, client, folder, artifact, bytes, name);
|
|
1169
|
+
if (created.error) return created;
|
|
1170
|
+
const target = { driveId: created.ref.driveId, itemId: created.ref.itemId };
|
|
1171
|
+
const pinned = await pinAfterWrite(provider, client, target, created.written ?? null);
|
|
1172
|
+
if (pinned.error) return { error: `'${artifact.artifactId}' was created but ${pinned.error}`, reason: pinned.reason };
|
|
1173
|
+
return { ref: refFor(client, artifact, created.written, { versionId: pinned.versionId, mutability: "mutable", storedSha256: pinned.storedSha256 }) };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function createImmutable(provider, client, folder, artifact, bytes, name) {
|
|
1177
|
+
const existing = await provider.getItemByPath(client, folder, name).catch(() => null);
|
|
1178
|
+
if (existing) {
|
|
1179
|
+
// The name is content-addressed by the local bytes' hash (or, for an owned
|
|
1180
|
+
// mutable input, reserved for this deck), so an item under it was written
|
|
1181
|
+
// from these bytes — by this deck, possibly by an attempt that died before
|
|
1182
|
+
// it could journal the answer. Its identity is what the server holds now.
|
|
1183
|
+
const storedSha256 = await storedIdentity(provider, client, { driveId: existing.driveId, itemId: existing.itemId });
|
|
1184
|
+
return { ref: refFor(client, artifact, existing, { versionId: null, storedSha256 }), written: existing, reused: true };
|
|
1185
|
+
}
|
|
1186
|
+
// The deck's own write routes by size (`routeForSize`); this one had no such
|
|
1187
|
+
// check, so an artifact above the single-PUT ceiling 413'd into an
|
|
1188
|
+
// `unknown` outcome that blocks publishing and repeats the same oversized PUT
|
|
1189
|
+
// on every retry. There is no create-through-session route, so refuse plainly.
|
|
1190
|
+
const sizing = provider.routeForSize(client.host, bytes.length);
|
|
1191
|
+
if (sizing.large) {
|
|
1192
|
+
return {
|
|
1193
|
+
error: `'${artifact.artifactId}' is ${bytes.length} bytes; a new artifact cannot exceed ${sizing.smallMaxBytes}`,
|
|
1194
|
+
reason: "conditional_write_unsupported",
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const result = await provider.createConflictFail(client, folder, name, bytes);
|
|
1198
|
+
if (result.outcome !== "committed") {
|
|
1199
|
+
return {
|
|
1200
|
+
error: result.why ?? `creating '${name}' was refused (${result.reason})`,
|
|
1201
|
+
reason: result.reason ?? "unknown_outcome",
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const storedSha256 = await storedIdentity(provider, client, { driveId: result.item.driveId, itemId: result.item.itemId });
|
|
1205
|
+
return { ref: refFor(client, artifact, result.item, { versionId: null, storedSha256 }), written: result.item };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async function replaceMutable(provider, client, remote, artifact, bytes) {
|
|
1209
|
+
const ref = (remote.committedRefs ?? []).find((r) => r.artifactId === artifact.artifactId);
|
|
1210
|
+
if (!ref?.itemId) {
|
|
1211
|
+
return { error: `'${artifact.artifactId}' has no bound item to replace`, reason: "identity_unresolved" };
|
|
1212
|
+
}
|
|
1213
|
+
const target = { driveId: ref.driveId, itemId: ref.itemId };
|
|
1214
|
+
const current = await provider.getItem(client, target);
|
|
1215
|
+
// The ref's sha256 is what the server stored at the pairing; the item is
|
|
1216
|
+
// unchanged if it still hashes to that.
|
|
1217
|
+
const snapshot = await provider.downloadSnapshot(client, target);
|
|
1218
|
+
if (snapshot.sha256 !== ref.sha256 || (snapshot.revision?.eTag && snapshot.revision.eTag !== current.eTag)) {
|
|
1219
|
+
return { error: `'${artifact.artifactId}' changed since its approved pairing`, reason: "needs_reconcile" };
|
|
1220
|
+
}
|
|
1221
|
+
// Same routing rule as the deck's own write: above the single-PUT ceiling the
|
|
1222
|
+
// bytes go through an upload session. Without this a large shared workbook 413'd
|
|
1223
|
+
// into an `unknown` outcome the deck could never clear or retry past.
|
|
1224
|
+
const sizing = provider.routeForSize(client.host, bytes.length);
|
|
1225
|
+
const result = sizing.large
|
|
1226
|
+
? await provider.replaceLarge(client, target, bytes, { ifMatch: current.eTag })
|
|
1227
|
+
: await provider.replaceSmall(client, target, bytes, { ifMatch: current.eTag });
|
|
1228
|
+
if (result.outcome !== "committed") {
|
|
1229
|
+
return {
|
|
1230
|
+
error: result.why ?? `replacing '${artifact.artifactId}' was refused (${result.reason})`,
|
|
1231
|
+
reason: result.reason ?? "unknown_outcome",
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
const pinned = await pinAfterWrite(provider, client, target, result.item);
|
|
1235
|
+
if (pinned.error) return { error: `'${artifact.artifactId}' was replaced but ${pinned.error}`, reason: pinned.reason };
|
|
1236
|
+
return { ref: refFor(client, artifact, result.item, { versionId: pinned.versionId, mutability: "mutable", storedSha256: pinned.storedSha256 }) };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function refFor(client, artifact, item, { versionId, mutability = "immutable", storedSha256 = null }) {
|
|
1240
|
+
return {
|
|
1241
|
+
artifactId: artifact.artifactId,
|
|
1242
|
+
purpose: artifact.purpose,
|
|
1243
|
+
required: artifact.required !== false,
|
|
1244
|
+
mutability,
|
|
1245
|
+
provider: "sharepoint",
|
|
1246
|
+
tenant: new URL(client.host).hostname,
|
|
1247
|
+
host: new URL(client.host).hostname,
|
|
1248
|
+
driveId: item.driveId,
|
|
1249
|
+
itemId: item.itemId,
|
|
1250
|
+
...(versionId ? { versionId } : {}),
|
|
1251
|
+
// The stored bytes' hash — the one a download can be checked against.
|
|
1252
|
+
sha256: storedSha256 ?? artifact.sha256,
|
|
1253
|
+
bytes: artifact.bytes,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* The context these exact bytes will ship with.
|
|
1259
|
+
*
|
|
1260
|
+
* Refs that resolved in THIS run replace the ones they supersede; every other
|
|
1261
|
+
* ref the deck was already paired with is carried forward unchanged. An
|
|
1262
|
+
* artifact nobody re-uploaded keeps pointing at the exact version it was
|
|
1263
|
+
* approved against, which is the whole point of pinning it.
|
|
1264
|
+
*/
|
|
1265
|
+
export function finalizeContext({ context, remote, refs, owners, declarations, operationId }) {
|
|
1266
|
+
const previous = context?.context ?? {};
|
|
1267
|
+
const fresh = new Map(refs.map((r) => [r.artifactId, r]));
|
|
1268
|
+
const ownerOf = owners ?? new Map();
|
|
1269
|
+
return {
|
|
1270
|
+
v: 1,
|
|
1271
|
+
deckId: remote.deckId ?? previous.deckId ?? remote.documentInstance,
|
|
1272
|
+
documentInstance: remote.documentInstance,
|
|
1273
|
+
...(previous.brand ? { brand: previous.brand } : {}),
|
|
1274
|
+
...(previous.intent ? { intent: previous.intent } : {}),
|
|
1275
|
+
extensions: (declarations ?? []).map((d) => {
|
|
1276
|
+
const paired = [...(d.refs ?? []), ...(remote.committedRefs ?? []).filter((r) =>
|
|
1277
|
+
!(d.refs ?? []).some((p) => p.artifactId === r.artifactId) &&
|
|
1278
|
+
(ownerOf.get(r.artifactId) ?? (declarations.length === 1 ? d.id : null)) === d.id)];
|
|
1279
|
+
const carried = paired.map((r) => fresh.get(r.artifactId) ?? r);
|
|
1280
|
+
const known = new Set(carried.map((r) => r.artifactId));
|
|
1281
|
+
const added = refs.filter(
|
|
1282
|
+
(r) => !known.has(r.artifactId) && (ownerOf.get(r.artifactId) ?? d.id) === d.id,
|
|
1283
|
+
);
|
|
1284
|
+
return {
|
|
1285
|
+
id: d.id,
|
|
1286
|
+
protocolVersion: 1,
|
|
1287
|
+
...(d.dataVersion !== undefined ? { dataVersion: d.dataVersion } : {}),
|
|
1288
|
+
required: d.required !== false,
|
|
1289
|
+
refs: [...carried, ...added],
|
|
1290
|
+
};
|
|
1291
|
+
}),
|
|
1292
|
+
operationId,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Bake a context into a package's bytes, returning the new bytes. */
|
|
1297
|
+
export function withContext(bytes, context) {
|
|
1298
|
+
const files = unzip(Buffer.from(bytes));
|
|
1299
|
+
bakePresentationContext(files, context);
|
|
1300
|
+
return Buffer.from(zip(files));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
// the manual branch
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Everything a read-only or manual deck needs to be published by hand: the
|
|
1309
|
+
* validated .pptx, the required external context, the changed attachments, and
|
|
1310
|
+
* one short instruction per file with its exact hash.
|
|
1311
|
+
*
|
|
1312
|
+
* Refs that could not be resolved carry `artifactId` + `sha256` placeholders
|
|
1313
|
+
* rather than invented remote identities, and the status is
|
|
1314
|
+
* `pendingManualUpload` — never `saved`. A later authorized read recognizes the
|
|
1315
|
+
* actual upload by content, which is evidence; it is NOT a claim that the
|
|
1316
|
+
* browser upload had any of the concurrency protection a conditional write has.
|
|
1317
|
+
*/
|
|
1318
|
+
export async function prepareManualHandoff(
|
|
1319
|
+
workspaceDir,
|
|
1320
|
+
{ candidate, manifest, context, decision, note = () => {} },
|
|
1321
|
+
) {
|
|
1322
|
+
const outDir = path.join(workspaceDir, "state", "handoff", candidate.candidateId);
|
|
1323
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1324
|
+
const deckFile = path.join(outDir, "deck.pptx");
|
|
1325
|
+
fs.writeFileSync(deckFile, candidateBytes(workspaceDir, candidate.candidateId));
|
|
1326
|
+
|
|
1327
|
+
const collected = collectManifest(workspaceDir, mergeArtifacts(manifest), { extensionId: null });
|
|
1328
|
+
const files = [
|
|
1329
|
+
{
|
|
1330
|
+
role: "deck",
|
|
1331
|
+
file: deckFile,
|
|
1332
|
+
sha256: candidate.outputSha256,
|
|
1333
|
+
bytes: candidate.outputBytes,
|
|
1334
|
+
instruction: "upload this file over the existing presentation, keeping its name",
|
|
1335
|
+
},
|
|
1336
|
+
];
|
|
1337
|
+
for (const artifact of collected.artifacts) {
|
|
1338
|
+
if (!artifact.uploadable) continue;
|
|
1339
|
+
const copy = path.join(outDir, `${artifact.extensionId}-${artifact.artifactId}`.replace(/[^a-zA-Z0-9._-]/g, "-") + path.extname(artifact.file));
|
|
1340
|
+
fs.copyFileSync(artifact.file, copy);
|
|
1341
|
+
files.push({
|
|
1342
|
+
role: artifact.purpose ?? "attachment",
|
|
1343
|
+
artifactId: artifact.artifactId,
|
|
1344
|
+
file: copy,
|
|
1345
|
+
sha256: artifact.sha256,
|
|
1346
|
+
bytes: artifact.bytes,
|
|
1347
|
+
required: artifact.required !== false,
|
|
1348
|
+
instruction: `upload beside the presentation; the deck refers to it as ${artifact.artifactId}`,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
const manifestFile = path.join(outDir, "handoff.json");
|
|
1352
|
+
const record = {
|
|
1353
|
+
schemaVersion: 1,
|
|
1354
|
+
status: "pendingManualUpload",
|
|
1355
|
+
preparedAt: nowIso(),
|
|
1356
|
+
reason: decision?.reason ?? null,
|
|
1357
|
+
mode: decision?.mode ?? null,
|
|
1358
|
+
candidateId: candidate.candidateId,
|
|
1359
|
+
candidateSha256: candidate.outputSha256,
|
|
1360
|
+
contextDigest: context?.context ? contextDigest(context.context) : null,
|
|
1361
|
+
// Unresolved refs are placeholders, deliberately. A fabricated remote id
|
|
1362
|
+
// here would later be indistinguishable from one that was really created.
|
|
1363
|
+
unresolvedRefs: collected.artifacts
|
|
1364
|
+
.filter((a) => a.uploadable)
|
|
1365
|
+
.map((a) => ({ artifactId: a.artifactId, sha256: a.sha256, resolved: false })),
|
|
1366
|
+
files: files.map((f) => ({ ...f, file: path.relative(workspaceDir, f.file) })),
|
|
1367
|
+
};
|
|
1368
|
+
fs.writeFileSync(manifestFile, `${JSON.stringify(record, null, 2)}\n`);
|
|
1369
|
+
const remote = readRemote(workspaceDir);
|
|
1370
|
+
if (remote) {
|
|
1371
|
+
writeRemote(workspaceDir, {
|
|
1372
|
+
...remote,
|
|
1373
|
+
pendingCandidateId: candidate.candidateId,
|
|
1374
|
+
manualHandoff: { at: nowIso(), dir: path.relative(workspaceDir, outDir) },
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
note(`prepared a manual handoff in ${outDir} — nothing was written to SharePoint.`);
|
|
1378
|
+
return { dir: outDir, manifest: manifestFile, record };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ---------------------------------------------------------------------------
|
|
1382
|
+
// rename
|
|
1383
|
+
// ---------------------------------------------------------------------------
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Rename the bound item. Identity does not move, no deck is rendered, and no
|
|
1387
|
+
* second file is created — the receipt refreshes labels only. Accepted content
|
|
1388
|
+
* markers remain unchanged until pull proves which bytes they represent.
|
|
1389
|
+
*/
|
|
1390
|
+
export async function renameBound(workspaceDir, { provider, client, name, note = () => {} }) {
|
|
1391
|
+
const remote = readRemote(workspaceDir);
|
|
1392
|
+
if (!remote) {
|
|
1393
|
+
const e = new Error("this deck is not bound to a remote copy");
|
|
1394
|
+
e.code = "identity_unresolved";
|
|
1395
|
+
throw e;
|
|
1396
|
+
}
|
|
1397
|
+
const current = await provider.getItem(client, itemRef(remote));
|
|
1398
|
+
const operation = beginOperation(workspaceDir, {
|
|
1399
|
+
kind: "rename",
|
|
1400
|
+
deck: { driveId: remote.driveId, itemId: remote.itemId, expectedETag: current.eTag },
|
|
1401
|
+
evidence: { from: current.name, to: name },
|
|
1402
|
+
});
|
|
1403
|
+
stepOperation(workspaceDir, operation.operationId, { phase: "deck-write", step: "rename" });
|
|
1404
|
+
const result = await provider.renameItem(client, itemRef(remote), name, { ifMatch: current.eTag });
|
|
1405
|
+
if (result.outcome === "unknown") {
|
|
1406
|
+
return { status: "unknown", reason: "unknown_outcome", why: result.why, operation };
|
|
1407
|
+
}
|
|
1408
|
+
if (result.outcome === "refused") {
|
|
1409
|
+
abandonOperation(workspaceDir, operation.operationId, `rename refused: ${result.reason}`);
|
|
1410
|
+
return { status: "blocked", reason: result.reason, name };
|
|
1411
|
+
}
|
|
1412
|
+
if (result.item?.itemId && result.item.itemId !== remote.itemId) {
|
|
1413
|
+
return {
|
|
1414
|
+
status: "unknown",
|
|
1415
|
+
reason: "unknown_outcome",
|
|
1416
|
+
why: "the rename was confirmed against a different item",
|
|
1417
|
+
operation,
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
const receipt = writeReceipt(workspaceDir, operation.operationId, {
|
|
1421
|
+
item: result.item,
|
|
1422
|
+
version: result.version,
|
|
1423
|
+
candidateId: null,
|
|
1424
|
+
candidateSha256: null,
|
|
1425
|
+
refs: remote.committedRefs ?? [],
|
|
1426
|
+
route: "rename",
|
|
1427
|
+
});
|
|
1428
|
+
// Labels move independently of accepted content. The item may already have
|
|
1429
|
+
// contained an unseen colleague save before this rename.
|
|
1430
|
+
const after = readRemote(workspaceDir);
|
|
1431
|
+
writeRemote(workspaceDir, {
|
|
1432
|
+
...after,
|
|
1433
|
+
name: result.item?.name ?? name,
|
|
1434
|
+
webUrl: result.item?.webUrl ?? after.webUrl,
|
|
1435
|
+
// A successful rename proves only the label, never acceptance of its bytes.
|
|
1436
|
+
// Preserve both markers so an intervening colleague save still requires pull.
|
|
1437
|
+
lastReceipt: { operationId: operation.operationId, confirmedAt: receipt.confirmedAt, route: "rename" },
|
|
1438
|
+
pendingOperationId: null,
|
|
1439
|
+
});
|
|
1440
|
+
stepOperation(workspaceDir, operation.operationId, { phase: "finalized", step: "finalized" });
|
|
1441
|
+
note(`renamed to ${result.item?.name ?? name}; the deck still publishes to the same item.`);
|
|
1442
|
+
return { status: "committed", receipt, name: result.item?.name ?? name };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ---------------------------------------------------------------------------
|
|
1446
|
+
// cold open: is this the document the context thinks it is?
|
|
1447
|
+
// ---------------------------------------------------------------------------
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Compare the context's physical identity with the item the user's link actually
|
|
1451
|
+
* resolved to.
|
|
1452
|
+
*
|
|
1453
|
+
* `own` the same document — carry on.
|
|
1454
|
+
* `fork` a different one, or one that cannot be verified. The logical id is
|
|
1455
|
+
* reminted and mutable context is forked before anything is changed;
|
|
1456
|
+
* immutable refs may be shared, because sharing an immutable object
|
|
1457
|
+
* cannot damage the original.
|
|
1458
|
+
* `unknown` no context at all and no local binding — inspection is fine,
|
|
1459
|
+
* managed publication is not, because the absence of metadata is not
|
|
1460
|
+
* proof that no business gate ever applied.
|
|
1461
|
+
*/
|
|
1462
|
+
export function classifyInstance({ context, resolved, remote }) {
|
|
1463
|
+
const here = documentInstance({
|
|
1464
|
+
provider: "sharepoint",
|
|
1465
|
+
tenant: resolved.tenant,
|
|
1466
|
+
driveId: resolved.driveId,
|
|
1467
|
+
itemId: resolved.itemId,
|
|
1468
|
+
});
|
|
1469
|
+
const claimed = context?.context?.documentInstance ?? null;
|
|
1470
|
+
if (claimed && here && claimed === here) return { kind: "own", instance: here };
|
|
1471
|
+
if (claimed && here && claimed !== here) {
|
|
1472
|
+
return {
|
|
1473
|
+
kind: "fork",
|
|
1474
|
+
instance: here,
|
|
1475
|
+
why: "these bytes were published to a different item — this is a copy",
|
|
1476
|
+
shareImmutable: true,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
if (!claimed && remote?.documentInstance === here) {
|
|
1480
|
+
// Warm deck whose tags were stripped by a host save. The binding remembers
|
|
1481
|
+
// what this is; the loss is reported, the gates are not dropped.
|
|
1482
|
+
return { kind: "own", instance: here, tagsLost: true };
|
|
1483
|
+
}
|
|
1484
|
+
if (!claimed) return { kind: "unknown", instance: here };
|
|
1485
|
+
return { kind: "fork", instance: here, why: "the document instance could not be verified" };
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/** Everything a hydrated artifact needs, fetched and written under containment. */
|
|
1489
|
+
export async function hydrateContext(workspaceDir, { provider, client, context, note = () => {}, staged = false, companions = {} }) {
|
|
1490
|
+
if (!staged) {
|
|
1491
|
+
const tx = stageWorkspace(workspaceDir);
|
|
1492
|
+
try {
|
|
1493
|
+
const result = await hydrateContext(tx.workspace, { provider, client, context, note, staged: true, companions });
|
|
1494
|
+
if (!result.blockers.length) tx.activate();
|
|
1495
|
+
return { ...result, written: result.written.map(p => ({ ...p, path: path.resolve(workspaceDir, path.relative(tx.workspace, p.path)) })) };
|
|
1496
|
+
} finally { tx.dispose(); }
|
|
1497
|
+
}
|
|
1498
|
+
const blockers = [];
|
|
1499
|
+
const { declarations } = declaredExtensions(workspaceDir, context);
|
|
1500
|
+
const hydrate = runDeclared(workspaceDir, declarations, "hydrate", {
|
|
1501
|
+
revision: revisionSummary(workspaceDir),
|
|
1502
|
+
dependencies: pairedRefs(workspaceDir, context),
|
|
1503
|
+
context: context?.context ?? null,
|
|
1504
|
+
});
|
|
1505
|
+
const written = [];
|
|
1506
|
+
for (const result of hydrate.results) {
|
|
1507
|
+
for (const artifact of result.response.artifacts ?? []) {
|
|
1508
|
+
const ref = (result.declaration.refs ?? []).find((r) => r.artifactId === artifact.artifactId);
|
|
1509
|
+
if ((!ref?.driveId || !ref?.itemId) && !companions[artifact.artifactId]) {
|
|
1510
|
+
if (artifact.required !== false) blockers.push({ code: 'artifact_missing', message: `No pinned ref for ${artifact.artifactId}` });
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
try {
|
|
1514
|
+
const bytes = companions[artifact.artifactId] ?? (ref.versionId
|
|
1515
|
+
? await provider.downloadVersion(client, { driveId: ref.driveId, itemId: ref.itemId }, ref.versionId)
|
|
1516
|
+
: (await provider.downloadSnapshot(client, { driveId: ref.driveId, itemId: ref.itemId })).bytes);
|
|
1517
|
+
// `ref` is deliberately allowed to be absent when a --companion supplies the
|
|
1518
|
+
// bytes (the guard above admits exactly that case), so its hash must be read
|
|
1519
|
+
// optionally — `ref.sha256` threw a TypeError that the catch below turned
|
|
1520
|
+
// into an `artifact_missing` blocker, failing the one case the guard exists for.
|
|
1521
|
+
const sha256 = ref?.sha256 ?? artifact.sha256 ?? null;
|
|
1522
|
+
const placement = materializeArtifact(workspaceDir, { ...artifact, extensionId: result.id, sha256 }, bytes);
|
|
1523
|
+
if (placement.isolated && artifact.required !== false) blockers.push({ code: 'artifact_missing', message: `Unsafe materialization path for ${artifact.artifactId}` });
|
|
1524
|
+
written.push(placement);
|
|
1525
|
+
if (!placement.isolated) {
|
|
1526
|
+
const registryPath = path.join(workspaceDir, "state", "artifact-materializations.json");
|
|
1527
|
+
let registry = { artifacts: [] };
|
|
1528
|
+
try { registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); } catch {}
|
|
1529
|
+
registry.artifacts = (registry.artifacts ?? []).filter((a) => !(a.extensionId === result.id && a.artifactId === artifact.artifactId));
|
|
1530
|
+
registry.artifacts.push({ extensionId: result.id, artifactId: artifact.artifactId, localPath: path.relative(workspaceDir, placement.path), sha256 });
|
|
1531
|
+
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
1532
|
+
fs.writeFileSync(`${registryPath}.tmp`, JSON.stringify(registry));
|
|
1533
|
+
fs.renameSync(`${registryPath}.tmp`, registryPath);
|
|
1534
|
+
}
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
if (artifact.required !== false) blockers.push({ code: 'artifact_missing', message: `${artifact.artifactId}: ${err.message}` });
|
|
1537
|
+
note(`'${artifact.artifactId}' could not be restored: ${err?.message ?? err}`);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const restored = written.length ? runDeclared(workspaceDir, declarations, "hydrate", {
|
|
1542
|
+
revision: revisionSummary(workspaceDir), dependencies: pairedRefs(workspaceDir, context), context: context?.context ?? null,
|
|
1543
|
+
}) : hydrate;
|
|
1544
|
+
const slides = restored.results.flatMap((r) => r.response.hydrate?.slides ?? []);
|
|
1545
|
+
return { hydrate, written, blockers: [...blockers, ...restored.blockers], slides: [...new Set(slides)].sort((a, b) => a - b) };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Explicit classification cannot erase remembered or embedded business gates. */
|
|
1549
|
+
export function classifyOrdinary(workspaceDir, context) {
|
|
1550
|
+
const remote = readRemote(workspaceDir);
|
|
1551
|
+
if (context?.corrupt || declaredExtensions(workspaceDir, context).declarations.length) throw Object.assign(new Error('Recover the declared context; --ordinary cannot remove business gates.'), { code: 'validation_required' });
|
|
1552
|
+
const revision = readRevision(workspaceDir);
|
|
1553
|
+
if (!revision) throw new Error('Adopt the deck before classifying it.');
|
|
1554
|
+
writeRevision(workspaceDir, { ...revision, origin: { ...revision.origin, classification: 'ordinary', classifiedAt: nowIso() } });
|
|
1555
|
+
if (remote) writeRemote(workspaceDir, { ...remote, originUnknown: false, ordinary: true });
|
|
1556
|
+
}
|
|
1557
|
+
|