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,1119 @@
|
|
|
1
|
+
// Automatic remote synchronization of a deck's shared plan record — the
|
|
2
|
+
// algorithm in synchronization.md, as one per-record queue.
|
|
3
|
+
//
|
|
4
|
+
// state/workspace-sync.json the queue: idle | pending | sending |
|
|
5
|
+
// applying | blocked | outcome-unknown, the
|
|
6
|
+
// latest unsent local hash, the operation in
|
|
7
|
+
// flight (base, sent snapshot, response), the
|
|
8
|
+
// network claim, and a conflict when blocked.
|
|
9
|
+
// state/record.json the CONFIRMED remote baseline (record-state);
|
|
10
|
+
// advanced only from what the service answered.
|
|
11
|
+
//
|
|
12
|
+
// A local save never waits on the network: it commits, then `scheduleSync`
|
|
13
|
+
// marks the queue pending. `drainOnce` is the coordinator — one drain per
|
|
14
|
+
// record at a time, in the daemon (startSyncLoop) or in a headless command
|
|
15
|
+
// (`record sync`): claim and snapshot under the deck lock, do the network
|
|
16
|
+
// call without it, reacquire to apply. A crash anywhere leaves enough on disk
|
|
17
|
+
// to finish or to ask: a persisted `sending`/`outcome-unknown` operation is
|
|
18
|
+
// read back before anything is sent again, never resent blindly.
|
|
19
|
+
//
|
|
20
|
+
// `blocked` is always explicit: it ends with a sign-in, a policy change, a
|
|
21
|
+
// resolve (unblockSync) — never with the next tick. Transient failures stay
|
|
22
|
+
// `pending` with a `retryAt`.
|
|
23
|
+
//
|
|
24
|
+
// Generic plan.md decks are not synchronized here (contracts §7): without an
|
|
25
|
+
// installed adapter the queue stays idle.
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { isDeepStrictEqual } from "node:util";
|
|
31
|
+
|
|
32
|
+
import { Merge3Error, SERVER_OWNED_TOP_LEVEL, merge3 } from "../lib/merge-json.mjs";
|
|
33
|
+
import { pidAlive } from "../lib/revision.mjs";
|
|
34
|
+
import { createRecord, getRecord, putRecord, recordSelector, resolveRecordBackend } from "../lib/record-client.mjs";
|
|
35
|
+
import { planToSend, readRecordState, recordStateFrom, resolveStoredFile, writeRecordState } from "../lib/record-state.mjs";
|
|
36
|
+
import { dispatchAdapter } from "./adapters.mjs";
|
|
37
|
+
import { EDIT_FILE, SCHEMA_VERSION as EDIT_SCHEMA_VERSION } from "./edit.mjs";
|
|
38
|
+
import { runDeckOperation } from "./operations.mjs";
|
|
39
|
+
import { hasEditablePlan, readDestination, writeDestination } from "./remote-status.mjs";
|
|
40
|
+
import { getTask, listTasks, requestTask, respondTask, supersedeTask } from "./tasks.mjs";
|
|
41
|
+
|
|
42
|
+
export const SYNC_FILE = "workspace-sync.json";
|
|
43
|
+
export const SCHEMA_VERSION = 1;
|
|
44
|
+
export const STATES = ["idle", "pending", "sending", "applying", "blocked", "outcome-unknown"];
|
|
45
|
+
export const OPERATION_KINDS = ["create", "save", "approve"];
|
|
46
|
+
export const BACKOFF_MIN_MS = 2_000;
|
|
47
|
+
export const BACKOFF_MAX_MS = 60_000;
|
|
48
|
+
export const CLAIM_TTL_MS = 10 * 60_000;
|
|
49
|
+
|
|
50
|
+
const NONCE = randomUUID();
|
|
51
|
+
const inFlight = new Set(); // workspaceDirs with a request on the wire in THIS process
|
|
52
|
+
let wakeHook = null; // the daemon loop, when one runs in this process
|
|
53
|
+
|
|
54
|
+
const nowIso = (now) => new Date(now()).toISOString();
|
|
55
|
+
const wsDir = (dir) => (path.basename(dir) === ".slidemind" ? dir : path.join(dir, ".slidemind"));
|
|
56
|
+
const typed = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
|
57
|
+
const SHA = /^sha256:[a-f0-9]{64}$/;
|
|
58
|
+
const isBound = (binding) => Boolean(binding?.url || (binding?.driveId && binding?.itemId));
|
|
59
|
+
const approvedHash = (plan) => plan?.approval?.planHash ?? null;
|
|
60
|
+
|
|
61
|
+
/** True while a shared approval is in flight: ordinary saves are buffered by the caller, sends wait (synchronization.md "Approval in flight"). */
|
|
62
|
+
export function approvalInProgress(workspaceDir) {
|
|
63
|
+
return readSync(workspaceDir).operation?.kind === "approve";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function syncPath(workspaceDir) {
|
|
67
|
+
return path.join(wsDir(workspaceDir), "state", SYNC_FILE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeAtomic(file, data) {
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
|
+
const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
73
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
74
|
+
fs.renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const EMPTY = () => ({ schemaVersion: SCHEMA_VERSION, state: "idle", pendingHash: null, attempt: 0, retryAt: null, operation: null, lastError: null, lastConfirmedAt: null });
|
|
78
|
+
|
|
79
|
+
/** `null` for a well-formed queue, else the `recovery_required` reason (fixtures: sync/cases.json). */
|
|
80
|
+
export function validateSync(data) {
|
|
81
|
+
if (!data || typeof data !== "object") return "not an object";
|
|
82
|
+
if (data.schemaVersion !== SCHEMA_VERSION) return `schemaVersion ${data.schemaVersion} is not ${SCHEMA_VERSION}`;
|
|
83
|
+
if (!STATES.includes(data.state)) return `state '${data.state}' is unknown`;
|
|
84
|
+
if (data.pendingHash !== null && typeof data.pendingHash !== "string") return "pendingHash must be a hash or null";
|
|
85
|
+
if (!Number.isInteger(data.attempt) || data.attempt < 0) return "attempt must be a non-negative integer";
|
|
86
|
+
const op = data.operation;
|
|
87
|
+
if (op !== null && op !== undefined) {
|
|
88
|
+
if (typeof op !== "object" || typeof op.id !== "string") return "operation.id must be a string";
|
|
89
|
+
if (!OPERATION_KINDS.includes(op.kind)) return `operation.kind '${op.kind}' is unknown`;
|
|
90
|
+
if (!op.sent || typeof op.sent.hash !== "string") return "operation.sent.hash must be a string";
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The queue as stored. Missing → idle; malformed → throws `recovery_required`, the file kept on disk. */
|
|
96
|
+
export function readSync(workspaceDir) {
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = fs.readFileSync(syncPath(workspaceDir), "utf8");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err?.code === "ENOENT") return EMPTY();
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
let data;
|
|
105
|
+
try {
|
|
106
|
+
data = JSON.parse(raw);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw typed("recovery_required", `${SYNC_FILE} is not JSON: ${err.message}`, { file: syncPath(workspaceDir) });
|
|
109
|
+
}
|
|
110
|
+
const problem = validateSync(data);
|
|
111
|
+
if (problem) throw typed("recovery_required", `${SYNC_FILE}: ${problem}; inspect it before syncing this deck`, { file: syncPath(workspaceDir) });
|
|
112
|
+
return { ...EMPTY(), ...data };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeSync(workspaceDir, data) {
|
|
116
|
+
const problem = validateSync(data);
|
|
117
|
+
if (problem) throw new Error(`refusing to write an invalid ${SYNC_FILE}: ${problem}`);
|
|
118
|
+
writeAtomic(syncPath(workspaceDir), data);
|
|
119
|
+
return data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The queue, summarized for a status line or a card. `synced` = nothing pending, in flight or blocked. */
|
|
123
|
+
export function syncStatus(workspaceDir) {
|
|
124
|
+
const q = readSync(workspaceDir);
|
|
125
|
+
return {
|
|
126
|
+
exists: fs.existsSync(syncPath(workspaceDir)),
|
|
127
|
+
state: q.state,
|
|
128
|
+
synced: q.state === "idle" && q.pendingHash === null && !q.operation,
|
|
129
|
+
pendingHash: q.pendingHash,
|
|
130
|
+
attempt: q.attempt,
|
|
131
|
+
retryAt: q.retryAt,
|
|
132
|
+
operation: q.operation ? { id: q.operation.id, kind: q.operation.kind, startedAt: q.operation.startedAt, sent: q.operation.sent.hash } : null,
|
|
133
|
+
lastError: q.lastError,
|
|
134
|
+
lastConfirmedAt: q.lastConfirmedAt,
|
|
135
|
+
conflict: q.conflict ?? null,
|
|
136
|
+
claim: q.claim ?? null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* After an ACCEPTED local save: remember the latest local hash and mark the
|
|
142
|
+
* queue pending. Coalesces — ten saves during one held network call become
|
|
143
|
+
* one send of the newest value. A blocked queue keeps its block (a new edit
|
|
144
|
+
* does not erase a conflict); an operation in flight is left alone and the
|
|
145
|
+
* hash is picked up when it settles. Call only for validated values.
|
|
146
|
+
*/
|
|
147
|
+
export function scheduleSync(workspaceDir, { hash } = {}) {
|
|
148
|
+
if (typeof hash !== "string") throw typed("validation_failed", "scheduleSync needs the accepted local hash");
|
|
149
|
+
const ws = wsDir(workspaceDir);
|
|
150
|
+
const q = readSync(ws);
|
|
151
|
+
q.pendingHash = hash;
|
|
152
|
+
if (q.state === "idle") {
|
|
153
|
+
q.state = "pending";
|
|
154
|
+
q.attempt = 0;
|
|
155
|
+
q.retryAt = null;
|
|
156
|
+
}
|
|
157
|
+
// 7.7: a deck pointed at a folder before it had a plan creates its record on the first accepted save.
|
|
158
|
+
if (!q.createTarget && !isBound(readRecordState(ws))) {
|
|
159
|
+
const dest = readDestination(ws);
|
|
160
|
+
if (dest && !dest.malformed && dest.webUrl && dest.name) q.createTarget = { folderUrl: dest.webUrl, name: dest.name };
|
|
161
|
+
}
|
|
162
|
+
writeSync(ws, q);
|
|
163
|
+
wakeHook?.(ws);
|
|
164
|
+
return syncStatus(ws);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A first share: the next drain POSTs the current plan to `{ folderUrl, name }`.
|
|
169
|
+
* A queue blocked on `waiting_for_plan` (destination selected before a plan
|
|
170
|
+
* existed) resumes here.
|
|
171
|
+
*/
|
|
172
|
+
export function scheduleCreate(workspaceDir, { hash, folderUrl, name }) {
|
|
173
|
+
const q = readSync(workspaceDir);
|
|
174
|
+
q.createTarget = { folderUrl, name };
|
|
175
|
+
if (q.state === "blocked" && q.lastError?.code === "waiting_for_plan") {
|
|
176
|
+
q.state = "idle";
|
|
177
|
+
q.lastError = null;
|
|
178
|
+
}
|
|
179
|
+
writeSync(workspaceDir, q);
|
|
180
|
+
return scheduleSync(workspaceDir, { hash });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Explicit resume after a block: a sign-in, a policy change, a resolve. Clears
|
|
185
|
+
* the block (and the conflict when `conflictId` names the current one) and
|
|
186
|
+
* marks pending.
|
|
187
|
+
*/
|
|
188
|
+
export function unblockSync(workspaceDir, { conflictId = null } = {}) {
|
|
189
|
+
const q = readSync(workspaceDir);
|
|
190
|
+
if (q.conflict) {
|
|
191
|
+
if (conflictId !== q.conflict.conflictId) throw typed("stale_subject", "that conflict is no longer the current one");
|
|
192
|
+
q.conflict = null;
|
|
193
|
+
}
|
|
194
|
+
if (q.state === "blocked") {
|
|
195
|
+
q.state = q.operation ? (q.operation.response ? "applying" : "outcome-unknown") : "pending";
|
|
196
|
+
q.lastError = null;
|
|
197
|
+
q.retryAt = null;
|
|
198
|
+
q.staleRetries = 0;
|
|
199
|
+
}
|
|
200
|
+
writeSync(workspaceDir, q);
|
|
201
|
+
wakeHook?.(wsDir(workspaceDir));
|
|
202
|
+
return syncStatus(workspaceDir);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// io — the adapter, the record service, the clock: all injectable
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function adapterIo(adapter, ws) {
|
|
210
|
+
if (!adapter) return null;
|
|
211
|
+
if (typeof adapter.read === "function") return adapter; // a fake: { id, read, applyRemote }
|
|
212
|
+
if (!adapter.entry) return null;
|
|
213
|
+
const deckRoot = path.dirname(ws);
|
|
214
|
+
const call = async (method, payload, token) => {
|
|
215
|
+
const answer = await dispatchAdapter(adapter.entry, { method, deckRoot, payload, ownerToken: token });
|
|
216
|
+
if (answer.status === "ok") return { ok: true, ...answer.data };
|
|
217
|
+
return { ok: false, code: answer.error?.code ?? "validation_failed", message: answer.error?.message, ...(answer.data ?? {}) };
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
id: adapter.id,
|
|
221
|
+
read: (token) => call("read", {}, token),
|
|
222
|
+
applyRemote: (payload, token) => call("apply-remote", payload, token),
|
|
223
|
+
saveLocal: (payload, token) => call("save-local", payload, token),
|
|
224
|
+
approve: (payload, token) => call("approve", payload, token),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function defaultClient(binding) {
|
|
229
|
+
const backendURL = await resolveRecordBackend(binding);
|
|
230
|
+
const { sharedRecordsSupport } = await import("../bridge/client/transport.mjs");
|
|
231
|
+
const kind = binding?.recordKind ?? null; // the service the binding was made against
|
|
232
|
+
return {
|
|
233
|
+
backendURL,
|
|
234
|
+
capabilities: () => sharedRecordsSupport(backendURL, { kind, refresh: true }),
|
|
235
|
+
get: (selector) => getRecord(backendURL, selector, { kind }),
|
|
236
|
+
put: (args) => putRecord(backendURL, { kind, ...args }),
|
|
237
|
+
create: (args) => createRecord(backendURL, { kind, ...args }),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Same content, ignoring what the service stamps itself — decides whether a write of unknown outcome landed. */
|
|
242
|
+
export function sameContent(a, b) {
|
|
243
|
+
const strip = (v) => {
|
|
244
|
+
if (!v || typeof v !== "object") return v;
|
|
245
|
+
const copy = { ...v };
|
|
246
|
+
for (const k of SERVER_OWNED_TOP_LEVEL) delete copy[k];
|
|
247
|
+
return copy;
|
|
248
|
+
};
|
|
249
|
+
return isDeepStrictEqual(strip(a), strip(b));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function backoffMs(attempt, random = Math.random) {
|
|
253
|
+
const base = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** Math.max(0, attempt - 1));
|
|
254
|
+
return Math.round(base * (1 + random() * 0.25));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// One verdict per network failure: what to persist, and whether to retry.
|
|
258
|
+
// `null` means it is not a network verdict at all — rethrown.
|
|
259
|
+
function classify(err) {
|
|
260
|
+
const code = err?.code;
|
|
261
|
+
if (code === "bridge_timeout" || code === "timeout" || /timed out/i.test(String(err?.message ?? ""))) return { code: "outcome_unknown", unknown: true };
|
|
262
|
+
if (code === "version_unknown") return { code: "outcome_unknown", unknown: true, written: err?.body?.written === true };
|
|
263
|
+
if (code === "offline") return { code: "offline", retryable: true };
|
|
264
|
+
if (err?.status === 429 || code === "rate_limited") {
|
|
265
|
+
const after = Number(err?.headers?.["retry-after"] ?? err?.retryAfter);
|
|
266
|
+
return { code: "rate_limited", retryable: true, retryAfterMs: Number.isFinite(after) && after > 0 ? after * 1000 : null };
|
|
267
|
+
}
|
|
268
|
+
if (err?.status >= 500) return { code: "offline", retryable: true };
|
|
269
|
+
if (code === "sharepoint_write_disabled" || code === "write_disabled") return { code: "write_disabled" };
|
|
270
|
+
if (code === "not_found" || code === "remote_missing") return { code: "remote_missing" };
|
|
271
|
+
if (code === "login_required" || code === "access_denied" || code === "name_exists") return { code };
|
|
272
|
+
if (code === "backend_ambiguous") return { code: "capability_unknown" };
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// the coordinator
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Drain this record's queue once: read back an unfinished operation, send the
|
|
282
|
+
* newest local value if any, apply what came back. Resolves to `syncStatus`
|
|
283
|
+
* plus `action`: `noop`, `busy`, `deferred`, `blocked`, `recovered`, `applied`.
|
|
284
|
+
*
|
|
285
|
+
* `adapter` is `{ id, entry }` (installed pack) or a fake `{ id, read,
|
|
286
|
+
* applyRemote }`; `client` replaces the record service (`{ capabilities, get,
|
|
287
|
+
* put, create, backendURL }`); `owner` is runDeckOperation's (`false` in the
|
|
288
|
+
* daemon); `now`/`random` are the clock. The deck lock is never held across a
|
|
289
|
+
* network call.
|
|
290
|
+
*/
|
|
291
|
+
export async function drainOnce(workspaceDir, { adapter = null, client = null, owner, now = Date.now, random = Math.random } = {}) {
|
|
292
|
+
const ws = wsDir(workspaceDir);
|
|
293
|
+
const io = adapterIo(adapter, ws);
|
|
294
|
+
// Same signature as `refreshOnce`'s and `activateDestination`'s: the settled
|
|
295
|
+
// approval below rides out on `approval`, and a one-parameter `result` dropped
|
|
296
|
+
// it silently — the daemon's `if (r.approval?.ok)` observer could never fire,
|
|
297
|
+
// so a crashed shared approval was finalized without the open page hearing it.
|
|
298
|
+
const result = (action, extra = {}) => ({ action, ...syncStatus(ws), ...extra });
|
|
299
|
+
if (!io) return result("noop");
|
|
300
|
+
|
|
301
|
+
const binding = readRecordState(ws);
|
|
302
|
+
let net = client;
|
|
303
|
+
if (!net) {
|
|
304
|
+
try {
|
|
305
|
+
net = await defaultClient(binding);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
const verdict = classify(err) ?? { code: "capability_unknown" };
|
|
308
|
+
persistBlock(ws, { ...verdict, message: err.message }, now);
|
|
309
|
+
return result("blocked");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 1. claim + snapshot, under the lock.
|
|
314
|
+
const claimed = await runDeckOperation(ws, { kind: "sync-claim", owner }, async (op) => {
|
|
315
|
+
const q = readSync(ws);
|
|
316
|
+
if (q.claim) {
|
|
317
|
+
const mine = q.claim.nonce === NONCE;
|
|
318
|
+
if (mine || (pidAlive(q.claim.pid) && now() - Date.parse(q.claim.at) < CLAIM_TTL_MS)) return { busy: true };
|
|
319
|
+
// a dead claimant: whatever it had on the wire is of unknown outcome, not "nothing sent"
|
|
320
|
+
if (q.state === "sending") q.state = "outcome-unknown";
|
|
321
|
+
delete q.claim;
|
|
322
|
+
}
|
|
323
|
+
if (q.state === "blocked") return { blocked: true };
|
|
324
|
+
if (q.state === "pending" && q.retryAt && Date.parse(q.retryAt) > now()) return { deferred: true };
|
|
325
|
+
// the approval barrier: a shared approval on the wire is finished (read back, finalized) before any ordinary send
|
|
326
|
+
if (q.operation?.kind === "approve" && q.state === "sending") return { busy: true };
|
|
327
|
+
// an unsent operation is re-snapshotted: newer local edits ride along
|
|
328
|
+
if (q.operation && q.state === "pending" && !q.operation.response) q.operation = null;
|
|
329
|
+
if (q.state === "idle" && !q.pendingHash) return { idle: true };
|
|
330
|
+
|
|
331
|
+
if (!q.operation) {
|
|
332
|
+
const isCreate = !binding?.url && !(binding?.driveId && binding?.itemId);
|
|
333
|
+
if (isCreate && !q.createTarget) return { blocked: true, error: { code: "waiting_for_plan", message: "no shared record and no destination yet", retryable: false } };
|
|
334
|
+
const local = await io.read(op.token);
|
|
335
|
+
if (!local.ok) return { blocked: true, error: { code: local.code, message: local.message } };
|
|
336
|
+
q.operation = {
|
|
337
|
+
id: randomUUID(),
|
|
338
|
+
kind: isCreate ? "create" : "save",
|
|
339
|
+
base: { eTag: binding?.eTag ?? null, value: binding?.base ?? null },
|
|
340
|
+
sent: { hash: local.hash, value: planToSend(local.value) },
|
|
341
|
+
...(isCreate ? { target: q.createTarget } : {}),
|
|
342
|
+
startedAt: nowIso(now),
|
|
343
|
+
};
|
|
344
|
+
q.pendingHash = null;
|
|
345
|
+
q.state = "pending";
|
|
346
|
+
}
|
|
347
|
+
q.claim = { pid: process.pid, nonce: NONCE, operationId: q.operation.id, at: nowIso(now) };
|
|
348
|
+
writeSync(ws, q);
|
|
349
|
+
return { q };
|
|
350
|
+
});
|
|
351
|
+
if (claimed.busy) return result("busy");
|
|
352
|
+
if (claimed.idle) return result("noop");
|
|
353
|
+
if (claimed.deferred) return result("deferred");
|
|
354
|
+
if (claimed.blocked) {
|
|
355
|
+
if (claimed.error) persistBlock(ws, claimed.error, now);
|
|
356
|
+
return result("blocked");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let q = claimed.q;
|
|
360
|
+
try {
|
|
361
|
+
// 2. an operation of unknown outcome is read back before anything is sent.
|
|
362
|
+
if (q.state === "sending" || q.state === "outcome-unknown") {
|
|
363
|
+
const back = await readBack(ws, q, net, binding);
|
|
364
|
+
if (back.blocked) return result("blocked");
|
|
365
|
+
q = back.q;
|
|
366
|
+
if (!q.operation) return result("recovered"); // an approval that never landed is void: the human decides again
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 3. send, when there is still something to send.
|
|
370
|
+
if (q.state === "pending") {
|
|
371
|
+
const caps = await net.capabilities();
|
|
372
|
+
if (caps.status !== "supported") {
|
|
373
|
+
release(ws, (queue) => {
|
|
374
|
+
queue.operation = null;
|
|
375
|
+
queue.state = "blocked";
|
|
376
|
+
queue.lastError = {
|
|
377
|
+
code: "capability_unknown",
|
|
378
|
+
message: caps.status === "unknown" ? "the backend's capabilities could not be read; nothing was sent" : "the backend does not advertise shared records",
|
|
379
|
+
retryable: caps.status === "unknown",
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
return result("blocked");
|
|
383
|
+
}
|
|
384
|
+
q.state = "sending";
|
|
385
|
+
q.attempt += 1;
|
|
386
|
+
q.retryAt = null;
|
|
387
|
+
q.lastError = null;
|
|
388
|
+
// The deck lock is long gone and `q` is the snapshot taken under it (pendingHash
|
|
389
|
+
// nulled there). A browser save landing during these network windows writes a
|
|
390
|
+
// fresh pendingHash, which a whole-snapshot write would silently erase — the page
|
|
391
|
+
// would then report "Synced" over an edit nothing will ever send. Patch fields.
|
|
392
|
+
patch(ws, q, ["state", "attempt", "retryAt", "lastError"]);
|
|
393
|
+
inFlight.add(ws);
|
|
394
|
+
let response;
|
|
395
|
+
try {
|
|
396
|
+
response = await send(net, q.operation, binding);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
inFlight.delete(ws);
|
|
399
|
+
const verdict = classify(err);
|
|
400
|
+
if (verdict?.unknown) {
|
|
401
|
+
q.state = "outcome-unknown";
|
|
402
|
+
q.lastError = { code: "outcome_unknown", message: `the request may have reached the service: ${err.message}`, retryable: false, ...(verdict.written ? { written: true } : {}) };
|
|
403
|
+
patch(ws, q, ["state", "lastError"]);
|
|
404
|
+
const back = await readBack(ws, q, net, binding);
|
|
405
|
+
if (back.blocked) return result("blocked");
|
|
406
|
+
q = back.q;
|
|
407
|
+
if (q.state === "pending") return result("recovered"); // resend on the next drain, with a fresh snapshot
|
|
408
|
+
} else if (err?.code === "conflict") {
|
|
409
|
+
conflictBlock(ws, q, err.body, now);
|
|
410
|
+
await conflictTask(ws, io.id, owner);
|
|
411
|
+
return result("blocked");
|
|
412
|
+
} else if (err?.code === "stale") {
|
|
413
|
+
if ((q.staleRetries ?? 0) >= 1) {
|
|
414
|
+
// one automatic retry only: repeated contention is shown, not looped on
|
|
415
|
+
release(ws, (queue) => {
|
|
416
|
+
queue.state = "blocked";
|
|
417
|
+
queue.lastError = { code: "stale", message: "the shared record keeps moving; sync again once it settles", retryable: true };
|
|
418
|
+
});
|
|
419
|
+
return result("blocked");
|
|
420
|
+
}
|
|
421
|
+
// the answer carries the current record: merge it in, then send once more
|
|
422
|
+
q.operation.response = { ...err.body, staleAnswer: true };
|
|
423
|
+
q.state = "applying";
|
|
424
|
+
patch(ws, q, ["operation", "state"]);
|
|
425
|
+
} else if (verdict?.retryable) {
|
|
426
|
+
release(ws, (queue) => {
|
|
427
|
+
queue.state = "pending";
|
|
428
|
+
queue.retryAt = new Date(now() + (verdict.retryAfterMs ?? backoffMs(queue.attempt, random))).toISOString();
|
|
429
|
+
queue.lastError = { code: verdict.code, message: err.message, retryable: true };
|
|
430
|
+
});
|
|
431
|
+
return result("deferred");
|
|
432
|
+
} else if (verdict) {
|
|
433
|
+
release(ws, (queue) => {
|
|
434
|
+
queue.state = "blocked";
|
|
435
|
+
queue.lastError = { code: verdict.code, message: err.message, retryable: false };
|
|
436
|
+
});
|
|
437
|
+
return result("blocked");
|
|
438
|
+
} else {
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (response) {
|
|
443
|
+
inFlight.delete(ws);
|
|
444
|
+
q.operation.response = response;
|
|
445
|
+
q.state = "applying";
|
|
446
|
+
patch(ws, q, ["operation", "state"]);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 4. apply, under the lock again.
|
|
451
|
+
if (q.state === "applying") {
|
|
452
|
+
if (q.operation.kind === "approve") {
|
|
453
|
+
// a crashed shared approval: finalize what landed (or void what did not), and answer its task
|
|
454
|
+
const fin = await finalizeApprove(ws, q, io, binding, net.backendURL, owner, now);
|
|
455
|
+
if (fin.ok) await completeApproveTask(ws, q.operation, fin.evidence, owner);
|
|
456
|
+
return result(fin.ok ? "applied" : "recovered", { approval: fin });
|
|
457
|
+
}
|
|
458
|
+
const stale = q.operation.response?.staleAnswer === true;
|
|
459
|
+
await apply(ws, q, io, binding, net.backendURL, owner, now);
|
|
460
|
+
const after = readSync(ws);
|
|
461
|
+
if (after.state === "blocked" && after.conflict) await conflictTask(ws, io.id, owner);
|
|
462
|
+
if (stale && after.state === "pending") {
|
|
463
|
+
// the stale answer is merged in; the one automatic retry sends the result now
|
|
464
|
+
release(ws);
|
|
465
|
+
return drainOnce(ws, { adapter, client: net, owner, now, random });
|
|
466
|
+
}
|
|
467
|
+
return result(after.state === "blocked" ? "blocked" : "applied");
|
|
468
|
+
}
|
|
469
|
+
return result("noop");
|
|
470
|
+
} finally {
|
|
471
|
+
inFlight.delete(ws);
|
|
472
|
+
release(ws);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// A conflict is a task for the human (contracts §4 `sync-conflict`): one per
|
|
477
|
+
// local version, so an older conflict task is voided when the inputs move.
|
|
478
|
+
async function conflictTask(ws, adapterId, owner) {
|
|
479
|
+
const c = readSync(ws).conflict;
|
|
480
|
+
if (!c || !SHA.test(c.expectedHash ?? "")) return;
|
|
481
|
+
const deckRoot = path.dirname(ws);
|
|
482
|
+
for (const t of listTasks(deckRoot, { status: "pending" })) {
|
|
483
|
+
if (t.kind === "sync-conflict" && t.subject.hash !== c.expectedHash) await supersedeTask(deckRoot, t.id, { owner });
|
|
484
|
+
}
|
|
485
|
+
await requestTask(deckRoot, { kind: "sync-conflict", subject: { hash: c.expectedHash, remoteETag: c.currentETag ?? null }, adapterId }, { owner });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The confirmed baseline is what was observed remotely — conflict or not.
|
|
489
|
+
function advanceBaseline(ws, R, binding, backendURL) {
|
|
490
|
+
const previous = readRecordState(ws) ?? binding;
|
|
491
|
+
const file = resolveStoredFile(ws, previous?.file) ?? path.join(ws, "plan.json");
|
|
492
|
+
writeRecordState(ws, recordStateFrom(R, { file, deckDir: ws, link: previous?.link ?? null, backendURL: backendURL ?? previous?.backendURL ?? null, previous }));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// A remote draft newer than the FROZEN approved plan (plan/context.json) is
|
|
496
|
+
// staged beside it, never over it: the pack's cold resume reads
|
|
497
|
+
// state/plan-incoming.json and warns (resume.mjs). `hash` is the pack's local
|
|
498
|
+
// version, which only the pack computes — null here.
|
|
499
|
+
function stageIncoming(ws, R, now) {
|
|
500
|
+
let ctx;
|
|
501
|
+
try {
|
|
502
|
+
ctx = JSON.parse(fs.readFileSync(path.join(ws, "plan", "context.json"), "utf8"));
|
|
503
|
+
} catch {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!ctx?.planHash || approvedHash(R.plan) === ctx.planHash) return;
|
|
507
|
+
writeAtomic(path.join(ws, "state", "plan-incoming.json"), { schemaVersion: 1, eTag: R.eTag ?? null, hash: null, planHash: approvedHash(R.plan), value: R.plan, stagedAt: nowIso(now) });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 7.4 — read the shared record and bring it in: applied as-is when local is at
|
|
512
|
+
* the confirmed base, merged three ways otherwise, a same-field clash kept as a
|
|
513
|
+
* conflict. Never PUTs, never touches the PPTX binding. `busy` while a send,
|
|
514
|
+
* an apply or another process's claim is in progress; `blocked` when the queue
|
|
515
|
+
* is (a conflict, a policy block) or the read itself failed (`error`).
|
|
516
|
+
*/
|
|
517
|
+
export async function refreshOnce(workspaceDir, { adapter = null, client = null, owner, now = Date.now } = {}) {
|
|
518
|
+
const ws = wsDir(workspaceDir);
|
|
519
|
+
const io = adapterIo(adapter, ws);
|
|
520
|
+
const result = (action, extra = {}) => ({ action, ...syncStatus(ws), ...extra });
|
|
521
|
+
const binding = readRecordState(ws);
|
|
522
|
+
if (!io || !isBound(binding)) return result("noop");
|
|
523
|
+
const q = readSync(ws);
|
|
524
|
+
if (q.claim && (q.claim.nonce === NONCE || (pidAlive(q.claim.pid) && now() - Date.parse(q.claim.at) < CLAIM_TTL_MS))) return result("busy");
|
|
525
|
+
if (["sending", "applying", "outcome-unknown"].includes(q.state) || q.operation?.response) return result("busy");
|
|
526
|
+
if (q.state === "blocked") return result("blocked");
|
|
527
|
+
|
|
528
|
+
let net = client;
|
|
529
|
+
let remote;
|
|
530
|
+
try {
|
|
531
|
+
net ??= await defaultClient(binding);
|
|
532
|
+
remote = await net.get(recordSelector(binding));
|
|
533
|
+
} catch (err) {
|
|
534
|
+
const verdict = classify(err) ?? (client ? null : { code: "capability_unknown" });
|
|
535
|
+
if (!verdict) throw err;
|
|
536
|
+
return result("blocked", { error: { code: verdict.code, message: err.message } });
|
|
537
|
+
}
|
|
538
|
+
if (remote.eTag && remote.eTag === binding.eTag) return result("noop");
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
await runDeckOperation(ws, { kind: "sync-refresh", owner }, async (op) => {
|
|
542
|
+
const cur = readSync(ws);
|
|
543
|
+
if (cur.state !== q.state || cur.operation?.response || cur.claim) return; // moved under us: the next refresh sees it
|
|
544
|
+
stageIncoming(ws, remote, now);
|
|
545
|
+
await settle(ws, { io, R: remote, base: { eTag: binding.eTag ?? null, value: binding.base ?? null }, sent: { hash: "sha256:base", value: binding.base ?? {} }, binding, backendURL: net.backendURL, token: op.token, now });
|
|
546
|
+
});
|
|
547
|
+
} catch (err) {
|
|
548
|
+
if (err?.code === "deck_busy") return result("busy");
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
551
|
+
const after = readSync(ws);
|
|
552
|
+
if (after.state === "blocked" && after.conflict) {
|
|
553
|
+
await conflictTask(ws, io.id, owner);
|
|
554
|
+
return result("conflict");
|
|
555
|
+
}
|
|
556
|
+
return result(after.state === "pending" ? "merged" : after.state === "blocked" ? "blocked" : "applied");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 7.5 — the human's answer to a conflict. `conflictId` must be the current
|
|
561
|
+
* conflict and `expectedHash` the current local version, else `stale_subject`
|
|
562
|
+
* with the recomputed conflict (its id kept, inputs refreshed, a new task
|
|
563
|
+
* asked). The resolved `value` is committed as a local save, the baseline set
|
|
564
|
+
* to the latest remote, the block lifted and one drain run.
|
|
565
|
+
*/
|
|
566
|
+
export async function resolveConflict(workspaceDir, { adapter = null, client = null, owner, conflictId, expectedHash, value, now = Date.now } = {}) {
|
|
567
|
+
const ws = wsDir(workspaceDir);
|
|
568
|
+
const io = adapterIo(adapter, ws);
|
|
569
|
+
const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
|
|
570
|
+
const c = readSync(ws).conflict;
|
|
571
|
+
if (!io) return fail("validation_failed", "no installed adapter for this deck");
|
|
572
|
+
if (!c || c.conflictId !== conflictId) return fail("stale_subject", "that conflict is no longer the current one", { conflictId: c?.conflictId ?? null, expectedHash: c?.expectedHash ?? null });
|
|
573
|
+
if (!value || typeof value !== "object") return fail("validation_failed", "a resolution is the whole plan value");
|
|
574
|
+
const binding = readRecordState(ws);
|
|
575
|
+
let net = client;
|
|
576
|
+
let remote;
|
|
577
|
+
try {
|
|
578
|
+
net ??= await defaultClient(binding);
|
|
579
|
+
remote = await net.get(recordSelector(binding));
|
|
580
|
+
} catch (err) {
|
|
581
|
+
const verdict = classify(err) ?? (client ? null : { code: "capability_unknown" });
|
|
582
|
+
if (!verdict) throw err;
|
|
583
|
+
return fail(verdict.code, err.message);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const out = await runDeckOperation(ws, { kind: "conflict-resolve", owner }, async (op) => {
|
|
587
|
+
const cur = readSync(ws);
|
|
588
|
+
if (cur.conflict?.conflictId !== conflictId) return { refused: fail("stale_subject", "that conflict is no longer the current one", { conflictId: cur.conflict?.conflictId ?? null }) };
|
|
589
|
+
const local = await io.read(op.token);
|
|
590
|
+
if (!local.ok) return { refused: fail(local.code, local.message) };
|
|
591
|
+
if (local.hash !== expectedHash || (remote.eTag ?? null) !== (c.currentETag ?? null)) {
|
|
592
|
+
// the inputs moved since the conflict was shown: recompute, keep the id, refuse this answer
|
|
593
|
+
let merged;
|
|
594
|
+
try {
|
|
595
|
+
merged = merge3(c.base ?? c.sent?.value ?? {}, remote.plan, local.value);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (!(err instanceof Merge3Error)) throw err;
|
|
598
|
+
merged = { merged: null, conflicts: [{ path: err.path, code: "duplicate_id" }] };
|
|
599
|
+
}
|
|
600
|
+
cur.conflict = { ...c, expectedHash: local.hash, currentETag: remote.eTag ?? null, current: remote.plan, local: local.value, merged: merged.merged, conflicts: merged.conflicts, at: nowIso(now) };
|
|
601
|
+
writeSync(ws, cur);
|
|
602
|
+
return { stale: cur.conflict };
|
|
603
|
+
}
|
|
604
|
+
op.note({ before: local.hash });
|
|
605
|
+
const committed = await io.saveLocal({ base: { hash: local.hash, value: local.value }, value }, op.token);
|
|
606
|
+
if (!committed.ok) return { refused: fail(committed.code ?? "validation_failed", committed.message ?? "the resolution was refused", { conflicts: committed.conflicts, merged: committed.merged }) };
|
|
607
|
+
op.note({ after: committed.hash });
|
|
608
|
+
writeAtomic(path.join(ws, "state", EDIT_FILE), { schemaVersion: EDIT_SCHEMA_VERSION, adapterId: io.id, hash: committed.hash, value: committed.value, updatedAt: nowIso(now) });
|
|
609
|
+
advanceBaseline(ws, remote, binding, net.backendURL);
|
|
610
|
+
return { hash: committed.hash };
|
|
611
|
+
});
|
|
612
|
+
if (out.stale) {
|
|
613
|
+
await conflictTask(ws, io.id, owner);
|
|
614
|
+
return fail("stale_subject", "the plan or the shared record changed since this conflict was shown; decide again", { conflictId, expectedHash: out.stale.expectedHash, merged: out.stale.merged, conflicts: out.stale.conflicts, supersede: out.stale.expectedHash !== expectedHash });
|
|
615
|
+
}
|
|
616
|
+
if (out.refused) return out.refused;
|
|
617
|
+
scheduleSync(ws, { hash: out.hash });
|
|
618
|
+
unblockSync(ws, { conflictId });
|
|
619
|
+
const drained = await drainOnce(ws, { adapter, client: net, owner, now });
|
|
620
|
+
return { ok: true, hash: out.hash, ...drained };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 7.6 — approve a SHARED plan: local edits are flushed, the reviewed content
|
|
625
|
+
* must be what is local AND what the service holds (semantically — a revision
|
|
626
|
+
* the flush restamped is not a change), the pack prepares the approval on that
|
|
627
|
+
* exact version, the conditional write is read back for the exact approved
|
|
628
|
+
* hash, and only then does the pack finalize context and evidence. The
|
|
629
|
+
* operation (`kind: "approve"`) is the barrier: while it exists ordinary sends
|
|
630
|
+
* wait and `approvalInProgress` is true; an unknown outcome keeps it until a
|
|
631
|
+
* readback settles it. `response.actor` is the signed-in account (the caller
|
|
632
|
+
* resolved it); `task.subject.hash` is the version the human saw.
|
|
633
|
+
*/
|
|
634
|
+
export async function approveShared(workspaceDir, { adapter = null, client = null, owner, task, response, now = Date.now } = {}) {
|
|
635
|
+
const ws = wsDir(workspaceDir);
|
|
636
|
+
const io = adapterIo(adapter, ws);
|
|
637
|
+
const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
|
|
638
|
+
const stale = (message, extra = {}) => fail("stale_subject", message, { supersede: true, ...extra });
|
|
639
|
+
const binding = readRecordState(ws);
|
|
640
|
+
if (!io || !isBound(binding)) return fail("validation_failed", "this plan is not shared");
|
|
641
|
+
if (!task?.subject?.hash || !response?.actor) return fail("validation_failed", "approveShared needs the task and the attributed response");
|
|
642
|
+
let net = client;
|
|
643
|
+
try {
|
|
644
|
+
net ??= await defaultClient(binding);
|
|
645
|
+
} catch (err) {
|
|
646
|
+
return fail(classify(err)?.code ?? "capability_unknown", err.message);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 0. the human saw THIS local version
|
|
650
|
+
const reviewed = await io.read();
|
|
651
|
+
if (!reviewed.ok) return fail(reviewed.code, reviewed.message);
|
|
652
|
+
if (reviewed.hash !== task.subject.hash) return stale("the plan changed since it was reviewed", { current: reviewed.hash });
|
|
653
|
+
|
|
654
|
+
// 1. local edits go first; a queue that cannot drain cannot carry an approval
|
|
655
|
+
const flushed = await drainOnce(ws, { adapter, client: net, owner, now });
|
|
656
|
+
if (flushed.action === "busy") return fail("deck_busy", "another sync is in progress; try again in a moment");
|
|
657
|
+
if (flushed.state === "blocked") return fail(flushed.lastError?.code ?? "blocked", `the shared plan is not synchronized (${flushed.lastError?.code ?? "blocked"}); resolve that first`);
|
|
658
|
+
if (flushed.operation?.kind === "approve") return fail("approval_in_progress", "an approval of this plan is already in flight");
|
|
659
|
+
if (flushed.state !== "idle" || flushed.operation) return fail("sync_pending", "local edits are still being synchronized; try again in a moment");
|
|
660
|
+
|
|
661
|
+
// 2. the barrier, under the lock: the reviewed content stands, the pack prepares on exactly this version
|
|
662
|
+
const claimed = await runDeckOperation(ws, { kind: "approve-claim", owner }, async (op) => {
|
|
663
|
+
const q = readSync(ws);
|
|
664
|
+
if (q.operation?.kind === "approve") return { code: "approval_in_progress", message: "an approval of this plan is already in flight" };
|
|
665
|
+
if (q.claim || q.state !== "idle" || q.pendingHash) return { code: "sync_pending", message: "a save landed meanwhile; try again in a moment" };
|
|
666
|
+
const local = await io.read(op.token);
|
|
667
|
+
if (!local.ok) return { code: local.code, message: local.message };
|
|
668
|
+
if (!sameContent(local.value, reviewed.value)) return { code: "stale_subject", message: "synchronizing brought in changes nobody reviewed; the view is refreshed", supersede: true, current: local.hash };
|
|
669
|
+
const prep = await io.approve({ phase: "prepare", approvedBy: response.actor, expectedHash: local.hash, approvedAt: response.at }, op.token);
|
|
670
|
+
if (!prep.ok) return { code: prep.code, message: prep.message, supersede: prep.code === "stale_subject" };
|
|
671
|
+
if (!prep.prepared?.approved || typeof prep.prepared.hash !== "string") return { code: "extension_incompatible", message: "the pack's prepare answer carries no approved record" };
|
|
672
|
+
q.operation = {
|
|
673
|
+
id: randomUUID(),
|
|
674
|
+
kind: "approve",
|
|
675
|
+
base: { eTag: binding.eTag ?? null, value: binding.base ?? null },
|
|
676
|
+
sent: { hash: local.hash, value: planToSend(prep.prepared.approved, { approval: true }) },
|
|
677
|
+
prepared: prep.prepared,
|
|
678
|
+
task: { id: task.id, subjectHash: task.subject.hash, response },
|
|
679
|
+
startedAt: nowIso(now),
|
|
680
|
+
};
|
|
681
|
+
q.state = "sending";
|
|
682
|
+
q.claim = { pid: process.pid, nonce: NONCE, operationId: q.operation.id, at: nowIso(now) };
|
|
683
|
+
writeSync(ws, q);
|
|
684
|
+
return { q, local };
|
|
685
|
+
});
|
|
686
|
+
if (claimed.code) return fail(claimed.code, claimed.message, { supersede: claimed.supersede === true, current: claimed.current });
|
|
687
|
+
|
|
688
|
+
const q = claimed.q;
|
|
689
|
+
const prepared = q.operation.prepared;
|
|
690
|
+
const voided = (R) =>
|
|
691
|
+
release(ws, (queue) => {
|
|
692
|
+
queue.operation = null;
|
|
693
|
+
queue.state = queue.pendingHash ? "pending" : "idle";
|
|
694
|
+
if (R?.plan) advanceBaseline(ws, R, binding, net.backendURL);
|
|
695
|
+
});
|
|
696
|
+
try {
|
|
697
|
+
// 3. what the service holds must be the reviewed content
|
|
698
|
+
let remote;
|
|
699
|
+
try {
|
|
700
|
+
remote = await net.get(recordSelector(binding));
|
|
701
|
+
} catch (err) {
|
|
702
|
+
const verdict = classify(err);
|
|
703
|
+
if (!verdict) throw err;
|
|
704
|
+
voided();
|
|
705
|
+
return fail(verdict.code === "outcome_unknown" ? "offline" : verdict.code, err.message);
|
|
706
|
+
}
|
|
707
|
+
if (approvedHash(remote.plan) === prepared.hash) {
|
|
708
|
+
// an earlier attempt landed: nothing to send, finalize what is there
|
|
709
|
+
q.operation.response = { ...remote, submittedApplied: true, recovered: true };
|
|
710
|
+
} else {
|
|
711
|
+
if (!sameContent(remote.plan, claimed.local.value)) {
|
|
712
|
+
voided(remote);
|
|
713
|
+
return stale("the shared plan changed since it was reviewed; the view is refreshed");
|
|
714
|
+
}
|
|
715
|
+
q.attempt += 1;
|
|
716
|
+
patch(ws, q, ["attempt"]);
|
|
717
|
+
inFlight.add(ws);
|
|
718
|
+
let R;
|
|
719
|
+
try {
|
|
720
|
+
R = await net.put({ selector: recordSelector(binding), base: { eTag: remote.eTag ?? null, plan: remote.plan }, plan: q.operation.sent.value });
|
|
721
|
+
} catch (err) {
|
|
722
|
+
inFlight.delete(ws);
|
|
723
|
+
const verdict = classify(err);
|
|
724
|
+
if (verdict?.unknown) {
|
|
725
|
+
q.state = "outcome-unknown";
|
|
726
|
+
q.lastError = { code: "outcome_unknown", message: `the approval may have reached the service: ${err.message}`, retryable: false };
|
|
727
|
+
patch(ws, q, ["state", "lastError"]);
|
|
728
|
+
return fail("outcome_unknown", "the approval's outcome is unknown; it is read back before anything else happens", { pending: true });
|
|
729
|
+
}
|
|
730
|
+
if (err?.code === "conflict" || err?.code === "stale") {
|
|
731
|
+
voided(err.body?.plan ? { plan: err.body.plan, eTag: err.body.eTag } : null);
|
|
732
|
+
return stale("the shared plan moved while approving; the view is refreshed");
|
|
733
|
+
}
|
|
734
|
+
voided();
|
|
735
|
+
if (verdict) return fail(verdict.code, err.message);
|
|
736
|
+
throw err;
|
|
737
|
+
}
|
|
738
|
+
inFlight.delete(ws);
|
|
739
|
+
q.operation.response = R;
|
|
740
|
+
}
|
|
741
|
+
q.state = "applying";
|
|
742
|
+
patch(ws, q, ["state", "operation"]);
|
|
743
|
+
|
|
744
|
+
// 4/5. exact readback, then the pack finalizes under the lock; the task is answered with the evidence
|
|
745
|
+
const fin = await finalizeApprove(ws, q, io, binding, net.backendURL, owner, now);
|
|
746
|
+
if (fin.ok) fin.task = await completeApproveTask(ws, q.operation, fin.evidence, owner);
|
|
747
|
+
return fin;
|
|
748
|
+
} finally {
|
|
749
|
+
inFlight.delete(ws);
|
|
750
|
+
release(ws);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// The persisted approve operation's response is on disk: verify it carries the
|
|
755
|
+
// exact approved hash, then let the pack write context and evidence. Anything
|
|
756
|
+
// else voids the operation (the task stays pending for a fresh decision).
|
|
757
|
+
async function finalizeApprove(ws, q, io, binding, backendURL, owner, now) {
|
|
758
|
+
const op = q.operation;
|
|
759
|
+
const R = op.response;
|
|
760
|
+
const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
|
|
761
|
+
const clear = (edit) => release(ws, (queue) => { if (queue.operation?.id === op.id) queue.operation = null; edit?.(queue); });
|
|
762
|
+
if (R.staleAnswer || approvedHash(R.plan) !== op.prepared?.hash) {
|
|
763
|
+
advanceBaseline(ws, R, binding, backendURL);
|
|
764
|
+
clear((queue) => { queue.state = queue.pendingHash ? "pending" : "idle"; });
|
|
765
|
+
return fail("stale_subject", "the approval was overtaken by another change; the view is refreshed", { supersede: true });
|
|
766
|
+
}
|
|
767
|
+
return runDeckOperation(ws, { operationId: `${op.id}:finalize`, kind: "approve-finalize", owner }, async (opctx) => {
|
|
768
|
+
if (readSync(ws).operation?.id !== op.id) return fail("outcome_unknown", "the approval was settled by another process");
|
|
769
|
+
const local = await io.read(opctx.token);
|
|
770
|
+
if (!local.ok) return fail(local.code, local.message);
|
|
771
|
+
if (local.hash !== op.sent.hash) {
|
|
772
|
+
// a raw edit during approval: the remote is approved, the local draft is not — it is sent as an amendment, the task is void
|
|
773
|
+
advanceBaseline(ws, R, binding, backendURL);
|
|
774
|
+
clear((queue) => { queue.state = "pending"; queue.pendingHash = local.hash; });
|
|
775
|
+
return fail("stale_subject", "the plan was edited while it was being approved; reconcile and decide again", { supersede: true });
|
|
776
|
+
}
|
|
777
|
+
const fin = await io.approve({ phase: "finalize", prepared: op.prepared, sharedVersion: R.eTag ?? null }, opctx.token);
|
|
778
|
+
if (!fin.ok) {
|
|
779
|
+
if (fin.code === "stale_subject") {
|
|
780
|
+
advanceBaseline(ws, R, binding, backendURL);
|
|
781
|
+
clear((queue) => { queue.state = queue.pendingHash ? "pending" : "idle"; });
|
|
782
|
+
return fail("stale_subject", fin.message ?? "the pack refused to finalize", { supersede: true });
|
|
783
|
+
}
|
|
784
|
+
// the pack died or errored: the outcome of its context write is unknown — the next drain retries the finalize
|
|
785
|
+
return fail("outcome_unknown", fin.message ?? "the pack did not finalize the approval", { pending: true });
|
|
786
|
+
}
|
|
787
|
+
advanceBaseline(ws, R, binding, backendURL);
|
|
788
|
+
writeAtomic(path.join(ws, "state", EDIT_FILE), { schemaVersion: EDIT_SCHEMA_VERSION, adapterId: io.id, hash: fin.hash, value: fin.value, updatedAt: nowIso(now) });
|
|
789
|
+
clear((queue) => {
|
|
790
|
+
queue.lastError = null;
|
|
791
|
+
queue.attempt = 0;
|
|
792
|
+
queue.retryAt = null;
|
|
793
|
+
if (queue.pendingHash && queue.pendingHash !== fin.hash) queue.state = "pending";
|
|
794
|
+
else {
|
|
795
|
+
queue.state = "idle";
|
|
796
|
+
queue.pendingHash = null;
|
|
797
|
+
queue.lastConfirmedAt = nowIso(now);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
return { ok: true, evidence: { record: `context.json#${fin.planHash ?? op.prepared.hash}`, hash: op.task?.subjectHash ?? op.sent.hash, remoteETag: R.eTag ?? null } };
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Answer the task the approval was for, with the recorded response — from the
|
|
805
|
+
// coordinator or from a later drain that finished a crashed approval.
|
|
806
|
+
async function completeApproveTask(ws, op, evidence, owner) {
|
|
807
|
+
const deckRoot = path.dirname(ws);
|
|
808
|
+
const t = op.task;
|
|
809
|
+
if (!t?.id || !t.response || !getTask(deckRoot, t.id)) return null;
|
|
810
|
+
const r = t.response;
|
|
811
|
+
try {
|
|
812
|
+
const out = await respondTask(deckRoot, t.id, { operationId: r.operationId, expectedHash: t.subjectHash ?? op.sent.hash, action: r.action ?? "approve", actor: r.actor, payload: r.comment ? { comment: r.comment } : null, selfDeclared: r.selfDeclared === true }, { invoke: () => ({ evidence }), owner });
|
|
813
|
+
return out.task;
|
|
814
|
+
} catch (err) {
|
|
815
|
+
if (err?.code === "stale_subject" || err?.code === "validation_failed") return null; // answered or voided elsewhere; the approval itself stands
|
|
816
|
+
throw err;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 7.7 — create the shared record in the saved destination when a plan exists
|
|
822
|
+
* and policy allows. `waiting_for_plan` until a structurally editable plan is
|
|
823
|
+
* there; `blocked` (destination kept) when support or writes are missing or
|
|
824
|
+
* the name is taken; `created` once the binding is confirmed; `noop` with a
|
|
825
|
+
* `reason` when there is nothing to do. The uncertain-create readback is the
|
|
826
|
+
* queue's (`drainOnce`).
|
|
827
|
+
*/
|
|
828
|
+
export async function activateDestination(workspaceDir, { adapter = null, client = null, owner, now = Date.now } = {}) {
|
|
829
|
+
const ws = wsDir(workspaceDir);
|
|
830
|
+
const io = adapterIo(adapter, ws);
|
|
831
|
+
const result = (action, extra = {}) => ({ action, ...syncStatus(ws), ...extra });
|
|
832
|
+
const blocked = (code, message) => result("blocked", { error: { code, message, retryable: false } });
|
|
833
|
+
const binding = readRecordState(ws);
|
|
834
|
+
const dest = readDestination(ws);
|
|
835
|
+
if (dest?.malformed) return blocked("recovery_required", "destination.json is unreadable; inspect it before creating a record");
|
|
836
|
+
if (!dest) return result("noop", { reason: "no_destination" });
|
|
837
|
+
if (isBound(binding)) {
|
|
838
|
+
if (dest.state !== "created") writeDestination(ws, { ...dest, state: "created" });
|
|
839
|
+
return result("noop", { reason: "already_shared" });
|
|
840
|
+
}
|
|
841
|
+
if (!io) return result("noop", { reason: "no_adapter" });
|
|
842
|
+
if (!hasEditablePlan(ws)) {
|
|
843
|
+
if (dest.state !== "waiting_for_plan") writeDestination(ws, { ...dest, state: "waiting_for_plan" });
|
|
844
|
+
return result("waiting_for_plan");
|
|
845
|
+
}
|
|
846
|
+
let net = client;
|
|
847
|
+
try {
|
|
848
|
+
net ??= await defaultClient({ backendURL: dest.backendURL });
|
|
849
|
+
} catch (err) {
|
|
850
|
+
return blocked(classify(err)?.code ?? "capability_unknown", err.message);
|
|
851
|
+
}
|
|
852
|
+
const caps = await net.capabilities();
|
|
853
|
+
if (caps.status !== "supported") {
|
|
854
|
+
writeDestination(ws, { ...dest, state: "ready_to_create", writes: "unknown" });
|
|
855
|
+
return blocked("capability_unknown", caps.status === "unknown" ? "the backend's capabilities could not be read; nothing was created" : "the backend does not advertise shared records");
|
|
856
|
+
}
|
|
857
|
+
if (caps.sharepointPublish === false) {
|
|
858
|
+
writeDestination(ws, { ...dest, state: "ready_to_create", writes: "disabled" });
|
|
859
|
+
return blocked("write_disabled", "this deployment has record writing switched off; the destination is kept");
|
|
860
|
+
}
|
|
861
|
+
const target = { folderUrl: dest.webUrl, name: dest.name };
|
|
862
|
+
try {
|
|
863
|
+
await net.get({ url: candidateUrl(target) });
|
|
864
|
+
return blocked("name_exists", `${dest.name} already exists in ${dest.folderName ?? "that folder"}; open it or choose another name`);
|
|
865
|
+
} catch (err) {
|
|
866
|
+
const verdict = classify(err);
|
|
867
|
+
if (!verdict) throw err;
|
|
868
|
+
if (verdict.code !== "remote_missing") return blocked(verdict.code, err.message);
|
|
869
|
+
}
|
|
870
|
+
const local = await io.read();
|
|
871
|
+
if (!local.ok) return blocked(local.code, local.message);
|
|
872
|
+
scheduleCreate(ws, { hash: local.hash, ...target });
|
|
873
|
+
const drained = await drainOnce(ws, { adapter, client: net, owner, now });
|
|
874
|
+
if (isBound(readRecordState(ws))) {
|
|
875
|
+
writeDestination(ws, { ...readDestination(ws), state: "created", writes: "enabled" });
|
|
876
|
+
return result("created");
|
|
877
|
+
}
|
|
878
|
+
return drained.action === "blocked" ? result("blocked", { error: drained.lastError }) : result(drained.action);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function persistBlock(ws, error, now) {
|
|
882
|
+
release(ws, (q) => {
|
|
883
|
+
q.state = "blocked";
|
|
884
|
+
q.lastError = { code: error.code, message: error.message ?? error.code, retryable: error.retryable === true };
|
|
885
|
+
q.updatedAt = nowIso(now);
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Write only `fields` of an in-memory snapshot over the queue on disk: a save
|
|
890
|
+
// that landed while the request was on the wire (pendingHash) is kept.
|
|
891
|
+
function patch(ws, snapshot, fields) {
|
|
892
|
+
const q = readSync(ws);
|
|
893
|
+
for (const f of fields) q[f] = snapshot[f];
|
|
894
|
+
writeSync(ws, q);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Drop our claim, editing the queue in the same write.
|
|
898
|
+
function release(ws, edit = null) {
|
|
899
|
+
let q;
|
|
900
|
+
try {
|
|
901
|
+
q = readSync(ws);
|
|
902
|
+
} catch {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (q.claim?.nonce === NONCE) delete q.claim;
|
|
906
|
+
if (edit) edit(q);
|
|
907
|
+
writeSync(ws, q);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function send(net, operation, binding) {
|
|
911
|
+
if (operation.kind === "create") return net.create({ folderUrl: operation.target.folderUrl, name: operation.target.name, plan: operation.sent.value });
|
|
912
|
+
return net.put({ selector: recordSelector(binding), base: { eTag: operation.base.eTag, plan: operation.base.value }, plan: operation.sent.value });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// The file a create lands at: the destination's folder link plus the record
|
|
916
|
+
// name (destination.json, 7.7). The record service resolves that link like any
|
|
917
|
+
// pasted one, so the candidate is read through the same client as the record.
|
|
918
|
+
const candidateUrl = (target) => `${String(target.folderUrl).replace(/\/+$/, "")}/${encodeURIComponent(target.name)}`;
|
|
919
|
+
|
|
920
|
+
// Did a write of unknown outcome land? Content for a save; content AND the
|
|
921
|
+
// exact approval for an approve — `sameContent` ignores what the service owns.
|
|
922
|
+
const landed = (remote, op) => sameContent(remote.plan, op.sent.value) && (op.kind !== "approve" || approvedHash(remote.plan) === op.prepared?.hash);
|
|
923
|
+
|
|
924
|
+
// A GET before another PUT: did the write of unknown outcome land? Leaves the
|
|
925
|
+
// queue `applying` (it did — the readback is the response), `pending` (it did
|
|
926
|
+
// not, and the remote is where we left it — resend), `applying` with a stale
|
|
927
|
+
// answer (the remote moved without us — merge, then send), or blocked.
|
|
928
|
+
async function readBack(ws, q, net, binding) {
|
|
929
|
+
const op = q.operation;
|
|
930
|
+
let remote;
|
|
931
|
+
try {
|
|
932
|
+
remote = op.kind === "create" ? await net.get({ url: candidateUrl(op.target) }) : await net.get(recordSelector(binding));
|
|
933
|
+
} catch (err) {
|
|
934
|
+
const verdict = classify(err);
|
|
935
|
+
if (!verdict) throw err;
|
|
936
|
+
if (verdict.code === "remote_missing" && op.kind === "create") {
|
|
937
|
+
q.state = "pending"; // nothing at that name: the create never landed
|
|
938
|
+
writeSync(ws, q);
|
|
939
|
+
return { q };
|
|
940
|
+
}
|
|
941
|
+
release(ws, (queue) => {
|
|
942
|
+
queue.state = verdict.retryable || verdict.unknown ? "outcome-unknown" : "blocked";
|
|
943
|
+
queue.lastError = { code: verdict.unknown ? "outcome_unknown" : verdict.code, message: err.message, retryable: Boolean(verdict.retryable) };
|
|
944
|
+
});
|
|
945
|
+
return { blocked: true };
|
|
946
|
+
}
|
|
947
|
+
if (landed(remote, op)) {
|
|
948
|
+
q.operation.response = { ...remote, submittedApplied: true, recovered: true };
|
|
949
|
+
q.state = "applying";
|
|
950
|
+
} else if (op.kind === "approve") {
|
|
951
|
+
// never resend an approval blindly: nothing of it landed, so it is void — the human decides again
|
|
952
|
+
q.operation = null;
|
|
953
|
+
q.state = q.pendingHash ? "pending" : "idle";
|
|
954
|
+
q.lastError = null;
|
|
955
|
+
} else if (op.kind === "create") {
|
|
956
|
+
// a different file sits at that name: never overwrite it, never invent a second name
|
|
957
|
+
release(ws, (queue) => {
|
|
958
|
+
queue.state = "blocked";
|
|
959
|
+
queue.lastError = { code: "name_exists", message: `${op.target.name} exists in that folder with different content`, retryable: false };
|
|
960
|
+
});
|
|
961
|
+
return { blocked: true };
|
|
962
|
+
} else if (remote.eTag === op.base.eTag) {
|
|
963
|
+
q.state = "pending"; // remote unchanged since our base: the write never arrived
|
|
964
|
+
q.lastError = null;
|
|
965
|
+
} else {
|
|
966
|
+
q.operation.response = { ...remote, staleAnswer: true };
|
|
967
|
+
q.state = "applying";
|
|
968
|
+
}
|
|
969
|
+
writeSync(ws, q);
|
|
970
|
+
return { q };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Apply a persisted response under the lock: merge what came back over what
|
|
974
|
+
// was typed since, advance the confirmed baseline, settle the queue. Keyed by
|
|
975
|
+
// the operation id — a second application of the same response is a no-op.
|
|
976
|
+
async function apply(ws, q, io, binding, backendURL, owner, now) {
|
|
977
|
+
const op = q.operation;
|
|
978
|
+
const R = op.response;
|
|
979
|
+
return runDeckOperation(ws, { operationId: `${op.id}:apply`, kind: "sync-apply", owner }, async (opctx) => {
|
|
980
|
+
const current = readSync(ws);
|
|
981
|
+
if (current.operation?.id !== op.id || !current.operation.response) return;
|
|
982
|
+
const staleAnswer = R.staleAnswer === true;
|
|
983
|
+
// A stale answer means nothing of ours landed: merge R over our BASE so
|
|
984
|
+
// every local change since the base is carried forward and sent next.
|
|
985
|
+
const sent = staleAnswer ? { hash: "sha256:base", value: op.base.value ?? {} } : op.sent;
|
|
986
|
+
await settle(ws, { io, R, base: op.base, sent, binding, backendURL, token: opctx.token, now, staleAnswer, operation: op });
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Merge what the service holds over what is local (the pack's rule), advance
|
|
991
|
+
// the confirmed baseline, settle the queue. Shared by a response (`apply`) and
|
|
992
|
+
// a read (`refreshOnce`) — `operation` is the former's, cleared here.
|
|
993
|
+
async function settle(ws, { io, R, base, sent, binding, backendURL, token, now, staleAnswer = false, operation = null }) {
|
|
994
|
+
const merged = await io.applyRemote({ base, sent, returned: { eTag: R.eTag ?? null, value: R.plan } }, token);
|
|
995
|
+
advanceBaseline(ws, R, binding, backendURL);
|
|
996
|
+
|
|
997
|
+
const next = readSync(ws);
|
|
998
|
+
if (!merged.ok) {
|
|
999
|
+
next.state = "blocked";
|
|
1000
|
+
if (merged.code === "remote_conflict") {
|
|
1001
|
+
next.conflict = {
|
|
1002
|
+
conflictId: randomUUID(),
|
|
1003
|
+
expectedHash: merged.hash ?? null,
|
|
1004
|
+
currentETag: R.eTag ?? null,
|
|
1005
|
+
current: R.plan,
|
|
1006
|
+
local: merged.local ?? null,
|
|
1007
|
+
base: base?.value ?? null,
|
|
1008
|
+
sent: operation?.sent ?? sent,
|
|
1009
|
+
merged: merged.merged ?? null,
|
|
1010
|
+
conflicts: merged.conflicts ?? [],
|
|
1011
|
+
at: nowIso(now),
|
|
1012
|
+
};
|
|
1013
|
+
next.operation = null;
|
|
1014
|
+
next.lastError = { code: "remote_conflict", message: `${next.conflict.conflicts.length} field(s) changed on both sides`, retryable: false };
|
|
1015
|
+
} else {
|
|
1016
|
+
// the pack refused the apply: keep the response for a later attempt
|
|
1017
|
+
next.lastError = { code: merged.code, message: merged.message ?? merged.code, retryable: false };
|
|
1018
|
+
}
|
|
1019
|
+
writeSync(ws, next);
|
|
1020
|
+
return merged;
|
|
1021
|
+
}
|
|
1022
|
+
writeAtomic(path.join(ws, "state", EDIT_FILE), { schemaVersion: EDIT_SCHEMA_VERSION, adapterId: io.id, hash: merged.hash, value: merged.value, updatedAt: nowIso(now) });
|
|
1023
|
+
next.operation = null;
|
|
1024
|
+
next.conflict = null;
|
|
1025
|
+
next.lastError = null;
|
|
1026
|
+
next.attempt = 0;
|
|
1027
|
+
next.retryAt = null;
|
|
1028
|
+
if (merged.pending || staleAnswer) {
|
|
1029
|
+
next.state = "pending";
|
|
1030
|
+
next.pendingHash = merged.hash;
|
|
1031
|
+
next.staleRetries = (next.staleRetries ?? 0) + (staleAnswer ? 1 : 0);
|
|
1032
|
+
} else {
|
|
1033
|
+
next.state = "idle";
|
|
1034
|
+
next.pendingHash = null;
|
|
1035
|
+
next.staleRetries = 0;
|
|
1036
|
+
next.lastConfirmedAt = nowIso(now);
|
|
1037
|
+
}
|
|
1038
|
+
writeSync(ws, next);
|
|
1039
|
+
return merged;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function conflictBlock(ws, q, body, now) {
|
|
1043
|
+
release(ws, (queue) => {
|
|
1044
|
+
queue.conflict = {
|
|
1045
|
+
conflictId: randomUUID(),
|
|
1046
|
+
expectedHash: q.operation.sent.hash,
|
|
1047
|
+
currentETag: body?.eTag ?? null,
|
|
1048
|
+
current: body?.current ?? null,
|
|
1049
|
+
local: null,
|
|
1050
|
+
base: q.operation.base?.value ?? null,
|
|
1051
|
+
sent: q.operation.sent,
|
|
1052
|
+
merged: body?.merged ?? null,
|
|
1053
|
+
conflicts: Array.isArray(body?.conflicts) ? body.conflicts : [],
|
|
1054
|
+
at: nowIso(now),
|
|
1055
|
+
};
|
|
1056
|
+
queue.operation = null;
|
|
1057
|
+
queue.state = "blocked";
|
|
1058
|
+
queue.lastError = { code: "remote_conflict", message: `${queue.conflict.conflicts.length} field(s) changed on both sides`, retryable: false };
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Stop hook: a request on the wire when the process goes down is of unknown outcome. */
|
|
1063
|
+
export function markInFlightUnknown() {
|
|
1064
|
+
for (const ws of inFlight) {
|
|
1065
|
+
try {
|
|
1066
|
+
const q = readSync(ws);
|
|
1067
|
+
if (q.state === "sending") {
|
|
1068
|
+
q.state = "outcome-unknown";
|
|
1069
|
+
q.lastError = { code: "outcome_unknown", message: "the process stopped while the request was on the wire", retryable: false };
|
|
1070
|
+
writeSync(ws, q);
|
|
1071
|
+
}
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* the next drain reports it */
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
inFlight.clear();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* The daemon's sender: every `intervalMs` (and on `wake`) drain each deck whose
|
|
1081
|
+
* queue has work. One drain per deck at a time. `decks()` lists workspace
|
|
1082
|
+
* dirs, `adapterFor(ws)` the installed adapter or null. Returns `{ stop,
|
|
1083
|
+
* wake }`; `stop` marks in-flight sends unknown — register it as a stop hook.
|
|
1084
|
+
*/
|
|
1085
|
+
export function startSyncLoop({ decks, adapterFor, intervalMs = 2_000, track = (p) => p, log = () => {}, owner = false } = {}) {
|
|
1086
|
+
const running = new Set();
|
|
1087
|
+
const drain = (ws) => {
|
|
1088
|
+
if (running.has(ws)) return;
|
|
1089
|
+
running.add(ws);
|
|
1090
|
+
track(
|
|
1091
|
+
drainOnce(ws, { adapter: adapterFor(ws), owner })
|
|
1092
|
+
.catch((err) => log(`sync ${ws}: ${err?.code ?? ""} ${err?.message ?? err}`))
|
|
1093
|
+
.finally(() => running.delete(ws)),
|
|
1094
|
+
);
|
|
1095
|
+
};
|
|
1096
|
+
const tick = () => {
|
|
1097
|
+
for (const ws of decks()) {
|
|
1098
|
+
try {
|
|
1099
|
+
const { state, retryAt } = syncStatus(ws);
|
|
1100
|
+
const due = state === "pending" && (!retryAt || Date.parse(retryAt) <= Date.now());
|
|
1101
|
+
if (due || state === "sending" || state === "applying" || state === "outcome-unknown") drain(ws);
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
log(`sync ${ws}: ${err?.message ?? err}`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
const timer = setInterval(tick, intervalMs);
|
|
1108
|
+
timer.unref?.();
|
|
1109
|
+
wakeHook = drain;
|
|
1110
|
+
tick();
|
|
1111
|
+
return {
|
|
1112
|
+
wake: (ws) => drain(wsDir(ws)),
|
|
1113
|
+
stop() {
|
|
1114
|
+
clearInterval(timer);
|
|
1115
|
+
if (wakeHook === drain) wakeHook = null;
|
|
1116
|
+
markInFlightUnknown();
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
}
|