slidemind 0.3.19 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/engine-slides.mjs +1 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { assertNoActivation } from './workspace-transaction.mjs';
|
|
2
|
+
// The remote half of a deck's revision state: which item it publishes to, what
|
|
3
|
+
// this machine last observed there, what a publish is allowed to do, and the
|
|
4
|
+
// durable record an interrupted write is recovered from.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is SYNC and filesystem-only — the same shape contract as
|
|
7
|
+
// `revision.mjs`, and for the same reason: a pack and the provider
|
|
8
|
+
// client both read these records and cannot import each other.
|
|
9
|
+
//
|
|
10
|
+
// state/remote.json the binding + capabilities + last receipt
|
|
11
|
+
// state/operations/<operationId>.json journaled BEFORE any external write
|
|
12
|
+
// state/receipts/<operationId>.json written only on a CONFIRMED commit
|
|
13
|
+
//
|
|
14
|
+
// Three rules the file exists to keep:
|
|
15
|
+
//
|
|
16
|
+
// 1. **Physical identity is the binding.** provider/tenant/host/driveId/itemId
|
|
17
|
+
// select the target; name/webUrl/path are labels that a rename changes and
|
|
18
|
+
// nothing routes on. A tag inside a .pptx never redirects a publish.
|
|
19
|
+
// 2. **Journal before the write, receipt after the confirmation.** An
|
|
20
|
+
// operation record exists before the first byte leaves; the receipt exists
|
|
21
|
+
// only once the server has named the version it committed. A crash between
|
|
22
|
+
// the two is `unknown_outcome`, which is INSPECTED, never retried blindly.
|
|
23
|
+
// 3. **Policy, grant, route support and item access are four things.** A
|
|
24
|
+
// readable item is not a writable one, a 403 is not a consent verdict, and
|
|
25
|
+
// an unproved conditional route disables automatic publication even when
|
|
26
|
+
// everything else is allowed.
|
|
27
|
+
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
export const SCHEMA_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
export const REMOTE_FILE = "remote.json";
|
|
35
|
+
export const OPERATIONS_DIR = "operations";
|
|
36
|
+
export const RECEIPTS_DIR = "receipts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every typed reason a remote operation can be blocked or degraded by, from the
|
|
40
|
+
* design's §1 list. `publish`/`pull`/`ready` return ALL that apply and pick one
|
|
41
|
+
* stable primary — a caller that switches on a single string still gets a
|
|
42
|
+
* deterministic answer, and a caller that renders the list gets the whole truth.
|
|
43
|
+
*
|
|
44
|
+
* Ordered by how much they decide: the earlier reason is the primary one.
|
|
45
|
+
*/
|
|
46
|
+
export const REASONS = [
|
|
47
|
+
// the deck is not pointed anywhere / cannot be pointed anywhere
|
|
48
|
+
"identity_unresolved",
|
|
49
|
+
"remote_missing",
|
|
50
|
+
"context_missing",
|
|
51
|
+
// deployment or tenant policy said no before any request was made
|
|
52
|
+
"remote_saving_disabled",
|
|
53
|
+
"write_scope_missing",
|
|
54
|
+
"consent_required",
|
|
55
|
+
// the provider refused THIS item, or an artifact's exact version cannot be pinned
|
|
56
|
+
"target_access_denied",
|
|
57
|
+
"conditional_write_unsupported",
|
|
58
|
+
// a required local gate has not passed
|
|
59
|
+
"extension_missing",
|
|
60
|
+
"extension_incompatible",
|
|
61
|
+
"validation_required",
|
|
62
|
+
"needs_reconcile",
|
|
63
|
+
// a previous operation left the world in a state only inspection resolves
|
|
64
|
+
"unknown_outcome",
|
|
65
|
+
"snapshot_raced",
|
|
66
|
+
// transient
|
|
67
|
+
"throttled",
|
|
68
|
+
"offline",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Capability answers are three-valued: never infer the third from the other two. */
|
|
72
|
+
export const SUPPORT = ["supported", "unsupported", "unknown"];
|
|
73
|
+
export const ACCESS = ["allowed", "denied", "unknown"];
|
|
74
|
+
|
|
75
|
+
/** The routes a provider adapter reports on, independently. */
|
|
76
|
+
export const ROUTES = [
|
|
77
|
+
"read",
|
|
78
|
+
"create",
|
|
79
|
+
"rename",
|
|
80
|
+
"conditionalSmall",
|
|
81
|
+
"conditionalLarge",
|
|
82
|
+
"exactVersionRead",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a deck may do with its remote copy right now.
|
|
87
|
+
*
|
|
88
|
+
* - `automatic` — a protected publish may be ATTEMPTED. Not a promise it will
|
|
89
|
+
* succeed: item write access can still be `unknown` until the write happens.
|
|
90
|
+
* - `read-only` — reads work, managed writes are ineligible.
|
|
91
|
+
* - `manual` — reads are unavailable; the deck exports a validated handoff.
|
|
92
|
+
* - `blocked` — a local gate must clear first (reconcile, extension, unknown
|
|
93
|
+
* outcome); nothing remote is attempted.
|
|
94
|
+
*/
|
|
95
|
+
export const MODES = ["automatic", "read-only", "manual", "blocked"];
|
|
96
|
+
|
|
97
|
+
const nowIso = () => new Date().toISOString();
|
|
98
|
+
|
|
99
|
+
function stateDir(workspaceDir, ...sub) {
|
|
100
|
+
return path.join(workspaceDir, "state", ...sub);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readJson(file) {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Write-then-rename: a kill mid-write must not leave half a JSON record where
|
|
112
|
+
// the operation journal is supposed to be.
|
|
113
|
+
function writeJson(file, data) {
|
|
114
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
115
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
116
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
117
|
+
fs.renameSync(tmp, file);
|
|
118
|
+
return file;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// the binding
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The physical identity of one stored document, as one comparable string.
|
|
127
|
+
*
|
|
128
|
+
* This is what a portable context's `documentInstance` carries and what a cold
|
|
129
|
+
* open compares against the item the user's own link resolved to. Name, path and
|
|
130
|
+
* webUrl are deliberately absent: a rename must not read as a different document,
|
|
131
|
+
* and a moved file inside one drive is still the same file.
|
|
132
|
+
*/
|
|
133
|
+
export function documentInstance({ provider, tenant, driveId, itemId }) {
|
|
134
|
+
if (!provider || !tenant || !driveId || !itemId) return null;
|
|
135
|
+
return `${provider}:${tenant}:${driveId}:${itemId}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function remotePath(workspaceDir) {
|
|
139
|
+
return stateDir(workspaceDir, REMOTE_FILE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readRemote(workspaceDir) {
|
|
143
|
+
assertNoActivation(workspaceDir);
|
|
144
|
+
const data = readJson(remotePath(workspaceDir));
|
|
145
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `destination.json` as a create target, or null. Folder identity plus the
|
|
150
|
+
* saved presentation name — the folder the user connected in the workspace
|
|
151
|
+
* (src/workspace/remote-status.mjs writes it), where the first publish creates
|
|
152
|
+
* the file. A malformed or nameless file is ignored here — the workspace card
|
|
153
|
+
* reports it, publish just behaves as if no folder was chosen.
|
|
154
|
+
*/
|
|
155
|
+
export function savedDestination(workspaceDir) {
|
|
156
|
+
const saved = readJson(stateDir(workspaceDir, "destination.json"));
|
|
157
|
+
if (saved?.schemaVersion !== 1 || !saved.driveId || !saved.itemId || !saved.pptxName) return null;
|
|
158
|
+
if (!/\.pptx$/i.test(saved.pptxName) || /[\\/]/.test(saved.pptxName)) return null;
|
|
159
|
+
return { driveId: saved.driveId, parentId: saved.itemId, name: saved.pptxName, backendURL: saved.backendURL ?? null };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function writeRemote(workspaceDir, remote) {
|
|
163
|
+
const record = { ...remote, schemaVersion: SCHEMA_VERSION, updatedAt: nowIso() };
|
|
164
|
+
writeJson(remotePath(workspaceDir), record);
|
|
165
|
+
return record;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Point a deck at an item. `identity` is the physical half and is never merged
|
|
170
|
+
* from an existing record — binding somewhere else is a REBIND, which the caller
|
|
171
|
+
* decides explicitly; this only refuses to do it silently.
|
|
172
|
+
*
|
|
173
|
+
* Throws `rebind_required` when a different item is already bound, so no caller
|
|
174
|
+
* can repoint a deck by forgetting to ask.
|
|
175
|
+
*/
|
|
176
|
+
export function bindRemote(
|
|
177
|
+
workspaceDir,
|
|
178
|
+
{ identity, labels = {}, rebind = false, ...rest },
|
|
179
|
+
) {
|
|
180
|
+
const instance = documentInstance(identity);
|
|
181
|
+
if (!instance) {
|
|
182
|
+
const e = new Error("a remote binding needs provider, tenant, driveId and itemId");
|
|
183
|
+
e.code = "identity_unresolved";
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
const existing = readRemote(workspaceDir);
|
|
187
|
+
if (existing && existing.documentInstance !== instance && !rebind) {
|
|
188
|
+
const e = new Error(
|
|
189
|
+
`this deck is bound to ${existing.name ?? existing.documentInstance}. ` +
|
|
190
|
+
`Publishing it somewhere else is a rebind — pass --rebind to confirm, or open the file ` +
|
|
191
|
+
`into a fresh deck to keep both.`,
|
|
192
|
+
);
|
|
193
|
+
e.code = "rebind_required";
|
|
194
|
+
e.bound = existing;
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
// A rebind starts a clean remote record: the old item's accepted eTag, its
|
|
198
|
+
// committed refs and its pending operation describe a DIFFERENT file, and
|
|
199
|
+
// carrying any of them forward would let a publish send an If-Match that
|
|
200
|
+
// belongs to someone else's document.
|
|
201
|
+
const carried = existing && existing.documentInstance === instance ? existing : {};
|
|
202
|
+
return writeRemote(workspaceDir, {
|
|
203
|
+
...carried,
|
|
204
|
+
...identity,
|
|
205
|
+
documentInstance: instance,
|
|
206
|
+
...labels,
|
|
207
|
+
boundAt: carried.boundAt ?? nowIso(),
|
|
208
|
+
...rest,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Forget the binding; the workspace and its revision state are untouched. */
|
|
213
|
+
export function detachRemote(workspaceDir) {
|
|
214
|
+
const existing = readRemote(workspaceDir);
|
|
215
|
+
fs.rmSync(remotePath(workspaceDir), { force: true });
|
|
216
|
+
return existing;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Record what a read actually observed. `lastPulledAt` means "a freshness
|
|
221
|
+
* observation SUCCEEDED at this time" and is never refreshed by a failed
|
|
222
|
+
* attempt — a stale timestamp that says so beats a current one that lies.
|
|
223
|
+
*/
|
|
224
|
+
export function noteObservation(workspaceDir, { revision = null, failure = null, labels = {} }) {
|
|
225
|
+
const remote = readRemote(workspaceDir);
|
|
226
|
+
if (!remote) return null;
|
|
227
|
+
return writeRemote(workspaceDir, {
|
|
228
|
+
...remote,
|
|
229
|
+
...labels,
|
|
230
|
+
...(revision ? { observedRevision: revision, lastPulledAt: nowIso() } : {}),
|
|
231
|
+
lastObservationError: failure ? { at: nowIso(), ...failure } : null,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// capabilities, access and the mode they add up to
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fold policy, grant, route support, item access and the local gates into one
|
|
241
|
+
* answer.
|
|
242
|
+
*
|
|
243
|
+
* `capabilities` maps each route of {@link ROUTES} to a {@link SUPPORT} value.
|
|
244
|
+
* `access` is `{ read, write }` over {@link ACCESS} — `write: "unknown"` is the
|
|
245
|
+
* NORMAL answer and stays that way: the only honest way to learn item write
|
|
246
|
+
* access is the publish the user actually asked for, never a surprise probe.
|
|
247
|
+
*
|
|
248
|
+
* `blockers` is every applicable reason; `reason` is the first of them in
|
|
249
|
+
* {@link REASONS} order, so a switch on one string is stable across runs.
|
|
250
|
+
*
|
|
251
|
+
* `canPublish` means a protected publish is ELIGIBLE to be attempted, not that
|
|
252
|
+
* it will be allowed. That distinction is the whole point of keeping
|
|
253
|
+
* `writeAccess` separate.
|
|
254
|
+
*/
|
|
255
|
+
export function computeMode({ capabilities = {}, access = {}, policy = {}, gates = {} } = {}) {
|
|
256
|
+
const blockers = new Set();
|
|
257
|
+
// Every route is live (bridge/sharepoint.mjs); `capabilities` is kept for the
|
|
258
|
+
// on-disk record and for a provider that reports a read as unsupported.
|
|
259
|
+
const support = (route) => capabilities[route] ?? "supported";
|
|
260
|
+
const readAccess = access.read ?? "unknown";
|
|
261
|
+
const writeAccess = access.write ?? "unknown";
|
|
262
|
+
|
|
263
|
+
if (policy.identityUnresolved) blockers.add("identity_unresolved");
|
|
264
|
+
if (policy.itemMissing) blockers.add("remote_missing");
|
|
265
|
+
if (policy.offline) blockers.add("offline");
|
|
266
|
+
if (policy.throttled) blockers.add("throttled");
|
|
267
|
+
if (policy.remoteSavingDisabled) blockers.add("remote_saving_disabled");
|
|
268
|
+
if (policy.writeScopeMissing) blockers.add("write_scope_missing");
|
|
269
|
+
// Only ever set from EVIDENCE the provider returned (an `invalid_grant`, a
|
|
270
|
+
// WWW-Authenticate naming insufficient_claims). A bare 403 never lands here.
|
|
271
|
+
if (policy.consentRequired) blockers.add("consent_required");
|
|
272
|
+
|
|
273
|
+
if (gates.needsReconcile) blockers.add("needs_reconcile");
|
|
274
|
+
if (gates.extensionMissing) blockers.add("extension_missing");
|
|
275
|
+
if (gates.extensionIncompatible) blockers.add("extension_incompatible");
|
|
276
|
+
if (gates.validationRequired) blockers.add("validation_required");
|
|
277
|
+
if (gates.unknownOutcome) blockers.add("unknown_outcome");
|
|
278
|
+
if (gates.contextMissing) blockers.add("context_missing");
|
|
279
|
+
if (gates.snapshotRaced) blockers.add("snapshot_raced");
|
|
280
|
+
|
|
281
|
+
if (writeAccess === "denied") blockers.add("target_access_denied");
|
|
282
|
+
|
|
283
|
+
const writesPossible = !policy.remoteSavingDisabled && !policy.writeScopeMissing;
|
|
284
|
+
|
|
285
|
+
// Reads unavailable is the manual branch — and the reason for it is kept,
|
|
286
|
+
// because "we could not read it" and "you may not write it" need different
|
|
287
|
+
// remedies.
|
|
288
|
+
const readable = readAccess !== "denied" && support("read") === "supported" && !policy.readsDisabled;
|
|
289
|
+
if (!readable && readAccess === "denied") blockers.add("target_access_denied");
|
|
290
|
+
|
|
291
|
+
let mode;
|
|
292
|
+
if (!readable) {
|
|
293
|
+
mode = "manual";
|
|
294
|
+
} else if (
|
|
295
|
+
blockers.has("context_missing") ||
|
|
296
|
+
blockers.has("identity_unresolved") ||
|
|
297
|
+
blockers.has("remote_missing") ||
|
|
298
|
+
blockers.has("needs_reconcile") ||
|
|
299
|
+
blockers.has("extension_missing") ||
|
|
300
|
+
blockers.has("extension_incompatible") ||
|
|
301
|
+
blockers.has("validation_required") ||
|
|
302
|
+
blockers.has("unknown_outcome") ||
|
|
303
|
+
blockers.has("snapshot_raced")
|
|
304
|
+
) {
|
|
305
|
+
mode = "blocked";
|
|
306
|
+
} else if (writesPossible && writeAccess !== "denied" && !blockers.has("consent_required")) {
|
|
307
|
+
mode = "automatic";
|
|
308
|
+
} else {
|
|
309
|
+
mode = "read-only";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const ordered = REASONS.filter((r) => blockers.has(r));
|
|
313
|
+
return {
|
|
314
|
+
mode,
|
|
315
|
+
canPublish: mode === "automatic",
|
|
316
|
+
writeAccess,
|
|
317
|
+
readAccess,
|
|
318
|
+
capabilities: Object.fromEntries(ROUTES.map((r) => [r, support(r)])),
|
|
319
|
+
blockers: ordered,
|
|
320
|
+
reason: ordered[0] ?? null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Classify a provider response into access evidence WITHOUT inventing a consent
|
|
326
|
+
* verdict. The distinction the design insists on:
|
|
327
|
+
*
|
|
328
|
+
* - a READ 403 → the target is not readable by this user → manual.
|
|
329
|
+
* - a WRITE 403 with readable metadata → read-only, `target_access_denied`.
|
|
330
|
+
* - a generic 403 → never `consent_required`. Consent is claimed only when the
|
|
331
|
+
* provider says so (a `WWW-Authenticate` naming insufficient_claims, or an
|
|
332
|
+
* `invalid_grant` / `interaction_required` from the token endpoint).
|
|
333
|
+
*/
|
|
334
|
+
export function classifyAccessResponse({ status, on, headers = {}, body = null }) {
|
|
335
|
+
const challenge = String(
|
|
336
|
+
headers["www-authenticate"] ?? headers["WWW-Authenticate"] ?? "",
|
|
337
|
+
).toLowerCase();
|
|
338
|
+
const code = String(body?.error?.code ?? body?.error ?? "").toLowerCase();
|
|
339
|
+
const consentEvidence =
|
|
340
|
+
challenge.includes("insufficient_claims") ||
|
|
341
|
+
challenge.includes("consent") ||
|
|
342
|
+
["invalid_grant", "interaction_required", "consent_required"].includes(code);
|
|
343
|
+
|
|
344
|
+
if (status === 404) return { access: "unknown", reason: "remote_missing" };
|
|
345
|
+
if (status === 429 || status === 503) return { access: "unknown", reason: "throttled" };
|
|
346
|
+
if (status === 401) return { access: "unknown", reason: "consent_required" };
|
|
347
|
+
if (status === 403) {
|
|
348
|
+
if (consentEvidence) return { access: "unknown", reason: "consent_required" };
|
|
349
|
+
return {
|
|
350
|
+
access: "denied",
|
|
351
|
+
reason: on === "read" ? "target_access_denied" : "target_access_denied",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (status >= 200 && status < 300) return { access: "allowed", reason: null };
|
|
355
|
+
return { access: "unknown", reason: null };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// the operation journal — written BEFORE any external effect
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The phases an operation passes through. Everything from `artifacts` onward may
|
|
364
|
+
* have left an effect on the server, so a restart that finds one of those must
|
|
365
|
+
* INSPECT before it acts.
|
|
366
|
+
*/
|
|
367
|
+
export const OPERATION_PHASES = [
|
|
368
|
+
"planned", // journaled; nothing has left this machine
|
|
369
|
+
"artifacts", // durable context / attachments may have been written
|
|
370
|
+
"deck-write", // the conditional replace or create is in flight
|
|
371
|
+
"committed", // the server confirmed; a receipt exists
|
|
372
|
+
"finalized", // local state advanced and the extension callback acknowledged
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
/** Phases whose outcome cannot be assumed without asking the server. */
|
|
376
|
+
export const UNCERTAIN_PHASES = ["artifacts", "deck-write"];
|
|
377
|
+
|
|
378
|
+
export function operationsDir(workspaceDir) {
|
|
379
|
+
return stateDir(workspaceDir, OPERATIONS_DIR);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function operationPath(workspaceDir, operationId) {
|
|
383
|
+
return path.join(operationsDir(workspaceDir), `${operationId}.json`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function readOperation(workspaceDir, operationId) {
|
|
387
|
+
const data = readJson(operationPath(workspaceDir, operationId));
|
|
388
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function listOperations(workspaceDir) {
|
|
392
|
+
let names = [];
|
|
393
|
+
try {
|
|
394
|
+
names = fs.readdirSync(operationsDir(workspaceDir));
|
|
395
|
+
} catch {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
return names
|
|
399
|
+
.filter((n) => n.endsWith(".json"))
|
|
400
|
+
.map((n) => readOperation(workspaceDir, n.slice(0, -5)))
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Operations whose outcome the next command must resolve before it writes. */
|
|
406
|
+
export function pendingOperations(workspaceDir) {
|
|
407
|
+
return listOperations(workspaceDir).filter(
|
|
408
|
+
(op) => op.phase !== "finalized" && op.phase !== "abandoned",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Journal an operation before its first external effect.
|
|
414
|
+
*
|
|
415
|
+
* `recoveryMarker` is what makes an unknown outcome findable: it rides into the
|
|
416
|
+
* server's own record of the write (the content-addressed artifact name, the
|
|
417
|
+
* created item's name, the operation id inside the uploaded context tag) so a
|
|
418
|
+
* restart can ask "is MY write the one that landed?" instead of guessing from a
|
|
419
|
+
* timestamp.
|
|
420
|
+
*/
|
|
421
|
+
export function beginOperation(
|
|
422
|
+
workspaceDir,
|
|
423
|
+
{ kind, deck = null, destination = null, evidence = {}, plannedArtifacts = [], recoveryMarker = null },
|
|
424
|
+
) {
|
|
425
|
+
const operationId = `op-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
426
|
+
const record = {
|
|
427
|
+
schemaVersion: SCHEMA_VERSION,
|
|
428
|
+
operationId,
|
|
429
|
+
kind,
|
|
430
|
+
phase: "planned",
|
|
431
|
+
startedAt: nowIso(),
|
|
432
|
+
deck,
|
|
433
|
+
...(destination ? { destination } : {}),
|
|
434
|
+
evidence,
|
|
435
|
+
plannedArtifacts,
|
|
436
|
+
pendingRefs: [],
|
|
437
|
+
committedRefs: [],
|
|
438
|
+
recoveryMarker: recoveryMarker ?? operationId,
|
|
439
|
+
steps: [],
|
|
440
|
+
receipt: null,
|
|
441
|
+
};
|
|
442
|
+
writeJson(operationPath(workspaceDir, operationId), record);
|
|
443
|
+
const remote = readRemote(workspaceDir);
|
|
444
|
+
if (remote) writeRemote(workspaceDir, { ...remote, pendingOperationId: operationId });
|
|
445
|
+
return record;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Advance an operation. The record on disk is the truth; the return value is a copy. */
|
|
449
|
+
export function stepOperation(workspaceDir, operationId, { phase = null, step = null, ...patch }) {
|
|
450
|
+
const current = readOperation(workspaceDir, operationId);
|
|
451
|
+
if (!current) return null;
|
|
452
|
+
const next = {
|
|
453
|
+
...current,
|
|
454
|
+
...patch,
|
|
455
|
+
...(phase ? { phase } : {}),
|
|
456
|
+
steps: step ? [...current.steps, { step, at: nowIso() }] : current.steps,
|
|
457
|
+
};
|
|
458
|
+
writeJson(operationPath(workspaceDir, operationId), next);
|
|
459
|
+
return next;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Persist a CONFIRMED receipt, idempotently.
|
|
464
|
+
*
|
|
465
|
+
* Called twice with the same operation id, the second call returns the first
|
|
466
|
+
* receipt unchanged: a retried extension callback, a resumed publish and a
|
|
467
|
+
* crash-recovery pass must all converge on one record rather than each writing
|
|
468
|
+
* their own version of what happened.
|
|
469
|
+
*/
|
|
470
|
+
export function writeReceipt(workspaceDir, operationId, receipt) {
|
|
471
|
+
const file = path.join(stateDir(workspaceDir, RECEIPTS_DIR), `${operationId}.json`);
|
|
472
|
+
const existing = readJson(file);
|
|
473
|
+
if (existing?.schemaVersion === SCHEMA_VERSION) return existing;
|
|
474
|
+
const record = {
|
|
475
|
+
schemaVersion: SCHEMA_VERSION,
|
|
476
|
+
operationId,
|
|
477
|
+
confirmedAt: nowIso(),
|
|
478
|
+
...receipt,
|
|
479
|
+
};
|
|
480
|
+
writeJson(file, record);
|
|
481
|
+
stepOperation(workspaceDir, operationId, {
|
|
482
|
+
phase: "committed",
|
|
483
|
+
step: "receipt",
|
|
484
|
+
receipt: record,
|
|
485
|
+
});
|
|
486
|
+
return record;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function readReceipt(workspaceDir, operationId) {
|
|
490
|
+
const data = readJson(path.join(stateDir(workspaceDir, RECEIPTS_DIR), `${operationId}.json`));
|
|
491
|
+
return data?.schemaVersion === SCHEMA_VERSION ? data : null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function listReceipts(workspaceDir) {
|
|
495
|
+
let names = [];
|
|
496
|
+
try {
|
|
497
|
+
names = fs.readdirSync(stateDir(workspaceDir, RECEIPTS_DIR));
|
|
498
|
+
} catch {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
return names
|
|
502
|
+
.filter((n) => n.endsWith(".json"))
|
|
503
|
+
.map((n) => readReceipt(workspaceDir, n.slice(0, -5)))
|
|
504
|
+
.filter(Boolean)
|
|
505
|
+
.sort((a, b) => (a.confirmedAt < b.confirmedAt ? 1 : -1));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Advance the ACCEPTED remote base from a confirmed receipt. This is the remote
|
|
510
|
+
* half of acceptance — the only door besides `revision accept` (which refuses a
|
|
511
|
+
* remote-bound deck for exactly this reason).
|
|
512
|
+
*
|
|
513
|
+
* Pending refs become committed refs here and nowhere else: a ref an artifact
|
|
514
|
+
* upload resolved is not paired with anything until the deck version that names
|
|
515
|
+
* it is the one the server holds.
|
|
516
|
+
*/
|
|
517
|
+
export function acceptReceipt(workspaceDir, receipt) {
|
|
518
|
+
const remote = readRemote(workspaceDir);
|
|
519
|
+
if (!remote) return null;
|
|
520
|
+
if (receipt.route === "rename" || receipt.route === "rename-recovered") {
|
|
521
|
+
return writeRemote(workspaceDir, {
|
|
522
|
+
...remote,
|
|
523
|
+
name: receipt.item?.name ?? remote.name,
|
|
524
|
+
webUrl: receipt.item?.webUrl ?? remote.webUrl,
|
|
525
|
+
pendingOperationId: null,
|
|
526
|
+
lastReceipt: { operationId: receipt.operationId, confirmedAt: receipt.confirmedAt, route: receipt.route },
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return writeRemote(workspaceDir, {
|
|
530
|
+
...remote,
|
|
531
|
+
// A rename or a move changes the labels; identity does not move.
|
|
532
|
+
name: receipt.item?.name ?? remote.name,
|
|
533
|
+
webUrl: receipt.item?.webUrl ?? remote.webUrl,
|
|
534
|
+
acceptedETag: receipt.version?.eTag ?? remote.acceptedETag,
|
|
535
|
+
acceptedCTag: receipt.version?.cTag ?? remote.acceptedCTag,
|
|
536
|
+
acceptedVersionId: receipt.version?.versionId ?? remote.acceptedVersionId,
|
|
537
|
+
acceptedSha256: receipt.candidateSha256 ?? remote.acceptedSha256,
|
|
538
|
+
observedRevision: receipt.version ?? remote.observedRevision,
|
|
539
|
+
lastPulledAt: nowIso(),
|
|
540
|
+
committedRefs: receipt.refs ?? remote.committedRefs ?? [],
|
|
541
|
+
pendingRefs: [],
|
|
542
|
+
pendingOperationId: null,
|
|
543
|
+
pendingCandidateId: null,
|
|
544
|
+
lastReceipt: {
|
|
545
|
+
operationId: receipt.operationId,
|
|
546
|
+
confirmedAt: receipt.confirmedAt,
|
|
547
|
+
versionId: receipt.version?.versionId ?? null,
|
|
548
|
+
eTag: receipt.version?.eTag ?? null,
|
|
549
|
+
route: receipt.route ?? null,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* What a restart must do about an operation it found.
|
|
556
|
+
*
|
|
557
|
+
* `resolved` when a receipt already exists (the commit landed; only local
|
|
558
|
+
* finalization is pending), `inspect` while the phase could have left an effect,
|
|
559
|
+
* `discard` when nothing can have happened yet.
|
|
560
|
+
*/
|
|
561
|
+
export function operationOutcome(workspaceDir, operation) {
|
|
562
|
+
if (!operation) return null;
|
|
563
|
+
const receipt = readReceipt(workspaceDir, operation.operationId);
|
|
564
|
+
if (receipt) return { action: "resolved", receipt, operation };
|
|
565
|
+
if (UNCERTAIN_PHASES.includes(operation.phase)) {
|
|
566
|
+
return { action: "inspect", operation, reason: "unknown_outcome" };
|
|
567
|
+
}
|
|
568
|
+
return { action: "discard", operation };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// what `ready` and `status` report about the remote half
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The remote block of `ready --json`, computed WITHOUT a network call.
|
|
577
|
+
*
|
|
578
|
+
* Deliberately a disk read: `ready` runs at the top of every session and must
|
|
579
|
+
* not turn into a SharePoint round trip. It reports the last SUCCESSFUL
|
|
580
|
+
* observation and says when that was; it never claims the deck is current,
|
|
581
|
+
* because nothing here has asked. Continuous freshness is a lie a status command
|
|
582
|
+
* must not tell.
|
|
583
|
+
*/
|
|
584
|
+
export function remoteReadiness(workspaceDir, revisionStatus) {
|
|
585
|
+
const remote = readRemote(workspaceDir);
|
|
586
|
+
if (!remote) return null;
|
|
587
|
+
const status = revisionStatus?.(workspaceDir) ?? null;
|
|
588
|
+
const pending = pendingOperations(workspaceDir);
|
|
589
|
+
const decision = computeMode({
|
|
590
|
+
capabilities: remote.capabilities ?? {},
|
|
591
|
+
access: remote.access ?? {},
|
|
592
|
+
gates: {
|
|
593
|
+
needsReconcile: Boolean(status && (status.conflicts > 0 || status.staged.length > 0)),
|
|
594
|
+
unknownOutcome: pending.some((op) => UNCERTAIN_PHASES.includes(op.phase)),
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
provider: remote.provider ?? "sharepoint",
|
|
599
|
+
documentInstance: remote.documentInstance,
|
|
600
|
+
name: remote.name ?? null,
|
|
601
|
+
webUrl: remote.webUrl ?? null,
|
|
602
|
+
mode: decision.mode,
|
|
603
|
+
canPublish: decision.canPublish,
|
|
604
|
+
writeAccess: decision.writeAccess,
|
|
605
|
+
capabilities: decision.capabilities,
|
|
606
|
+
blockers: decision.blockers,
|
|
607
|
+
reason: decision.reason,
|
|
608
|
+
baseRevision: remote.acceptedETag ?? null,
|
|
609
|
+
observedRemoteRevision: remote.observedRevision?.eTag ?? null,
|
|
610
|
+
lastPulledAt: remote.lastPulledAt ?? null,
|
|
611
|
+
lastObservationError: remote.lastObservationError ?? null,
|
|
612
|
+
localDirty: status?.localDirty ?? null,
|
|
613
|
+
pendingCandidate: status?.candidate?.candidateId ?? remote.pendingCandidateId ?? null,
|
|
614
|
+
stagedIncomingRevision: status?.staged?.[0]?.revisionId ?? null,
|
|
615
|
+
dependencyChanges: Object.keys(remote.dependencyObservations ?? {}),
|
|
616
|
+
operationOutcome: pending.length
|
|
617
|
+
? { operationId: pending[0].operationId, phase: pending[0].phase }
|
|
618
|
+
: null,
|
|
619
|
+
pendingManualUpload: Boolean(remote.manualHandoff),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** One human line for `ready <deck>` — observation time included, no freshness claim. */
|
|
624
|
+
export function remoteHumanLine(readiness) {
|
|
625
|
+
if (!readiness) return "";
|
|
626
|
+
const where = readiness.name ?? readiness.documentInstance;
|
|
627
|
+
const when = readiness.lastPulledAt
|
|
628
|
+
? `last checked ${readiness.lastPulledAt}`
|
|
629
|
+
: "never checked";
|
|
630
|
+
// The mode is the verdict; the blockers are why. "read-only — conditional_write_unsupported"
|
|
631
|
+
// and "blocked — needs_reconcile" need different remedies and must not share a label.
|
|
632
|
+
const state = readiness.canPublish
|
|
633
|
+
? "publishing is available"
|
|
634
|
+
: `${readiness.mode} — ${readiness.blockers.join(", ") || readiness.reason || "no protected write route"}`;
|
|
635
|
+
return (
|
|
636
|
+
`remote: ${where} · ${state} · ${when}\n` +
|
|
637
|
+
(readiness.stagedIncomingRevision
|
|
638
|
+
? ` a newer save is staged (${readiness.stagedIncomingRevision}) — reconcile before publishing\n`
|
|
639
|
+
: "") +
|
|
640
|
+
(readiness.pendingManualUpload
|
|
641
|
+
? ` a manual upload is prepared and still pending — nothing is saved remotely yet\n`
|
|
642
|
+
: "") +
|
|
643
|
+
(readiness.operationOutcome
|
|
644
|
+
? ` a previous publish (${readiness.operationOutcome.operationId}) has an unresolved outcome\n`
|
|
645
|
+
: "")
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Close an operation that inspection proved never landed. Never deletes its record. */
|
|
650
|
+
export function abandonOperation(workspaceDir, operationId, why) {
|
|
651
|
+
const next = stepOperation(workspaceDir, operationId, {
|
|
652
|
+
phase: "abandoned",
|
|
653
|
+
step: "abandoned",
|
|
654
|
+
abandonedBecause: why,
|
|
655
|
+
});
|
|
656
|
+
const remote = readRemote(workspaceDir);
|
|
657
|
+
if (remote?.pendingOperationId === operationId) {
|
|
658
|
+
writeRemote(workspaceDir, { ...remote, pendingOperationId: null });
|
|
659
|
+
}
|
|
660
|
+
return next;
|
|
661
|
+
}
|