@zq-silk/yui 0.2.0 → 0.4.2
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/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real, canonical {@link MigrationTarget} bound to a Yui Home.
|
|
3
|
+
*
|
|
4
|
+
* This is the single seam where the generic, domain-free migration engine
|
|
5
|
+
* (`../migration`) meets a real authoritative store. The engine holds no list of
|
|
6
|
+
* Yui records or derived-state fields; all of that domain knowledge lives here,
|
|
7
|
+
* behind the injected boundary:
|
|
8
|
+
*
|
|
9
|
+
* - `inspectVersions` reads the on-disk versions read-only via
|
|
10
|
+
* `inspectStorageSchema` plus the current record map.
|
|
11
|
+
* - `readSource` reads `schema.json` + `state.json` read-only; it never
|
|
12
|
+
* writes the source.
|
|
13
|
+
* - `writeFreshOutput` stages the migrated snapshot into a fresh sibling
|
|
14
|
+
* directory and REFUSES to overwrite an existing staged output.
|
|
15
|
+
* - `rebuildDerivedState` is the canonical derived-state rebuild; Yui's
|
|
16
|
+
* authoritative state is self-contained in `state.json`, so it rebuilds only
|
|
17
|
+
* the effects a step declared (none for the current production step).
|
|
18
|
+
* - `validateCurrentState` constructs `new FileTaskStore(outputHome)` and reads
|
|
19
|
+
* it once, which forces the strict `parseState` gate (record validation,
|
|
20
|
+
* id counters, and the full reference graph). Any failure throws and the
|
|
21
|
+
* engine aborts before switching.
|
|
22
|
+
* - `atomicSwitchWithBackup` promotes the staged output into the same logical
|
|
23
|
+
* Home path, backing up the original under a timestamped sibling, using the
|
|
24
|
+
* durable temp+rename+fsync discipline.
|
|
25
|
+
*
|
|
26
|
+
* The production registry authorizes only explicit adjacent steps. Aggregate
|
|
27
|
+
* 16→17 and the execution record-family upgrades exercise this target against
|
|
28
|
+
* historical Homes; all other missing paths remain fail-closed.
|
|
29
|
+
*/
|
|
30
|
+
import { closeSync, constants, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
31
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
32
|
+
import { writeTextFileAtomically } from "../durableFile.js";
|
|
33
|
+
import { inspectStorageSchema, parseStorageSchemaManifest, readStorageSchemaManifest, STORAGE_SCHEMA_FILE } from "../storageSchema.js";
|
|
34
|
+
import { FileTaskStore, STORAGE_STATE_FILE } from "../taskStore.js";
|
|
35
|
+
import { readUpgradeFence } from "../upgradeFence.js";
|
|
36
|
+
import { scanRecordVersionsFromState, scanSourceRecordVersions } from "./recordVersionScan.js";
|
|
37
|
+
import { clearSwitchProgress, writeSwitchProgress } from "./switchProgress.js";
|
|
38
|
+
import { AmbiguousSwitchError } from "../migration/index.js";
|
|
39
|
+
const STATE_LOCK_DIRECTORY = ".state.lock";
|
|
40
|
+
const CONTROLLER_DISCOVERY_FILE = "runtime/controller.json";
|
|
41
|
+
/**
|
|
42
|
+
* Inspect the layout-agnostic runtime signals of a Home. These files
|
|
43
|
+
* (`.state.lock`, `runtime/controller.json`) do not depend on the storage
|
|
44
|
+
* version, so this is safe to call against an older, not-yet-migrated Home.
|
|
45
|
+
*/
|
|
46
|
+
export function inspectHomeRuntime(home, callerPid = process.pid) {
|
|
47
|
+
return {
|
|
48
|
+
foreignWriteLock: inspectForeignWriteLock(home, callerPid),
|
|
49
|
+
liveController: inspectLiveController(home),
|
|
50
|
+
fence: readUpgradeFence(home)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* True when any live-or-undeterminable runtime is holding the Home. A holder we
|
|
55
|
+
* cannot positively clear (`unknown`) counts as active — fail closed.
|
|
56
|
+
*/
|
|
57
|
+
export function homeRuntimeIsActive(signals) {
|
|
58
|
+
return signals.foreignWriteLock !== null || signals.liveController !== null;
|
|
59
|
+
}
|
|
60
|
+
/** A human-readable description of why the runtime is considered active. */
|
|
61
|
+
export function describeActiveRuntime(signals) {
|
|
62
|
+
if (signals.liveController !== null) {
|
|
63
|
+
return signals.liveController.state === "live"
|
|
64
|
+
? `Controller pid ${signals.liveController.pid} is running.`
|
|
65
|
+
: `A ${CONTROLLER_DISCOVERY_FILE} exists but is malformed; a live Controller cannot be ruled out.`;
|
|
66
|
+
}
|
|
67
|
+
if (signals.foreignWriteLock !== null) {
|
|
68
|
+
return signals.foreignWriteLock.state === "live"
|
|
69
|
+
? `Another writer holds ${STATE_LOCK_DIRECTORY} (pid ${signals.foreignWriteLock.ownerPid}).`
|
|
70
|
+
: `${STATE_LOCK_DIRECTORY} exists but its owner is undeterminable; another writer cannot be ruled out.`;
|
|
71
|
+
}
|
|
72
|
+
return "A live runtime is holding the Home.";
|
|
73
|
+
}
|
|
74
|
+
/** Build the real Home-bound migration target. */
|
|
75
|
+
export function createHomeMigrationTarget(options) {
|
|
76
|
+
const home = options.home;
|
|
77
|
+
const latest = options.latest;
|
|
78
|
+
const now = options.now ?? (() => new Date());
|
|
79
|
+
const callerPid = options.callerPid ?? process.pid;
|
|
80
|
+
const stagingPath = options.stagingPath ?? `${home}.upgrade-staging`;
|
|
81
|
+
// The staging directory must live OUTSIDE the source Home so the complete-copy
|
|
82
|
+
// contract (F1) never has to copy the staging tree into itself, and so a
|
|
83
|
+
// legitimately-named Home entry is never misclassified as "the staging dir".
|
|
84
|
+
// A staging path nested inside the Home is refused outright rather than papered
|
|
85
|
+
// over with a fragile exclusion.
|
|
86
|
+
if (isPathInside(home, stagingPath)) {
|
|
87
|
+
throw new Error(`Staging path must not be inside the Home being migrated: ${stagingPath} is under ${home}. `
|
|
88
|
+
+ "Use a sibling staging directory.");
|
|
89
|
+
}
|
|
90
|
+
// The promote (staging -> home) and rollback (backup -> home) renames share the
|
|
91
|
+
// injectable seam: a fault that blocks the forward rename (e.g. a read-only
|
|
92
|
+
// parent) would block the reverse too, so an always-failing `renameImpl` drives
|
|
93
|
+
// the genuine interrupted/ambiguous path. Default is the real fs.
|
|
94
|
+
const promoteRename = options.renameImpl ?? renameSync;
|
|
95
|
+
// Fault seam for the fsync/marker steps around the renames (F2). A no-op unless
|
|
96
|
+
// a test injects it; production leaves it undefined.
|
|
97
|
+
const faultHook = options.switchFaultHook ?? (() => { });
|
|
98
|
+
return {
|
|
99
|
+
stagingPath,
|
|
100
|
+
inspectVersions() {
|
|
101
|
+
const inspected = inspectSourceVersionState(home, latest);
|
|
102
|
+
if ("corruption" in inspected) {
|
|
103
|
+
// A structurally-damaged source cannot yield trustworthy versions. The
|
|
104
|
+
// engine has no CORRUPTED outcome, so surface it as a hard error rather
|
|
105
|
+
// than pretend the record axis is current (the classifier turns the same
|
|
106
|
+
// structural damage into a CORRUPTED verdict for doctor/upgrade).
|
|
107
|
+
throw new Error(inspected.corruption.detail);
|
|
108
|
+
}
|
|
109
|
+
return inspected.source;
|
|
110
|
+
},
|
|
111
|
+
detectLiveRuntime() {
|
|
112
|
+
const signals = inspectHomeRuntime(home, callerPid);
|
|
113
|
+
if (!homeRuntimeIsActive(signals))
|
|
114
|
+
return { active: false };
|
|
115
|
+
return { active: true, detail: describeActiveRuntime(signals) };
|
|
116
|
+
},
|
|
117
|
+
readSource() {
|
|
118
|
+
const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
|
|
119
|
+
const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
|
|
120
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
121
|
+
const state = existsSync(statePath)
|
|
122
|
+
? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
|
|
123
|
+
: null;
|
|
124
|
+
return Object.freeze({ schemaManifest, state });
|
|
125
|
+
},
|
|
126
|
+
writeFreshOutput(snapshot) {
|
|
127
|
+
if (existsSync(stagingPath)) {
|
|
128
|
+
throw new Error(`Refusing to overwrite an existing staged upgrade output: ${stagingPath}. `
|
|
129
|
+
+ "Delete it and retry.");
|
|
130
|
+
}
|
|
131
|
+
mkdirSync(stagingPath, { recursive: true, mode: 0o700 });
|
|
132
|
+
// COMPLETE HOME CONTENT PRESERVATION CONTRACT (P1-1)
|
|
133
|
+
// -------------------------------------------------
|
|
134
|
+
// The migration only TRANSFORMS schema.json + state.json, but the atomic
|
|
135
|
+
// switch replaces the WHOLE Home directory (home -> backup, staging ->
|
|
136
|
+
// home). If staging held only those two files, everything else the real
|
|
137
|
+
// Home persists — runtime/inbox/* (AUTHORITATIVE, not-yet-applied events),
|
|
138
|
+
// runtime/ discovery, cache/ and artifacts/ (rebuildable) — would be
|
|
139
|
+
// silently lost on the first real migration.
|
|
140
|
+
//
|
|
141
|
+
// Chosen contract: staging carries a COMPLETE copy of the Home; only
|
|
142
|
+
// schema.json + state.json are overwritten with their migrated bytes.
|
|
143
|
+
// Every other entry (of any depth: dirs, files, symlinks) is copied
|
|
144
|
+
// verbatim, so the switch preserves all authoritative and rebuildable
|
|
145
|
+
// content. The two migrated files are written LAST so they always win over
|
|
146
|
+
// any copied original. The transient `.state.lock` is NOT promoted (a lock
|
|
147
|
+
// is per-instance coordination state, never authoritative Home content);
|
|
148
|
+
// the upgrade fence under runtime/ is copied but cleared post-switch by
|
|
149
|
+
// this process's fence release. See ARCHITECTURE.md "Complete Home content
|
|
150
|
+
// preservation".
|
|
151
|
+
//
|
|
152
|
+
// Only schema.json / state.json / .state.lock are excluded — NOT the staging
|
|
153
|
+
// basename (F1). The staging directory is guaranteed to be OUTSIDE the Home
|
|
154
|
+
// (enforced at target construction), so a Home entry that happens to share
|
|
155
|
+
// the staging basename (e.g. a real `home.upgrade-staging` file) is genuine
|
|
156
|
+
// content and is copied like anything else, never silently dropped.
|
|
157
|
+
copyHomeContentsExcept(home, stagingPath, new Set([STORAGE_SCHEMA_FILE, STORAGE_STATE_FILE, STATE_LOCK_DIRECTORY]));
|
|
158
|
+
writeTextFileAtomically(join(stagingPath, STORAGE_SCHEMA_FILE), `${JSON.stringify(snapshot.schemaManifest, null, 2)}\n`);
|
|
159
|
+
if (snapshot.state !== null) {
|
|
160
|
+
writeTextFileAtomically(join(stagingPath, STORAGE_STATE_FILE), `${JSON.stringify(snapshot.state, null, 2)}\n`);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
rebuildDerivedState(effects) {
|
|
164
|
+
// Yui's authoritative data is fully contained in state.json; id counters
|
|
165
|
+
// and the reference graph are validated by parseState in the next step.
|
|
166
|
+
// There is no separate on-disk derived index to rebuild, so this echoes
|
|
167
|
+
// the effects a step declared (none for the current production step). New
|
|
168
|
+
// record families that introduce out-of-band derived state extend here.
|
|
169
|
+
return { rebuiltEffects: [...effects] };
|
|
170
|
+
},
|
|
171
|
+
validateCurrentState() {
|
|
172
|
+
// The real post-migration loader gate: constructing the store runs the
|
|
173
|
+
// strict schema check, and a single read forces parseState across every
|
|
174
|
+
// record family plus the full reference graph. Any failure throws.
|
|
175
|
+
const store = new FileTaskStore(stagingPath);
|
|
176
|
+
store.getConfig();
|
|
177
|
+
const tasks = store.listTasks();
|
|
178
|
+
store.listProjects();
|
|
179
|
+
store.listConfiguredAgents();
|
|
180
|
+
store.listWorkMailboxes();
|
|
181
|
+
return {
|
|
182
|
+
checks: [
|
|
183
|
+
{
|
|
184
|
+
name: "FileTaskStore loader",
|
|
185
|
+
outcome: "passed",
|
|
186
|
+
detail: `parsed state, reference graph, and ${tasks.length} task(s)`
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
atomicSwitchWithBackup() {
|
|
192
|
+
if (!existsSync(stagingPath)) {
|
|
193
|
+
throw new Error(`No staged output to promote: ${stagingPath}.`);
|
|
194
|
+
}
|
|
195
|
+
const stamp = now().toISOString().replace(/[:.]/g, "-");
|
|
196
|
+
const backupPath = join(dirname(home), `${basename(home)}.backup-${stamp}`);
|
|
197
|
+
if (existsSync(backupPath)) {
|
|
198
|
+
throw new Error(`Refusing to overwrite an existing backup: ${backupPath}.`);
|
|
199
|
+
}
|
|
200
|
+
// The switch is two atomic renames with one non-atomic window between them.
|
|
201
|
+
// A durable sibling marker records the phase (backing-up / promoting /
|
|
202
|
+
// interrupted) so a reader — or `yui update`'s crash recovery — can tell how
|
|
203
|
+
// far the switch got even if this process dies mid-way (P1-4 / F2 / F3).
|
|
204
|
+
//
|
|
205
|
+
// The invariant that drives error handling: BEFORE step 1's rename commits,
|
|
206
|
+
// the Home is byte-for-byte intact and any failure is a clean pre-switch
|
|
207
|
+
// failure (plain throw -> engine `failed` -> "source unchanged", which is
|
|
208
|
+
// TRUE). AFTER step 1 commits, the original no longer lives at `home`, so
|
|
209
|
+
// EVERY subsequent operation — the post-rename fsync, the `promoting` marker
|
|
210
|
+
// write, the promote rename, and the post-promote fsync/marker-clear — is
|
|
211
|
+
// handled phase-aware: on any error we attempt rollback, and if rollback
|
|
212
|
+
// fails we persist `interrupted` and raise AmbiguousSwitchError. A
|
|
213
|
+
// post-switch exception is NEVER allowed to escape as a plain error that the
|
|
214
|
+
// engine would render as "source unchanged" (F2).
|
|
215
|
+
// Phase 0 (Home intact): write the backing-up marker, then move aside.
|
|
216
|
+
// Any failure here leaves the Home untouched — clear the marker, plain throw.
|
|
217
|
+
try {
|
|
218
|
+
writeSwitchProgress(home, {
|
|
219
|
+
phase: "backing-up",
|
|
220
|
+
homePath: home,
|
|
221
|
+
backupPath,
|
|
222
|
+
stagingPath,
|
|
223
|
+
updatedAt: now().toISOString()
|
|
224
|
+
});
|
|
225
|
+
renameSync(home, backupPath);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// Nothing committed (or the marker write failed before the rename): the
|
|
229
|
+
// Home is intact. Best-effort clear the marker and surface a clean error.
|
|
230
|
+
try {
|
|
231
|
+
clearSwitchProgress(home);
|
|
232
|
+
}
|
|
233
|
+
catch { /* best effort */ }
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
// ---- From here the original lives at `backupPath`, NOT at `home`. ----
|
|
237
|
+
// Every step is guarded: on failure, roll back to restore the original; if
|
|
238
|
+
// the rollback fails, the switch is genuinely interrupted (ambiguous).
|
|
239
|
+
try {
|
|
240
|
+
faultHook("post-backup-fsync");
|
|
241
|
+
fsyncDirectory(dirname(home));
|
|
242
|
+
faultHook("promoting-marker");
|
|
243
|
+
writeSwitchProgress(home, {
|
|
244
|
+
phase: "promoting",
|
|
245
|
+
homePath: home,
|
|
246
|
+
backupPath,
|
|
247
|
+
stagingPath,
|
|
248
|
+
updatedAt: now().toISOString()
|
|
249
|
+
});
|
|
250
|
+
// Step 2: promote the staged output into the Home path.
|
|
251
|
+
promoteRename(stagingPath, home);
|
|
252
|
+
// Post-promotion durability. If these throw, the new Home is ALREADY in
|
|
253
|
+
// place and correct; fsync/marker-clear are best-effort durability, not
|
|
254
|
+
// correctness, so a failure here must NOT fail the switch or trigger a
|
|
255
|
+
// rollback (that would destroy a good migrated Home). Swallow them.
|
|
256
|
+
try {
|
|
257
|
+
faultHook("post-promote-fsync");
|
|
258
|
+
fsyncDirectory(dirname(home));
|
|
259
|
+
}
|
|
260
|
+
catch { /* durability best effort */ }
|
|
261
|
+
try {
|
|
262
|
+
faultHook("post-promote-clear");
|
|
263
|
+
clearSwitchProgress(home);
|
|
264
|
+
}
|
|
265
|
+
catch { /* marker cleared lazily later */ }
|
|
266
|
+
return {
|
|
267
|
+
status: "switched",
|
|
268
|
+
backupPath,
|
|
269
|
+
detail: `Original Home backed up at ${backupPath}.`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (switchError) {
|
|
273
|
+
// A failure BEFORE promotion committed (post-rename fsync, `promoting`
|
|
274
|
+
// marker write, or the promote rename itself). The original is at the
|
|
275
|
+
// backup and `home` is missing. Attempt rollback to restore it — the
|
|
276
|
+
// `return` above is the only path once promotion has committed, so here
|
|
277
|
+
// promotion has NOT committed and rollback is always the right recovery.
|
|
278
|
+
return rollbackOrInterrupt(switchError);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Restore the original from the backup after a pre-promotion failure. On
|
|
282
|
+
* success the Home is intact again and we re-throw the original error as a
|
|
283
|
+
* clean pre-switch failure (engine `failed`, truthfully "source unchanged").
|
|
284
|
+
* If the rollback itself fails, persist `interrupted` and raise
|
|
285
|
+
* AmbiguousSwitchError with the exact manual recovery.
|
|
286
|
+
*/
|
|
287
|
+
function rollbackOrInterrupt(switchError) {
|
|
288
|
+
try {
|
|
289
|
+
faultHook("rollback-rename");
|
|
290
|
+
promoteRename(backupPath, home);
|
|
291
|
+
try {
|
|
292
|
+
fsyncDirectory(dirname(home));
|
|
293
|
+
}
|
|
294
|
+
catch { /* durability best effort */ }
|
|
295
|
+
try {
|
|
296
|
+
clearSwitchProgress(home);
|
|
297
|
+
}
|
|
298
|
+
catch { /* best effort */ }
|
|
299
|
+
}
|
|
300
|
+
catch (rollbackError) {
|
|
301
|
+
try {
|
|
302
|
+
writeSwitchProgress(home, {
|
|
303
|
+
phase: "interrupted",
|
|
304
|
+
homePath: home,
|
|
305
|
+
backupPath,
|
|
306
|
+
stagingPath,
|
|
307
|
+
updatedAt: now().toISOString()
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch { /* marker best effort; fs evidence + backup still recover it */ }
|
|
311
|
+
throw new AmbiguousSwitchError({
|
|
312
|
+
homePath: home,
|
|
313
|
+
backupPath,
|
|
314
|
+
stagingPath,
|
|
315
|
+
detail: `Storage switch is in an AMBIGUOUS, partially-applied state: the original Home was `
|
|
316
|
+
+ `moved to ${backupPath} but the switch failed (${messageOf(switchError)}), and the `
|
|
317
|
+
+ `automatic rollback also failed (${messageOf(rollbackError)}). The Home path ${home} `
|
|
318
|
+
+ `may be missing. Recover manually by restoring the backup: mv "${backupPath}" "${home}".`
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Rollback succeeded: the original is back in place, Home unchanged. Bubble
|
|
322
|
+
// the original error so the engine reports a failed switch with the source
|
|
323
|
+
// intact (the engine's failure path never claims a switch committed).
|
|
324
|
+
throw switchError instanceof Error
|
|
325
|
+
? switchError
|
|
326
|
+
: new Error(String(switchError));
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
discardFreshOutput() {
|
|
330
|
+
rmSync(stagingPath, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Read a source Home's full three-axis {@link StorageVersionState} read-only.
|
|
336
|
+
*
|
|
337
|
+
* All three axes come from the durable `schema.json` contract. Raw `state.json`
|
|
338
|
+
* is traversed structurally only to cross-check non-empty families against the
|
|
339
|
+
* declared record versions (never through strict `parseState`, which would
|
|
340
|
+
* conflate an older record family with corruption). A family missing from the
|
|
341
|
+
* manifest is explicit version 0, so the planner must resolve its introduction.
|
|
342
|
+
*
|
|
343
|
+
* Returns `corruption` when the manifest is invalid, storage is uninitialized, or
|
|
344
|
+
* `state.json` is structurally damaged; callers map that to a hard error (engine)
|
|
345
|
+
* or a `CORRUPTED` verdict (classifier).
|
|
346
|
+
*/
|
|
347
|
+
export function inspectSourceVersionState(home, latest) {
|
|
348
|
+
const schema = inspectStorageSchema(home);
|
|
349
|
+
if (schema.status === "uninitialized") {
|
|
350
|
+
return {
|
|
351
|
+
corruption: {
|
|
352
|
+
corrupted: true,
|
|
353
|
+
detail: "Yui storage is not initialized. Run `yui setup`."
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (schema.status === "invalid") {
|
|
358
|
+
return {
|
|
359
|
+
corruption: {
|
|
360
|
+
corrupted: true,
|
|
361
|
+
detail: `Storage schema manifest is invalid: ${schema.detail}`
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// schema.status is "current" or "unsupported": the manifest is structurally
|
|
366
|
+
// valid and supplies the authoritative persisted record-family versions.
|
|
367
|
+
const manifest = readStorageSchemaManifest(home);
|
|
368
|
+
const scan = scanSourceRecordVersions(home, latest.record);
|
|
369
|
+
if ("corruption" in scan) {
|
|
370
|
+
return { corruption: scan.corruption };
|
|
371
|
+
}
|
|
372
|
+
return combineManifestAndState(manifest, scan, latest);
|
|
373
|
+
}
|
|
374
|
+
/** Inspect an in-memory compatible/offline snapshot through the same contract. */
|
|
375
|
+
export function inspectSnapshotVersionState(snapshot, latest) {
|
|
376
|
+
let manifest;
|
|
377
|
+
try {
|
|
378
|
+
manifest = parseStorageSchemaManifest(snapshot.schemaManifest);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return {
|
|
382
|
+
corruption: {
|
|
383
|
+
corrupted: true,
|
|
384
|
+
detail: `Storage schema manifest is invalid: ${messageOf(error)}`
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const scan = snapshot.state === null
|
|
389
|
+
? {
|
|
390
|
+
record: Object.fromEntries(Object.entries(latest.record).map(([kind, entry]) => [kind, { ...entry }])),
|
|
391
|
+
counts: Object.fromEntries(Object.keys(latest.record).map((kind) => [kind, 0]))
|
|
392
|
+
}
|
|
393
|
+
: scanRecordVersionsFromState(snapshot.state, latest.record);
|
|
394
|
+
if ("corruption" in scan)
|
|
395
|
+
return { corruption: scan.corruption };
|
|
396
|
+
return combineManifestAndState(manifest, scan, latest);
|
|
397
|
+
}
|
|
398
|
+
function combineManifestAndState(manifest, scan, latest) {
|
|
399
|
+
const record = {};
|
|
400
|
+
if (manifest.recordVersions === undefined) {
|
|
401
|
+
for (const [kind, entry] of Object.entries(latest.record)) {
|
|
402
|
+
record[kind] = { version: 0, path: entry.path };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
for (const [kind, latestEntry] of Object.entries(latest.record)) {
|
|
407
|
+
const persisted = manifest.recordVersions[kind];
|
|
408
|
+
const count = scan.counts[kind] ?? 0;
|
|
409
|
+
const scanned = scan.record[kind];
|
|
410
|
+
if (persisted === undefined) {
|
|
411
|
+
if (count > 0) {
|
|
412
|
+
return {
|
|
413
|
+
corruption: {
|
|
414
|
+
corrupted: true,
|
|
415
|
+
detail: `Manifest omits record family '${kind}' but state.json contains ${count} record(s).`
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
record[kind] = { version: 0, path: latestEntry.path };
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (count > 0 && scanned?.version !== persisted) {
|
|
423
|
+
return {
|
|
424
|
+
corruption: {
|
|
425
|
+
corrupted: true,
|
|
426
|
+
detail: `Manifest declares record family '${kind}' at version ${persisted}, `
|
|
427
|
+
+ `but state.json contains version ${String(scanned?.version)}.`
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
record[kind] = { version: persisted, path: latestEntry.path };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
source: {
|
|
436
|
+
layout: manifest.storageVersion,
|
|
437
|
+
aggregate: manifest.aggregateSchemaVersion,
|
|
438
|
+
record: Object.freeze(record)
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Inspect the `.state.lock` holder, fail-closed.
|
|
444
|
+
*
|
|
445
|
+
* Returns `null` ONLY when the lock directory is provably absent (no writer).
|
|
446
|
+
* When the directory exists, the owner file is read: a clearly-live foreign pid
|
|
447
|
+
* is `live`; a dead owner is reclaimable (`null`); but a missing, empty,
|
|
448
|
+
* non-integer, or unreadable owner is `{ state: "unknown" }` — the lock is
|
|
449
|
+
* acquired mkdir-first with the owner written a moment later, so an
|
|
450
|
+
* undeterminable owner may be a writer mid-acquisition or a live process, and we
|
|
451
|
+
* must not treat it as absent.
|
|
452
|
+
*/
|
|
453
|
+
function inspectForeignWriteLock(home, callerPid) {
|
|
454
|
+
const lockDir = join(home, STATE_LOCK_DIRECTORY);
|
|
455
|
+
if (!existsSync(lockDir))
|
|
456
|
+
return null; // provably no lock: no writer.
|
|
457
|
+
let raw;
|
|
458
|
+
try {
|
|
459
|
+
raw = readFileSync(join(lockDir, "owner"), "utf8");
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
// Lock dir exists but the owner file is missing/unreadable: a writer may be
|
|
463
|
+
// mid-acquisition (mkdir done, owner not yet written). Fail closed.
|
|
464
|
+
if (isEnoent(error))
|
|
465
|
+
return { state: "unknown" };
|
|
466
|
+
return { state: "unknown" };
|
|
467
|
+
}
|
|
468
|
+
const trimmed = raw.trim();
|
|
469
|
+
if (trimmed.length === 0)
|
|
470
|
+
return { state: "unknown" }; // empty/partial write.
|
|
471
|
+
const ownerPid = Number.parseInt(trimmed, 10);
|
|
472
|
+
if (!Number.isInteger(ownerPid) || String(ownerPid) !== trimmed) {
|
|
473
|
+
// Non-integer or malformed owner: undeterminable. Fail closed.
|
|
474
|
+
return { state: "unknown" };
|
|
475
|
+
}
|
|
476
|
+
// Our own fence-exempt process re-pins under this lock; not a foreign writer.
|
|
477
|
+
if (ownerPid === callerPid)
|
|
478
|
+
return null;
|
|
479
|
+
// Only a provably-dead owner is reclaimable (no writer); a live one is active.
|
|
480
|
+
return processIsAlive(ownerPid) ? { state: "live", ownerPid } : null;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Inspect the per-home Controller discovery, fail-closed.
|
|
484
|
+
*
|
|
485
|
+
* Returns `null` ONLY when there is provably no discovery file. A file naming a
|
|
486
|
+
* live pid is `live`; a file naming a dead pid is `null` (no live Controller);
|
|
487
|
+
* but a present-but-malformed/unparseable file is `{ state: "unknown" }` — we
|
|
488
|
+
* cannot rule out a live Controller from unreadable discovery, so it fails
|
|
489
|
+
* closed rather than being treated as "no controller".
|
|
490
|
+
*/
|
|
491
|
+
function inspectLiveController(home) {
|
|
492
|
+
const discoveryPath = join(home, CONTROLLER_DISCOVERY_FILE);
|
|
493
|
+
let raw;
|
|
494
|
+
try {
|
|
495
|
+
raw = readFileSync(discoveryPath, "utf8");
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (isEnoent(error))
|
|
499
|
+
return null; // provably no discovery file.
|
|
500
|
+
return { state: "unknown" }; // present but unreadable: fail closed.
|
|
501
|
+
}
|
|
502
|
+
let value;
|
|
503
|
+
try {
|
|
504
|
+
value = JSON.parse(raw);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return { state: "unknown" }; // malformed JSON: cannot rule out a controller.
|
|
508
|
+
}
|
|
509
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
510
|
+
return { state: "unknown" };
|
|
511
|
+
}
|
|
512
|
+
const pid = value.pid;
|
|
513
|
+
if (!Number.isInteger(pid))
|
|
514
|
+
return { state: "unknown" }; // no usable pid.
|
|
515
|
+
// A named pid that is dead means no live Controller; alive means active.
|
|
516
|
+
return processIsAlive(pid) ? { state: "live", pid: pid } : null;
|
|
517
|
+
}
|
|
518
|
+
function parseJsonObject(raw, label) {
|
|
519
|
+
const value = JSON.parse(raw);
|
|
520
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
521
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
function fsyncDirectory(directory) {
|
|
526
|
+
const descriptor = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
527
|
+
try {
|
|
528
|
+
fsyncSync(descriptor);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
closeSync(descriptor);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function processIsAlive(pid) {
|
|
535
|
+
try {
|
|
536
|
+
process.kill(pid, 0);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function isEnoent(error) {
|
|
544
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
545
|
+
}
|
|
546
|
+
function messageOf(error) {
|
|
547
|
+
return error instanceof Error ? error.message : String(error);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* True when `candidate` is the same path as `base` or nested anywhere inside it.
|
|
551
|
+
* Uses resolved absolute paths and a relative-path check so it is robust to
|
|
552
|
+
* `.`/`..`/trailing-slash forms and never treats a sibling that merely shares a
|
|
553
|
+
* name prefix (e.g. `home.upgrade-staging` vs `home`) as "inside" (F1).
|
|
554
|
+
*/
|
|
555
|
+
function isPathInside(base, candidate) {
|
|
556
|
+
const rel = relative(resolve(base), resolve(candidate));
|
|
557
|
+
if (rel === "")
|
|
558
|
+
return true; // identical path.
|
|
559
|
+
// Inside iff the relative path does not escape upward and is not absolute.
|
|
560
|
+
return !rel.startsWith("..") && !isAbsolutePath(rel);
|
|
561
|
+
}
|
|
562
|
+
function isAbsolutePath(p) {
|
|
563
|
+
// resolve() yields platform-absolute paths; a relative() result that is
|
|
564
|
+
// absolute means the two paths share no common base (different roots).
|
|
565
|
+
return resolve(p) === p;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Copy every top-level entry of `sourceHome` into `destDir`, except the names in
|
|
569
|
+
* `exclude`, preserving nested directories, files, symlinks, timestamps, and
|
|
570
|
+
* modes. This implements the complete Home content preservation contract (P1-1):
|
|
571
|
+
* the migrated schema.json/state.json are written by the caller afterward, and
|
|
572
|
+
* everything else (runtime/inbox authoritative events, runtime/ discovery,
|
|
573
|
+
* cache/, artifacts/) is carried verbatim so the atomic switch never drops it.
|
|
574
|
+
*
|
|
575
|
+
* Symlinks are copied as links (not dereferenced), so an in-Home symlink keeps
|
|
576
|
+
* its target rather than duplicating content or escaping the Home. Copy failures
|
|
577
|
+
* propagate: the engine aborts before the switch, leaving the source untouched.
|
|
578
|
+
*/
|
|
579
|
+
function copyHomeContentsExcept(sourceHome, destDir, exclude) {
|
|
580
|
+
for (const entry of readdirSync(sourceHome, { withFileTypes: true })) {
|
|
581
|
+
if (exclude.has(entry.name))
|
|
582
|
+
continue;
|
|
583
|
+
cpSync(join(sourceHome, entry.name), join(destDir, entry.name), {
|
|
584
|
+
recursive: true,
|
|
585
|
+
// Copy symlinks as links rather than dereferencing them.
|
|
586
|
+
dereference: false,
|
|
587
|
+
// Preserve timestamps/mode so authoritative/rebuildable content is intact.
|
|
588
|
+
preserveTimestamps: true,
|
|
589
|
+
// The staging dir is fresh, so nothing should already exist; force keeps
|
|
590
|
+
// the copy robust if a partial retry left a remnant.
|
|
591
|
+
force: true,
|
|
592
|
+
errorOnExist: false
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|