@agentxm/workspace-operations 0.28.11 → 0.28.13
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/dist/src/index.d.ts +11 -10
- package/dist/src/index.js +16 -12
- package/dist/src/operations/scan-plan-readiness.d.ts +1 -1
- package/dist/src/operations/scan-plan-readiness.js +1 -1
- package/dist/src/plan/apply-plan.js +8 -8
- package/dist/src/plan/execution-candidate.d.ts +7 -1
- package/dist/src/plan/execution-candidate.js +9 -2
- package/dist/src/plan/interruption-resolution.d.ts +50 -0
- package/dist/src/plan/interruption-resolution.js +158 -0
- package/dist/src/plan/operation-journal.d.ts +9 -9
- package/dist/src/plan/operation-journal.js +5 -20
- package/dist/src/plan/operation-resolution.d.ts +1 -1
- package/dist/src/plan/plan-execution-fixtures.d.ts +2 -2
- package/dist/src/plan/plan-execution-fixtures.js +2 -2
- package/dist/src/plan/plan-execution.d.ts +48 -0
- package/dist/src/plan/plan-execution.js +40 -0
- package/dist/src/plan/plan.d.ts +9 -19
- package/dist/src/plan/resolve-plan-interaction.d.ts +1 -1
- package/dist/src/plan/resolve-plan-interaction.js +1 -1
- package/dist/src/plan/resolve-plan.d.ts +38 -23
- package/dist/src/plan/resolve-plan.js +114 -96
- package/dist/src/plan/step-failure-conversions.d.ts +2 -1
- package/dist/src/testing.d.ts +22 -0
- package/dist/src/testing.js +23 -0
- package/package.json +12 -16
- package/dist/src/live.d.ts +0 -13
- package/dist/src/live.js +0 -12
- package/dist/src/operations/load-workspace.d.ts +0 -43
- package/dist/src/operations/load-workspace.js +0 -77
- package/dist/src/operations/transaction.d.ts +0 -58
- package/dist/src/operations/transaction.js +0 -359
- package/dist/src/operations/transition-lock.d.ts +0 -66
- package/dist/src/operations/transition-lock.js +0 -294
package/dist/src/testing.js
CHANGED
|
@@ -7,6 +7,29 @@
|
|
|
7
7
|
* @experimental This API is unstable and may change without notice.
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
|
+
import * as Layer from "effect/Layer";
|
|
11
|
+
import { ConfiguredAgentOutcomesProvider } from "@agentxm/workspace-state";
|
|
12
|
+
import { ConfiguredAgentOutcomesProviderTest } from "@agentxm/workspace-state/testing";
|
|
13
|
+
import { FootprintRecorder, makeFootprintRecorder } from "@agentxm/workspace-transactions";
|
|
14
|
+
import { OperationJournal, makeOperationJournal } from "./plan/operation-journal.js";
|
|
15
|
+
/**
|
|
16
|
+
* An empty journal for one test invocation, standing in for the
|
|
17
|
+
* per-invocation journal the CLI's operation lifecycle creates around every
|
|
18
|
+
* command. Provide it wherever a test drives a plan directly instead of
|
|
19
|
+
* through that lifecycle. A test that asserts on what was recorded should
|
|
20
|
+
* build the service itself with `makeOperationJournal` and keep the ref.
|
|
21
|
+
*/
|
|
22
|
+
export const OperationJournalTest = Layer.effect(OperationJournal, makeOperationJournal);
|
|
23
|
+
/** A fresh footprint recorder for one test invocation. */
|
|
24
|
+
export const FootprintRecorderTest = Layer.effect(FootprintRecorder, makeFootprintRecorder);
|
|
25
|
+
/**
|
|
26
|
+
* Every per-invocation service `resolveExecutionCandidate` acquires that the CLI's
|
|
27
|
+
* operation lifecycle opens around a command: the journal, the footprint
|
|
28
|
+
* recorder, and a configured-agent outcome provider with no per-type
|
|
29
|
+
* refinement. A test that drives a plan directly needs all of them, so this
|
|
30
|
+
* is the layer to reach for rather than assembling the three by hand.
|
|
31
|
+
*/
|
|
32
|
+
export const PlanInvocationTest = Layer.mergeAll(OperationJournalTest, FootprintRecorderTest, ConfiguredAgentOutcomesProviderTest);
|
|
10
33
|
export { ResolvePlanInteractionTest, } from "./plan/resolve-plan-interaction.js";
|
|
11
34
|
export { interactiveOnlyPlanExecution, preapprovedPlanExecution, promptablePlanExecution, } from "./plan/plan-execution-fixtures.js";
|
|
12
35
|
//# sourceMappingURL=testing.js.map
|
package/package.json
CHANGED
|
@@ -4,17 +4,18 @@
|
|
|
4
4
|
"url": "https://github.com/agentxm/axm/issues"
|
|
5
5
|
},
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@agentxm/extension-model": "^0.28.
|
|
8
|
-
"@agentxm/
|
|
9
|
-
"@agentxm/
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
"@agentxm/extension-model": "^0.28.13",
|
|
8
|
+
"@agentxm/extension-resolution": "^0.28.13",
|
|
9
|
+
"@agentxm/registry-protocol": "^0.28.13",
|
|
10
|
+
"@agentxm/workspace-state": "^0.28.13",
|
|
11
|
+
"@agentxm/workspace-transactions": "^0.28.13",
|
|
12
|
+
"effect": "4.0.0-rc.112"
|
|
13
|
+
},
|
|
14
|
+
"description": "AXM workspace operations: plans, execution candidates, closure execution, and operation resolutions for the axm CLI. Unstable and unsupported — use the axm.sh CLI.",
|
|
14
15
|
"devDependencies": {
|
|
16
|
+
"@agentxm/specification-metadata": "^0.28.13",
|
|
15
17
|
"@effect/platform-node": "4.0.0-rc.112",
|
|
16
18
|
"@effect/vitest": "4.0.0-rc.112",
|
|
17
|
-
"@types/proper-lockfile": "^4.1.4",
|
|
18
19
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
19
20
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
20
21
|
"vitest": "^4.1.10",
|
|
@@ -29,11 +30,6 @@
|
|
|
29
30
|
"default": "./dist/src/index.js",
|
|
30
31
|
"types": "./dist/src/index.d.ts"
|
|
31
32
|
},
|
|
32
|
-
"./live": {
|
|
33
|
-
"axm-source": "./src/live.ts",
|
|
34
|
-
"default": "./dist/src/live.js",
|
|
35
|
-
"types": "./dist/src/live.d.ts"
|
|
36
|
-
},
|
|
37
33
|
"./testing": {
|
|
38
34
|
"axm-source": "./src/testing.ts",
|
|
39
35
|
"default": "./dist/src/testing.js",
|
|
@@ -54,11 +50,11 @@
|
|
|
54
50
|
"access": "public"
|
|
55
51
|
},
|
|
56
52
|
"repository": {
|
|
57
|
-
"directory": "packages/workspace-operations",
|
|
53
|
+
"directory": "packages/core/workspace-operations",
|
|
58
54
|
"type": "git",
|
|
59
55
|
"url": "https://github.com/agentxm/axm.git"
|
|
60
56
|
},
|
|
61
57
|
"sideEffects": false,
|
|
62
58
|
"type": "module",
|
|
63
|
-
"version": "0.28.
|
|
64
|
-
}
|
|
59
|
+
"version": "0.28.13"
|
|
60
|
+
}
|
package/dist/src/live.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @agentxm/workspace-operations environment-backed composition.
|
|
3
|
-
*
|
|
4
|
-
* The composed workspace layer every entry point provides. Only application
|
|
5
|
-
* composition roots import this module; feature logic keeps
|
|
6
|
-
* `WorkspaceMutations` in its Effect environment.
|
|
7
|
-
*
|
|
8
|
-
* @experimental This API is unstable and may change without notice.
|
|
9
|
-
* @packageDocumentation
|
|
10
|
-
*/
|
|
11
|
-
export { layer, loadWorkspace, makeWorkspaceTransactionCapabilities, } from "./operations/load-workspace.js";
|
|
12
|
-
export type { WorkspaceLayerOptions } from "@agentxm/workspace-state";
|
|
13
|
-
//# sourceMappingURL=live.d.ts.map
|
package/dist/src/live.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @agentxm/workspace-operations environment-backed composition.
|
|
3
|
-
*
|
|
4
|
-
* The composed workspace layer every entry point provides. Only application
|
|
5
|
-
* composition roots import this module; feature logic keeps
|
|
6
|
-
* `WorkspaceMutations` in its Effect environment.
|
|
7
|
-
*
|
|
8
|
-
* @experimental This API is unstable and may change without notice.
|
|
9
|
-
* @packageDocumentation
|
|
10
|
-
*/
|
|
11
|
-
export { layer, loadWorkspace, makeWorkspaceTransactionCapabilities, } from "./operations/load-workspace.js";
|
|
12
|
-
//# sourceMappingURL=live.js.map
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Live workspace composition: the one seam that needs both the workspace
|
|
3
|
-
* state facade and the operations-side transaction machinery. Supplies the
|
|
4
|
-
* transaction capabilities to `makeWorkspaceMutations` and publishes the
|
|
5
|
-
* `loadWorkspace` effect and `layer` every entry point composes.
|
|
6
|
-
*
|
|
7
|
-
* @experimental This API is unstable and may change without notice.
|
|
8
|
-
*/
|
|
9
|
-
import * as Effect from "effect/Effect";
|
|
10
|
-
import * as FileSystem from "effect/FileSystem";
|
|
11
|
-
import * as Layer from "effect/Layer";
|
|
12
|
-
import * as Path from "effect/Path";
|
|
13
|
-
import { WorkspaceMutations, type MakeWorkspaceTransactionCapabilities, type WorkspaceLayerOptions, type WorkspaceMutationsError, type WorkspaceMutationsService } from "@agentxm/workspace-state";
|
|
14
|
-
/**
|
|
15
|
-
* The live transaction capabilities: the runner claims the shared settings
|
|
16
|
-
* and lockfile targets by default, and both members eliminate FileSystem and
|
|
17
|
-
* Path so the facade's methods stay `R = never` for callers.
|
|
18
|
-
*/
|
|
19
|
-
export declare const makeWorkspaceTransactionCapabilities: MakeWorkspaceTransactionCapabilities;
|
|
20
|
-
/**
|
|
21
|
-
* Create workspace mutations effect.
|
|
22
|
-
*
|
|
23
|
-
* Loads an existing workspace from disk.
|
|
24
|
-
*
|
|
25
|
-
* The workspace must already be initialized. Missing or invalid settings and
|
|
26
|
-
* invalid or unsupported lockfiles fail fast with a typed workspace error.
|
|
27
|
-
*
|
|
28
|
-
* @param options - WorkspaceMutations layer options
|
|
29
|
-
* @returns Effect yielding WorkspaceMutationsService
|
|
30
|
-
*/
|
|
31
|
-
export declare const loadWorkspace: (options: WorkspaceLayerOptions) => Effect.Effect<WorkspaceMutationsService, WorkspaceMutationsError, FileSystem.FileSystem | Path.Path>;
|
|
32
|
-
/**
|
|
33
|
-
* Create a layer that loads workspace read model from disk.
|
|
34
|
-
*
|
|
35
|
-
* The workspace must already be initialized.
|
|
36
|
-
*
|
|
37
|
-
* @param options - WorkspaceMutations layer options
|
|
38
|
-
* @returns Layer providing WorkspaceMutations
|
|
39
|
-
*
|
|
40
|
-
* @experimental This API is unstable and may change without notice.
|
|
41
|
-
*/
|
|
42
|
-
export declare const layer: (options: WorkspaceLayerOptions) => Layer.Layer<WorkspaceMutations, WorkspaceMutationsError, Path.Path | FileSystem.FileSystem>;
|
|
43
|
-
//# sourceMappingURL=load-workspace.d.ts.map
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Live workspace composition: the one seam that needs both the workspace
|
|
3
|
-
* state facade and the operations-side transaction machinery. Supplies the
|
|
4
|
-
* transaction capabilities to `makeWorkspaceMutations` and publishes the
|
|
5
|
-
* `loadWorkspace` effect and `layer` every entry point composes.
|
|
6
|
-
*
|
|
7
|
-
* @experimental This API is unstable and may change without notice.
|
|
8
|
-
*/
|
|
9
|
-
import * as Effect from "effect/Effect";
|
|
10
|
-
import * as FileSystem from "effect/FileSystem";
|
|
11
|
-
import * as Layer from "effect/Layer";
|
|
12
|
-
import * as Path from "effect/Path";
|
|
13
|
-
import * as Semaphore from "effect/Semaphore";
|
|
14
|
-
import { WorkspaceMutations, makeWorkspaceMutations, } from "@agentxm/workspace-state";
|
|
15
|
-
import { runWorkspaceTransaction } from "./transaction.js";
|
|
16
|
-
import { acquireWorkspaceTransitionLock } from "./transition-lock.js";
|
|
17
|
-
/**
|
|
18
|
-
* The live transaction capabilities: the runner claims the shared settings
|
|
19
|
-
* and lockfile targets by default, and both members eliminate FileSystem and
|
|
20
|
-
* Path so the facade's methods stay `R = never` for callers.
|
|
21
|
-
*/
|
|
22
|
-
export const makeWorkspaceTransactionCapabilities = ({ workspaceDir, settingsPath, lockPath, }) => Effect.gen(function* () {
|
|
23
|
-
const fs = yield* FileSystem.FileSystem;
|
|
24
|
-
const path = yield* Path.Path;
|
|
25
|
-
// Transaction admission must be distinct from the facade's mutation
|
|
26
|
-
// mutex: a transaction calls the same service's mutation methods while
|
|
27
|
-
// it owns the outer admission permit.
|
|
28
|
-
const transactionSemaphore = yield* Semaphore.make(1);
|
|
29
|
-
const fsLayer = Layer.mergeAll(Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path));
|
|
30
|
-
const runTransaction = (args) => runWorkspaceTransaction({
|
|
31
|
-
workspaceDir,
|
|
32
|
-
semaphore: transactionSemaphore,
|
|
33
|
-
targets: [
|
|
34
|
-
...(args.claimDefaultTargets === false ? [] : [settingsPath, lockPath]),
|
|
35
|
-
...(args.targets ?? []),
|
|
36
|
-
],
|
|
37
|
-
transition: args.transition,
|
|
38
|
-
validate: args.validate,
|
|
39
|
-
...(args.onRestorationStarted === undefined
|
|
40
|
-
? {}
|
|
41
|
-
: { onRestorationStarted: args.onRestorationStarted }),
|
|
42
|
-
}).pipe(Effect.provide(fsLayer));
|
|
43
|
-
const acquireTransition = (request) => acquireWorkspaceTransitionLock({
|
|
44
|
-
workspaceDir,
|
|
45
|
-
holder: {
|
|
46
|
-
command: request.command,
|
|
47
|
-
pid: process.pid,
|
|
48
|
-
...(request.candidateId === undefined ? {} : { candidateId: request.candidateId }),
|
|
49
|
-
},
|
|
50
|
-
...(request.onWaiting === undefined ? {} : { onWaiting: request.onWaiting }),
|
|
51
|
-
}).pipe(Effect.provide(fsLayer));
|
|
52
|
-
return { runTransaction, acquireTransition };
|
|
53
|
-
});
|
|
54
|
-
/**
|
|
55
|
-
* Create workspace mutations effect.
|
|
56
|
-
*
|
|
57
|
-
* Loads an existing workspace from disk.
|
|
58
|
-
*
|
|
59
|
-
* The workspace must already be initialized. Missing or invalid settings and
|
|
60
|
-
* invalid or unsupported lockfiles fail fast with a typed workspace error.
|
|
61
|
-
*
|
|
62
|
-
* @param options - WorkspaceMutations layer options
|
|
63
|
-
* @returns Effect yielding WorkspaceMutationsService
|
|
64
|
-
*/
|
|
65
|
-
export const loadWorkspace = (options) => makeWorkspaceMutations(options, makeWorkspaceTransactionCapabilities);
|
|
66
|
-
/**
|
|
67
|
-
* Create a layer that loads workspace read model from disk.
|
|
68
|
-
*
|
|
69
|
-
* The workspace must already be initialized.
|
|
70
|
-
*
|
|
71
|
-
* @param options - WorkspaceMutations layer options
|
|
72
|
-
* @returns Layer providing WorkspaceMutations
|
|
73
|
-
*
|
|
74
|
-
* @experimental This API is unstable and may change without notice.
|
|
75
|
-
*/
|
|
76
|
-
export const layer = (options) => Layer.effect(WorkspaceMutations, loadWorkspace(options));
|
|
77
|
-
//# sourceMappingURL=load-workspace.js.map
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workspace transaction mechanics: the snapshot/restore/validate/rollback
|
|
3
|
-
* runner and the closure settlement operations, implemented against the
|
|
4
|
-
* ambient authority context declared in `../transaction.ts`.
|
|
5
|
-
*
|
|
6
|
-
* @experimental This API is unstable and may change without notice.
|
|
7
|
-
*/
|
|
8
|
-
import * as Effect from "effect/Effect";
|
|
9
|
-
import * as FileSystem from "effect/FileSystem";
|
|
10
|
-
import * as Path from "effect/Path";
|
|
11
|
-
import * as Semaphore from "effect/Semaphore";
|
|
12
|
-
import { WorkspaceRestorationIncomplete, type WorkspaceTransactionFailure } from "@agentxm/workspace-state";
|
|
13
|
-
/** Run one semantic closure's mutations under its closure identity. */
|
|
14
|
-
export declare const withWorkspaceClosure: (closureId: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
15
|
-
export interface WorkspaceTransactionArgs<A, E, R> {
|
|
16
|
-
readonly workspaceDir: string;
|
|
17
|
-
/** In-process admission owned by the workspace service instance. */
|
|
18
|
-
readonly semaphore: Semaphore.Semaphore;
|
|
19
|
-
/** Authoritative files or directories that the transition may mutate. */
|
|
20
|
-
readonly targets: ReadonlyArray<string>;
|
|
21
|
-
/** Desired, lock, canonical, projection, and native-configuration mutation. */
|
|
22
|
-
readonly transition: Effect.Effect<A, E, R>;
|
|
23
|
-
/** Confirms the complete durable postcondition before the transaction commits. */
|
|
24
|
-
readonly validate: (value: A) => Effect.Effect<void, E, R>;
|
|
25
|
-
/** Observes the start of rollback restoration; never controls it. */
|
|
26
|
-
readonly onRestorationStarted?: Effect.Effect<void>;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Settle one closure: its commits stand, so its snapshots leave the
|
|
30
|
-
* restoration set and a later closure touching the same target takes a fresh
|
|
31
|
-
* post-commit preimage. No-op outside a transaction.
|
|
32
|
-
*/
|
|
33
|
-
export declare const settleWorkspaceClosure: (closureId: string) => Effect.Effect<void>;
|
|
34
|
-
/**
|
|
35
|
-
* Roll back one failed closure: restore and verify exactly its snapshots, in
|
|
36
|
-
* reverse order, leaving every other closure's work in place. A restoration
|
|
37
|
-
* that does not complete and verify records a pending typed failure the
|
|
38
|
-
* transaction surfaces at its end — the truth travels in memory, never
|
|
39
|
-
* through a later workspace write. No-op outside a transaction.
|
|
40
|
-
*/
|
|
41
|
-
export declare const rollbackWorkspaceClosure: (closureId: string) => Effect.Effect<void>;
|
|
42
|
-
/**
|
|
43
|
-
* Run one coupled workspace mutation under the workspace transition lock.
|
|
44
|
-
*
|
|
45
|
-
* Every authoritative target is snapshotted into a uniquely prefixed
|
|
46
|
-
* OS-temporary directory before the transition begins. A failed transition or
|
|
47
|
-
* postcondition check restores and verifies the exact pre-operation paths and
|
|
48
|
-
* removes the snapshots; a restoration that does not complete and verify
|
|
49
|
-
* fails with the typed {@link WorkspaceRestorationIncomplete}, preserving the
|
|
50
|
-
* snapshot directory for manual inspection. Nothing about a failure persists
|
|
51
|
-
* in the workspace: the next mutation plans from the current workspace state.
|
|
52
|
-
*
|
|
53
|
-
* The invocation-level transition hold is reused when a plan-family apply
|
|
54
|
-
* already acquired it; otherwise this transaction acquires its own for the
|
|
55
|
-
* duration of the mutation.
|
|
56
|
-
*/
|
|
57
|
-
export declare const runWorkspaceTransaction: <A, E, R>(args: WorkspaceTransactionArgs<A, E, R>) => Effect.Effect<A, WorkspaceTransactionFailure | WorkspaceRestorationIncomplete | E, R | FileSystem.FileSystem | Path.Path>;
|
|
58
|
-
//# sourceMappingURL=transaction.d.ts.map
|
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workspace transaction mechanics: the snapshot/restore/validate/rollback
|
|
3
|
-
* runner and the closure settlement operations, implemented against the
|
|
4
|
-
* ambient authority context declared in `../transaction.ts`.
|
|
5
|
-
*
|
|
6
|
-
* @experimental This API is unstable and may change without notice.
|
|
7
|
-
*/
|
|
8
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
-
import * as Cause from "effect/Cause";
|
|
10
|
-
import * as Effect from "effect/Effect";
|
|
11
|
-
import * as FileSystem from "effect/FileSystem";
|
|
12
|
-
import * as Option from "effect/Option";
|
|
13
|
-
import * as Path from "effect/Path";
|
|
14
|
-
import * as Semaphore from "effect/Semaphore";
|
|
15
|
-
import { recordFootprint } from "@agentxm/workspace-state";
|
|
16
|
-
import { CurrentWorkspaceClosure, CurrentWorkspaceTransaction, protectInContext, TransitionLockUnavailable, WorkspaceDirectoryError, WorkspaceRestorationError, WorkspaceRestorationIncomplete, WorkspaceTransitionCompromised, } from "@agentxm/workspace-state";
|
|
17
|
-
import { acquireWorkspaceTransitionLock, heldWorkspaceTransition, isWorkspaceTransitionHeldByThisInvocation, } from "./transition-lock.js";
|
|
18
|
-
/** Run one semantic closure's mutations under its closure identity. */
|
|
19
|
-
export const withWorkspaceClosure = (closureId) => (effect) => effect.pipe(Effect.provideService(CurrentWorkspaceClosure, closureId));
|
|
20
|
-
const normalizedTargets = (path, targets) => {
|
|
21
|
-
const sorted = Array.from(new Set(targets.map((target) => path.resolve(target)))).sort((left, right) => left.length - right.length || left.localeCompare(right));
|
|
22
|
-
const retained = [];
|
|
23
|
-
for (const target of sorted) {
|
|
24
|
-
if (retained.some((parent) => target === parent || target.startsWith(`${parent}${path.sep}`))) {
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
retained.push(target);
|
|
28
|
-
}
|
|
29
|
-
return retained;
|
|
30
|
-
};
|
|
31
|
-
const workspaceRelative = (path, workspaceDir, target) => {
|
|
32
|
-
const relative = path.relative(path.dirname(workspaceDir), target);
|
|
33
|
-
return relative.startsWith("..") ? target : relative;
|
|
34
|
-
};
|
|
35
|
-
const sha256 = (input) => createHash("sha256").update(input).digest("hex");
|
|
36
|
-
/**
|
|
37
|
-
* Deterministic content hash of a path's current state: file bytes, symlink
|
|
38
|
-
* target, recursive directory listing, or the literal `absent`.
|
|
39
|
-
*/
|
|
40
|
-
const hashPathState = (fs, path, target) => Effect.gen(function* () {
|
|
41
|
-
const link = yield* fs.readLink(target).pipe(Effect.option);
|
|
42
|
-
if (Option.isSome(link))
|
|
43
|
-
return sha256(`symlink:${link.value}`);
|
|
44
|
-
const exists = yield* fs.exists(target);
|
|
45
|
-
if (!exists)
|
|
46
|
-
return "absent";
|
|
47
|
-
const info = yield* fs.stat(target);
|
|
48
|
-
if (info.type === "Directory") {
|
|
49
|
-
const entries = [...(yield* fs.readDirectory(target))].sort();
|
|
50
|
-
const parts = [];
|
|
51
|
-
for (const entry of entries) {
|
|
52
|
-
const child = yield* hashPathState(fs, path, path.join(target, entry));
|
|
53
|
-
parts.push(`${entry}:${child}`);
|
|
54
|
-
}
|
|
55
|
-
return sha256(`dir:${parts.join("\n")}`);
|
|
56
|
-
}
|
|
57
|
-
const bytes = yield* fs.readFile(target);
|
|
58
|
-
return sha256(bytes);
|
|
59
|
-
}).pipe(Effect.catch(() => Effect.succeed("unhashable")));
|
|
60
|
-
const dropClosureSnapshots = (context, closureId) => {
|
|
61
|
-
let index = context.snapshots.length;
|
|
62
|
-
while (index > 0) {
|
|
63
|
-
index -= 1;
|
|
64
|
-
if (context.snapshots[index]?.closure === closureId) {
|
|
65
|
-
context.snapshots.splice(index, 1);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
context.protectedTargets.delete(closureId);
|
|
69
|
-
};
|
|
70
|
-
/**
|
|
71
|
-
* Settle one closure: its commits stand, so its snapshots leave the
|
|
72
|
-
* restoration set and a later closure touching the same target takes a fresh
|
|
73
|
-
* post-commit preimage. No-op outside a transaction.
|
|
74
|
-
*/
|
|
75
|
-
export const settleWorkspaceClosure = (closureId) => CurrentWorkspaceTransaction.pipe(Effect.flatMap(Option.match({
|
|
76
|
-
onNone: () => Effect.void,
|
|
77
|
-
onSome: (context) => context.snapshotSemaphore.withPermits(1)(Effect.sync(() => {
|
|
78
|
-
dropClosureSnapshots(context, closureId);
|
|
79
|
-
})),
|
|
80
|
-
})));
|
|
81
|
-
/**
|
|
82
|
-
* Roll back one failed closure: restore and verify exactly its snapshots, in
|
|
83
|
-
* reverse order, leaving every other closure's work in place. A restoration
|
|
84
|
-
* that does not complete and verify records a pending typed failure the
|
|
85
|
-
* transaction surfaces at its end — the truth travels in memory, never
|
|
86
|
-
* through a later workspace write. No-op outside a transaction.
|
|
87
|
-
*/
|
|
88
|
-
export const rollbackWorkspaceClosure = (closureId) => CurrentWorkspaceTransaction.pipe(Effect.flatMap(Option.match({
|
|
89
|
-
onNone: () => Effect.void,
|
|
90
|
-
onSome: (context) => context.snapshotSemaphore.withPermits(1)(Effect.gen(function* () {
|
|
91
|
-
const { fs, path } = context;
|
|
92
|
-
const owned = context.snapshots.filter((snapshot) => snapshot.closure === closureId);
|
|
93
|
-
if (owned.length === 0) {
|
|
94
|
-
dropClosureSnapshots(context, closureId);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const held = heldWorkspaceTransition(path.resolve(context.workspaceDir));
|
|
98
|
-
const transitionCompromised = held === undefined ? () => false : held.isCompromised;
|
|
99
|
-
yield* restoreAll(fs, path, owned, transitionCompromised).pipe(Effect.andThen(verifySnapshots(fs, path, owned)), Effect.matchEffect({
|
|
100
|
-
onFailure: (restorationCause) => Effect.sync(() => {
|
|
101
|
-
context.pendingRestorationFailures.push({
|
|
102
|
-
closureId,
|
|
103
|
-
restorationCause,
|
|
104
|
-
retained: owned.map((snapshot) => workspaceRelative(path, context.workspaceDir, snapshot.target)),
|
|
105
|
-
});
|
|
106
|
-
}),
|
|
107
|
-
onSuccess: () => Effect.void,
|
|
108
|
-
}));
|
|
109
|
-
dropClosureSnapshots(context, closureId);
|
|
110
|
-
})),
|
|
111
|
-
})));
|
|
112
|
-
/** Whether anything occupies the path: a file, directory, or (broken) symlink. */
|
|
113
|
-
const pathPresent = (fs, target) => fs.readLink(target).pipe(Effect.map(() => true), Effect.catch(() => fs.exists(target)));
|
|
114
|
-
/**
|
|
115
|
-
* Restore one snapshot through validated staging and atomic publication.
|
|
116
|
-
* The restored content is fully staged and validated in an owned
|
|
117
|
-
* `<target>.tmp.<unique>` sibling before a rename publishes it, so abrupt
|
|
118
|
-
* termination — including a forced process exit — can never expose a
|
|
119
|
-
* partially restored target: the authoritative path holds the failure-time
|
|
120
|
-
* content, the restored content, or (for a directory swap only, between two
|
|
121
|
-
* renames) nothing, never a partial tree. The target path itself is never
|
|
122
|
-
* removed; only owned `.tmp.` siblings are.
|
|
123
|
-
*/
|
|
124
|
-
const restoreSnapshot = (fs, path, snapshot) => Effect.gen(function* () {
|
|
125
|
-
if (snapshot.state === "absent") {
|
|
126
|
-
if (!(yield* pathPresent(fs, snapshot.target)))
|
|
127
|
-
return;
|
|
128
|
-
// Publishing absence is one rename: the mutated tree leaves the
|
|
129
|
-
// authoritative path atomically, then the owned trash is removed.
|
|
130
|
-
const trash = `${snapshot.target}.tmp.${randomBytes(6).toString("hex")}`;
|
|
131
|
-
yield* fs.rename(snapshot.target, trash);
|
|
132
|
-
yield* fs.remove(trash, { recursive: true, force: true }).pipe(Effect.ignore);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
yield* fs.makeDirectory(path.dirname(snapshot.target), { recursive: true });
|
|
136
|
-
const staging = `${snapshot.target}.tmp.${randomBytes(6).toString("hex")}`;
|
|
137
|
-
yield* Effect.gen(function* () {
|
|
138
|
-
if (snapshot.state === "symlink") {
|
|
139
|
-
yield* fs.symlink(snapshot.linkTarget, staging);
|
|
140
|
-
const staged = yield* fs.readLink(staging);
|
|
141
|
-
if (staged !== snapshot.linkTarget) {
|
|
142
|
-
return yield* new WorkspaceRestorationError({
|
|
143
|
-
target: snapshot.target,
|
|
144
|
-
step: "stage",
|
|
145
|
-
cause: { staged, expected: snapshot.linkTarget },
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
yield* fs.copy(snapshot.backup, staging, { preserveTimestamps: true });
|
|
151
|
-
const stagedHash = yield* hashPathState(fs, path, staging);
|
|
152
|
-
const backupHash = yield* hashPathState(fs, path, snapshot.backup);
|
|
153
|
-
if (stagedHash !== backupHash || stagedHash === "unhashable") {
|
|
154
|
-
return yield* new WorkspaceRestorationError({
|
|
155
|
-
target: snapshot.target,
|
|
156
|
-
step: "stage",
|
|
157
|
-
cause: { stagedHash, backupHash },
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const targetLink = yield* fs.readLink(snapshot.target).pipe(Effect.option);
|
|
162
|
-
const targetInfo = Option.isSome(targetLink)
|
|
163
|
-
? Option.none()
|
|
164
|
-
: yield* fs.stat(snapshot.target).pipe(Effect.option);
|
|
165
|
-
const targetPresent = Option.isSome(targetLink) || Option.isSome(targetInfo);
|
|
166
|
-
const targetIsDirectory = Option.exists(targetInfo, (info) => info.type === "Directory");
|
|
167
|
-
const stagedIsDirectory = snapshot.state === "copied" && (yield* fs.stat(staging)).type === "Directory";
|
|
168
|
-
if (!targetPresent || (!targetIsDirectory && !stagedIsDirectory)) {
|
|
169
|
-
// rename atomically replaces a file or symlink target.
|
|
170
|
-
yield* fs.rename(staging, snapshot.target);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
// A directory is swapped through two renames of owned names; the
|
|
174
|
-
// moved-aside content is intact in the trash sibling until removal.
|
|
175
|
-
const trash = `${snapshot.target}.tmp.${randomBytes(6).toString("hex")}`;
|
|
176
|
-
yield* fs.rename(snapshot.target, trash);
|
|
177
|
-
yield* fs.rename(staging, snapshot.target);
|
|
178
|
-
yield* fs.remove(trash, { recursive: true, force: true }).pipe(Effect.ignore);
|
|
179
|
-
}).pipe(Effect.onError(() => fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)));
|
|
180
|
-
});
|
|
181
|
-
const restoreAll = (fs, path, snapshots, transitionCompromised) => Effect.forEach([...snapshots].reverse(), (snapshot) => Effect.suspend(() =>
|
|
182
|
-
// Restoration is a durable write like any other: once lock ownership
|
|
183
|
-
// is lost it must stop, or it could overwrite a successor's work.
|
|
184
|
-
transitionCompromised()
|
|
185
|
-
? Effect.fail(new WorkspaceRestorationError({
|
|
186
|
-
target: snapshot.target,
|
|
187
|
-
step: "stopped",
|
|
188
|
-
cause: undefined,
|
|
189
|
-
}))
|
|
190
|
-
: restoreSnapshot(fs, path, snapshot).pipe(Effect.andThen(recordFootprint({ path: snapshot.target, change: "restored" })))), {
|
|
191
|
-
discard: true,
|
|
192
|
-
});
|
|
193
|
-
const verifySnapshots = (fs, path, snapshots) => Effect.forEach(snapshots, (snapshot) => Effect.gen(function* () {
|
|
194
|
-
const verified = yield* Effect.gen(function* () {
|
|
195
|
-
if (snapshot.state === "absent") {
|
|
196
|
-
return !(yield* fs.exists(snapshot.target));
|
|
197
|
-
}
|
|
198
|
-
if (snapshot.state === "symlink") {
|
|
199
|
-
const link = yield* fs.readLink(snapshot.target).pipe(Effect.option);
|
|
200
|
-
return Option.exists(link, (value) => value === snapshot.linkTarget);
|
|
201
|
-
}
|
|
202
|
-
const restored = yield* hashPathState(fs, path, snapshot.target);
|
|
203
|
-
const backup = yield* hashPathState(fs, path, snapshot.backup);
|
|
204
|
-
return restored === backup && restored !== "unhashable";
|
|
205
|
-
}).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
206
|
-
if (!verified) {
|
|
207
|
-
return yield* new WorkspaceRestorationError({
|
|
208
|
-
target: snapshot.target,
|
|
209
|
-
step: "verify",
|
|
210
|
-
cause: { state: snapshot.state },
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
}), { discard: true });
|
|
214
|
-
/**
|
|
215
|
-
* Run one coupled workspace mutation under the workspace transition lock.
|
|
216
|
-
*
|
|
217
|
-
* Every authoritative target is snapshotted into a uniquely prefixed
|
|
218
|
-
* OS-temporary directory before the transition begins. A failed transition or
|
|
219
|
-
* postcondition check restores and verifies the exact pre-operation paths and
|
|
220
|
-
* removes the snapshots; a restoration that does not complete and verify
|
|
221
|
-
* fails with the typed {@link WorkspaceRestorationIncomplete}, preserving the
|
|
222
|
-
* snapshot directory for manual inspection. Nothing about a failure persists
|
|
223
|
-
* in the workspace: the next mutation plans from the current workspace state.
|
|
224
|
-
*
|
|
225
|
-
* The invocation-level transition hold is reused when a plan-family apply
|
|
226
|
-
* already acquired it; otherwise this transaction acquires its own for the
|
|
227
|
-
* duration of the mutation.
|
|
228
|
-
*/
|
|
229
|
-
export const runWorkspaceTransaction = (args) => Effect.gen(function* () {
|
|
230
|
-
const current = yield* CurrentWorkspaceTransaction;
|
|
231
|
-
if (Option.isSome(current)) {
|
|
232
|
-
const activeClosure = yield* CurrentWorkspaceClosure;
|
|
233
|
-
yield* Effect.forEach(normalizedTargets(current.value.path, args.targets), (target) => protectInContext(current.value, target, activeClosure), { discard: true });
|
|
234
|
-
const value = yield* args.transition;
|
|
235
|
-
yield* args.validate(value);
|
|
236
|
-
return value;
|
|
237
|
-
}
|
|
238
|
-
const fs = yield* FileSystem.FileSystem;
|
|
239
|
-
const path = yield* Path.Path;
|
|
240
|
-
const workspaceDir = path.resolve(args.workspaceDir);
|
|
241
|
-
const missingWorkspaceAncestors = [];
|
|
242
|
-
let ancestor = workspaceDir;
|
|
243
|
-
while (true) {
|
|
244
|
-
const exists = yield* fs
|
|
245
|
-
.exists(ancestor)
|
|
246
|
-
.pipe(Effect.mapError((cause) => new WorkspaceDirectoryError({ path: ancestor, step: "inspect", cause })));
|
|
247
|
-
if (exists)
|
|
248
|
-
break;
|
|
249
|
-
missingWorkspaceAncestors.push(ancestor);
|
|
250
|
-
const parent = path.dirname(ancestor);
|
|
251
|
-
if (parent === ancestor)
|
|
252
|
-
break;
|
|
253
|
-
ancestor = parent;
|
|
254
|
-
}
|
|
255
|
-
return yield* args.semaphore.withPermits(1)(Effect.gen(function* () {
|
|
256
|
-
yield* fs
|
|
257
|
-
.makeDirectory(workspaceDir, { recursive: true })
|
|
258
|
-
.pipe(Effect.mapError((cause) => new WorkspaceDirectoryError({ path: workspaceDir, step: "create", cause })));
|
|
259
|
-
const scratchDir = path.join(workspaceDir, "tmp");
|
|
260
|
-
const removeEmptyScratch = fs.readDirectory(scratchDir).pipe(Effect.flatMap((entries) => entries.length === 0
|
|
261
|
-
? fs.remove(scratchDir, { recursive: true, force: false })
|
|
262
|
-
: Effect.void), Effect.ignore);
|
|
263
|
-
const removeNewEmptyWorkspace = Effect.forEach(missingWorkspaceAncestors, (directory) => fs.readDirectory(directory).pipe(Effect.flatMap((entries) => entries.length === 0
|
|
264
|
-
? fs.remove(directory, { recursive: true, force: false })
|
|
265
|
-
: Effect.void), Effect.ignore), { concurrency: 1, discard: true });
|
|
266
|
-
return yield* Effect.scoped(Effect.gen(function* () {
|
|
267
|
-
// The invocation-level transition hold already provides
|
|
268
|
-
// cross-process exclusion; acquiring here again would deadlock on
|
|
269
|
-
// our own lock.
|
|
270
|
-
if (!isWorkspaceTransitionHeldByThisInvocation(workspaceDir)) {
|
|
271
|
-
const contention = yield* acquireWorkspaceTransitionLock({
|
|
272
|
-
workspaceDir,
|
|
273
|
-
holder: { command: "workspace-transaction", pid: process.pid },
|
|
274
|
-
});
|
|
275
|
-
if (Option.isSome(contention)) {
|
|
276
|
-
return yield* new TransitionLockUnavailable({
|
|
277
|
-
holder: Option.getOrUndefined(contention.value.holder),
|
|
278
|
-
waitedMillis: contention.value.waitedMillis,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const context = {
|
|
283
|
-
fs,
|
|
284
|
-
path,
|
|
285
|
-
workspaceDir,
|
|
286
|
-
snapshotStore: { dir: undefined },
|
|
287
|
-
protectedTargets: new Map(),
|
|
288
|
-
snapshots: [],
|
|
289
|
-
snapshotSemaphore: Semaphore.makeUnsafe(1),
|
|
290
|
-
pendingRestorationFailures: [],
|
|
291
|
-
snapshotSequence: { value: 0 },
|
|
292
|
-
};
|
|
293
|
-
// The store is removed only when nothing in it is still needed:
|
|
294
|
-
// a closure whose rollback failed leaves its pre-change
|
|
295
|
-
// snapshots preserved for manual recovery, and the typed
|
|
296
|
-
// restoration fact names this directory.
|
|
297
|
-
const removeSnapshotStore = Effect.suspend(() => context.snapshotStore.dir === undefined ||
|
|
298
|
-
context.pendingRestorationFailures.length > 0
|
|
299
|
-
? Effect.void
|
|
300
|
-
: fs
|
|
301
|
-
.remove(context.snapshotStore.dir, { recursive: true, force: true })
|
|
302
|
-
.pipe(Effect.ignore));
|
|
303
|
-
// The compromise signal of the hold serializing this mutation:
|
|
304
|
-
// the invocation-level hold when one exists, else the one just
|
|
305
|
-
// acquired above. Mutation races against it and stops when
|
|
306
|
-
// ownership is lost.
|
|
307
|
-
const held = heldWorkspaceTransition(workspaceDir);
|
|
308
|
-
// Interruptible like the business side: the race runs inside the
|
|
309
|
-
// uninterruptible rollback guard, and its loser must be
|
|
310
|
-
// interruptible for the race to settle.
|
|
311
|
-
const compromiseSignal = (held === undefined ? Effect.never : held.compromised).pipe(Effect.interruptible);
|
|
312
|
-
const transitionCompromised = held === undefined ? () => false : held.isCompromised;
|
|
313
|
-
const business = Effect.gen(function* () {
|
|
314
|
-
// The transaction's own declared targets belong to the
|
|
315
|
-
// operation closure: no semantic closure is active yet.
|
|
316
|
-
yield* Effect.forEach(normalizedTargets(path, args.targets), (target) => protectInContext(context, target, undefined), { discard: true });
|
|
317
|
-
const value = yield* args.transition;
|
|
318
|
-
yield* args.validate(value);
|
|
319
|
-
return value;
|
|
320
|
-
}).pipe(Effect.provideService(CurrentWorkspaceTransaction, Option.some(context)), Effect.interruptible);
|
|
321
|
-
const retainAll = (cause, restorationCause) => Effect.gen(function* () {
|
|
322
|
-
const interruption = Cause.hasInterruptsOnly(cause);
|
|
323
|
-
return yield* new WorkspaceRestorationIncomplete({
|
|
324
|
-
terminationCause: interruption ? "interruption" : "failure",
|
|
325
|
-
transitionCause: cause,
|
|
326
|
-
restorationCause,
|
|
327
|
-
snapshotDir: context.snapshotStore.dir,
|
|
328
|
-
retained: context.snapshots.map((snapshot) => workspaceRelative(path, workspaceDir, snapshot.target)),
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
// The mask/restore shape is load-bearing: the business runs in
|
|
332
|
-
// the restored (interruptible) region so an external termination
|
|
333
|
-
// request reaches it, while the settlement handlers — rollback,
|
|
334
|
-
// verification, and the typed retain path — run uninterruptibly
|
|
335
|
-
// and observe the interruption as a cause. A blanket mask would
|
|
336
|
-
// never deliver the interrupt to the parked business and the
|
|
337
|
-
// invocation could not stop.
|
|
338
|
-
return yield* Effect.uninterruptibleMask((restoreInterruptibility) => restoreInterruptibility(Effect.raceFirst(business, compromiseSignal)).pipe(Effect.matchCauseEffect({
|
|
339
|
-
onFailure: (cause) => {
|
|
340
|
-
const raceError = Option.getOrUndefined(Cause.findErrorOption(cause));
|
|
341
|
-
if (raceError instanceof WorkspaceTransitionCompromised) {
|
|
342
|
-
// Ownership is lost: restoring now could overwrite a
|
|
343
|
-
// successor's work. Retain everything the failure left,
|
|
344
|
-
// keep the snapshots, and fail typed.
|
|
345
|
-
return retainAll(cause, raceError);
|
|
346
|
-
}
|
|
347
|
-
return (args.onRestorationStarted ?? Effect.void)
|
|
348
|
-
.pipe(Effect.andThen(restoreAll(fs, path, context.snapshots, transitionCompromised)), Effect.andThen(verifySnapshots(fs, path, context.snapshots)))
|
|
349
|
-
.pipe(Effect.matchEffect({
|
|
350
|
-
onFailure: (restorationCause) => retainAll(cause, restorationCause),
|
|
351
|
-
onSuccess: () => removeSnapshotStore.pipe(Effect.andThen(Effect.failCause(cause))),
|
|
352
|
-
}));
|
|
353
|
-
},
|
|
354
|
-
onSuccess: (value) => removeSnapshotStore.pipe(Effect.as(value)),
|
|
355
|
-
})));
|
|
356
|
-
})).pipe(Effect.ensuring(removeEmptyScratch), Effect.ensuring(removeNewEmptyWorkspace));
|
|
357
|
-
}));
|
|
358
|
-
});
|
|
359
|
-
//# sourceMappingURL=transaction.js.map
|