@code-yeongyu/senpi 2026.10.23-1 → 2026.10.24-1
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/CHANGELOG.md +3 -1
- package/dist/bundle/chunks/{chunk-TFFJEEHQ.js → chunk-3PTVLFBA.js} +1 -1
- package/dist/bundle/chunks/{chunk-52DQGTJR.js → chunk-CXED7XG5.js} +1 -1
- package/dist/bundle/chunks/{chunk-TCWZBUBG.js → chunk-FKXOOQMF.js} +1 -1
- package/dist/bundle/chunks/{chunk-7R3FUPMO.js → chunk-OS42QW66.js} +1 -1
- package/dist/bundle/chunks/{chunk-NYYDXANA.js → chunk-QEHPPMVF.js} +2 -2
- package/dist/bundle/chunks/{chunk-TFULKHNU.js → chunk-T75A2MVF.js} +2 -2
- package/dist/bundle/chunks/{chunk-E6IDMANN.js → chunk-XE753Y6U.js} +1 -1
- package/dist/bundle/chunks/{chunk-RWRICRZB.js → chunk-ZSU4SPDQ.js} +80 -80
- package/dist/bundle/chunks/{cli-main-FVJRIDEL.js → cli-main-DQBJUCQS.js} +1 -1
- package/dist/bundle/chunks/{host-session-command-I7W5WSGN.js → host-session-command-MMYPIHCX.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-2GTQWY5I.js → interactive-mode-KIYNWS2N.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-4DDNG3S4.js → multi-session-host-X2XCJ7BZ.js} +4 -4
- package/dist/bundle/chunks/{package-manager-cli-VMY6B2WQ.js → package-manager-cli-UQMDWNHH.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-CN5HBSB3.js → rpc-mode-I7CRGV4X.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-I3IYHTWW.js → schedule-command-6LF6KHSF.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-W3EL4JYJ.js → session-control-endpoint-VV6PUFSF.js} +1 -1
- package/dist/bundle/chunks/{session-picker-HES2YE6K.js → session-picker-QZZ3DJER.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +143 -143
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/lifecycle-ledger.d.ts +3 -0
- package/dist/core/lifecycle-ledger.js +18 -1
- package/dist/core/lifecycle-session-binding.js +1 -0
- package/dist/core/session-holders.js +2 -1
- package/dist/core/session-lifecycle.d.ts +3 -0
- package/dist/core/session-lifecycle.js +11 -1
- package/dist/core/session-move/export.d.ts +3 -3
- package/dist/core/session-move/export.js +32 -27
- package/dist/core/session-move/extension-state.d.ts +5 -5
- package/dist/core/session-move/extension-state.js +25 -25
- package/dist/core/session-move/finish.d.ts +1 -1
- package/dist/core/session-move/finish.js +14 -13
- package/dist/core/session-move/import.js +37 -36
- package/dist/core/session-move/move-ledger.d.ts +3 -3
- package/dist/core/session-move/move-ledger.js +7 -6
- package/dist/core/session-move/requeue.js +1 -1
- package/dist/core/session-move/revoke.d.ts +1 -1
- package/dist/core/session-move/revoke.js +5 -5
- package/dist/core/session-move/session-lock.d.ts +4 -3
- package/dist/core/session-move/session-lock.js +23 -9
- package/dist/core/session-rebind.d.ts +1 -1
- package/dist/core/session-rebind.js +61 -31
- package/dist/modes/rpc/session-move-commands.js +16 -15
- package/dist/modes/rpc/session-release.js +3 -3
- package/docs/release-guide.md +1 -1
- package/package.json +6 -6
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import { basename, dirname, join } from "node:path";
|
|
5
|
-
import {
|
|
4
|
+
import { loadEntriesFromFileAsync } from "../session-manager.js";
|
|
5
|
+
import { pathExists } from "../tools/path-utils.js";
|
|
6
6
|
import { isPlainId, parseMoveId } from "./bundle.js";
|
|
7
7
|
import { SESSION_MOVED_IN, SESSION_RELEASED } from "./ownership.js";
|
|
8
8
|
/**
|
|
@@ -45,36 +45,36 @@ function sealedAttempt(entries, moveId) {
|
|
|
45
45
|
return isPlainId(attempt) ? attempt : undefined;
|
|
46
46
|
}
|
|
47
47
|
/** The folder of the release attempt that sealed the session for `moveId`, if that release staged one. */
|
|
48
|
-
export function sealedOutgoingStateDir(sessionFile, moveId) {
|
|
49
|
-
const attempt = sealedAttempt(
|
|
48
|
+
export async function sealedOutgoingStateDir(sessionFile, moveId) {
|
|
49
|
+
const attempt = sealedAttempt(await loadEntriesFromFileAsync(sessionFile), moveId);
|
|
50
50
|
return attempt === undefined ? undefined : join(outgoingStateDir(sessionFile, moveId), attempt);
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* Creates a fresh, empty folder for one release attempt. Earlier attempts of the move that never sealed the session
|
|
54
54
|
* are dead and removed (a handler still running for one of them may recreate its own folder, which is never read).
|
|
55
55
|
*/
|
|
56
|
-
export function prepareOutgoingState(sessionFile, moveId) {
|
|
56
|
+
export async function prepareOutgoingState(sessionFile, moveId) {
|
|
57
57
|
const root = outgoingStateDir(sessionFile, moveId);
|
|
58
|
-
const sealed = sealedAttempt(
|
|
59
|
-
if (
|
|
60
|
-
for (const name of
|
|
58
|
+
const sealed = sealedAttempt(await loadEntriesFromFileAsync(sessionFile), moveId);
|
|
59
|
+
if (await pathExists(root)) {
|
|
60
|
+
for (const name of await readdir(root)) {
|
|
61
61
|
if (name !== sealed)
|
|
62
|
-
|
|
62
|
+
await rm(join(root, name), { recursive: true, force: true });
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
const attempt = newAttempt();
|
|
66
66
|
const dir = join(root, attempt);
|
|
67
|
-
|
|
67
|
+
await mkdir(dir, { recursive: true });
|
|
68
68
|
return { attempt, dir };
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* The state the last move into this session left for its extensions, if it has not been handed over yet. Only the
|
|
72
72
|
* newest `session_moved_in` counts, and only until a `session_move_state_restored` for that move is written.
|
|
73
73
|
*/
|
|
74
|
-
export function pendingMovedState(sessionFile) {
|
|
75
|
-
if (!
|
|
74
|
+
export async function pendingMovedState(sessionFile) {
|
|
75
|
+
if (!(await pathExists(sessionFile)))
|
|
76
76
|
return undefined;
|
|
77
|
-
const entries =
|
|
77
|
+
const entries = await loadEntriesFromFileAsync(sessionFile);
|
|
78
78
|
const movedIn = entries.findLast((entry) => entry.type === "custom" && entry.customType === SESSION_MOVED_IN);
|
|
79
79
|
const moveId = movedIn === undefined ? undefined : moveIdOf(movedIn);
|
|
80
80
|
if (moveId === undefined)
|
|
@@ -84,7 +84,7 @@ export function pendingMovedState(sessionFile) {
|
|
|
84
84
|
return undefined;
|
|
85
85
|
const id = parseMoveId(moveId);
|
|
86
86
|
const dir = movedStateDir(sessionFile, id);
|
|
87
|
-
return
|
|
87
|
+
return (await pathExists(dir)) ? { moveId: id, dir } : undefined;
|
|
88
88
|
}
|
|
89
89
|
/** Delivery bookkeeping for one pending folder: the `delivered` marker and each delivery's own copy. */
|
|
90
90
|
const deliveryDir = (pendingDir) => join(dirname(dirname(pendingDir)), "delivery", basename(pendingDir));
|
|
@@ -99,28 +99,28 @@ export async function restoreMovedState(sessionManager, deliver) {
|
|
|
99
99
|
const sessionFile = sessionManager.getSessionFile();
|
|
100
100
|
if (sessionFile === undefined)
|
|
101
101
|
return;
|
|
102
|
-
const pending = pendingMovedState(sessionFile);
|
|
102
|
+
const pending = await pendingMovedState(sessionFile);
|
|
103
103
|
if (pending === undefined)
|
|
104
104
|
return;
|
|
105
105
|
const bookkeeping = deliveryDir(pending.dir);
|
|
106
106
|
const marker = join(bookkeeping, DELIVERED_MARKER);
|
|
107
|
-
const redelivered =
|
|
108
|
-
if (
|
|
109
|
-
for (const name of
|
|
107
|
+
const redelivered = await pathExists(marker);
|
|
108
|
+
if (await pathExists(bookkeeping)) {
|
|
109
|
+
for (const name of await readdir(bookkeeping)) {
|
|
110
110
|
if (name.startsWith(DELIVERY_COPY))
|
|
111
|
-
|
|
111
|
+
await rm(join(bookkeeping, name), { recursive: true, force: true });
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
await mkdir(bookkeeping, { recursive: true });
|
|
115
115
|
await writeFile(marker, "");
|
|
116
116
|
const copy = join(bookkeeping, `${DELIVERY_COPY}${newAttempt()}`);
|
|
117
|
-
|
|
117
|
+
await cp(pending.dir, copy, { recursive: true });
|
|
118
118
|
let restored;
|
|
119
119
|
try {
|
|
120
120
|
restored = await deliver({ moveId: pending.moveId, dir: copy, redelivered });
|
|
121
121
|
}
|
|
122
122
|
finally {
|
|
123
|
-
|
|
123
|
+
await rm(copy, { recursive: true, force: true });
|
|
124
124
|
}
|
|
125
125
|
if (!restored)
|
|
126
126
|
return;
|
|
@@ -128,7 +128,7 @@ export async function restoreMovedState(sessionManager, deliver) {
|
|
|
128
128
|
move_id: pending.moveId,
|
|
129
129
|
restored_at: new Date().toISOString(),
|
|
130
130
|
});
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
await rm(pending.dir, { recursive: true, force: true });
|
|
132
|
+
await rm(bookkeeping, { recursive: true, force: true });
|
|
133
133
|
}
|
|
134
134
|
//# sourceMappingURL=extension-state.js.map
|
|
@@ -9,7 +9,7 @@ export interface FinishMoveOptions {
|
|
|
9
9
|
/** The receiver's signed answer. `receiving` changes nothing: the move is still open. */
|
|
10
10
|
readonly answer: MoveAnswer;
|
|
11
11
|
/** Runs inside the move lock before anything is read; throw to refuse. */
|
|
12
|
-
readonly guard?: () => void
|
|
12
|
+
readonly guard?: () => void | Promise<void>;
|
|
13
13
|
}
|
|
14
14
|
export declare class MoveMismatchError extends Error {
|
|
15
15
|
readonly code = "move_mismatch";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { loadEntriesFromFileAsync, SessionManager } from "../session-manager.js";
|
|
3
|
+
import { pathExists } from "../tools/path-utils.js";
|
|
3
4
|
import { readManifest } from "./bundle.js";
|
|
4
5
|
import { outgoingStateDir } from "./extension-state.js";
|
|
5
6
|
import { moveStep } from "./move-steps.js";
|
|
@@ -32,17 +33,17 @@ export function finishMove(options) {
|
|
|
32
33
|
}
|
|
33
34
|
async function settleMove(options) {
|
|
34
35
|
const { sessionFile, bundleDir, moveId, answer } = options;
|
|
35
|
-
if (
|
|
36
|
+
if (await pathExists(bundleDir)) {
|
|
36
37
|
const bundled = (await readManifest(bundleDir)).moveId;
|
|
37
38
|
if (bundled !== moveId)
|
|
38
39
|
throw new MoveMismatchError(moveId, bundled, "the kept bundle");
|
|
39
40
|
}
|
|
40
|
-
const entries =
|
|
41
|
+
const entries = await loadEntriesFromFileAsync(sessionFile);
|
|
41
42
|
const ownership = sessionOwnership(entries);
|
|
42
43
|
if (ownership.writable) {
|
|
43
44
|
if (reclaimed(entries, moveId))
|
|
44
45
|
await requeueDroppedInput(sessionFile, bundleDir);
|
|
45
|
-
discardMoveFiles(sessionFile, bundleDir, moveId);
|
|
46
|
+
await discardMoveFiles(sessionFile, bundleDir, moveId);
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
48
49
|
if (ownership.moveId !== moveId)
|
|
@@ -50,7 +51,7 @@ async function settleMove(options) {
|
|
|
50
51
|
if (ownership.state === "moved") {
|
|
51
52
|
if ("revoked" in answer)
|
|
52
53
|
throw new MoveAlreadyMovedError(moveId);
|
|
53
|
-
discardMoveFiles(sessionFile, bundleDir, moveId);
|
|
54
|
+
await discardMoveFiles(sessionFile, bundleDir, moveId);
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
const sessionId = headerSessionId(entries) ?? "";
|
|
@@ -62,7 +63,7 @@ async function settleMove(options) {
|
|
|
62
63
|
await takeBack(sessionFile, bundleDir, { move_id: moveId, session_id: sessionId, reason: "revoked" });
|
|
63
64
|
break;
|
|
64
65
|
}
|
|
65
|
-
SessionManager.
|
|
66
|
+
(await SessionManager.openAsync(sessionFile)).appendCustomEntry(SESSION_MOVED, {
|
|
66
67
|
move_id: moveId,
|
|
67
68
|
session_id: sessionId,
|
|
68
69
|
target: ownership.target,
|
|
@@ -85,20 +86,20 @@ async function settleMove(options) {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
moveStep("finish:requeued");
|
|
88
|
-
discardMoveFiles(sessionFile, bundleDir, moveId);
|
|
89
|
+
await discardMoveFiles(sessionFile, bundleDir, moveId);
|
|
89
90
|
}
|
|
90
91
|
/** `session_reclaimed` makes the file writable again, then the queued input comes back under ids stable per move. */
|
|
91
92
|
async function takeBack(sessionFile, bundleDir, data) {
|
|
92
|
-
SessionManager.
|
|
93
|
+
(await SessionManager.openAsync(sessionFile)).appendCustomEntry(SESSION_RECLAIMED, {
|
|
93
94
|
...data,
|
|
94
95
|
reclaimed_at: new Date().toISOString(),
|
|
95
96
|
});
|
|
96
97
|
moveStep("finish:recorded");
|
|
97
98
|
await requeueDroppedInput(sessionFile, bundleDir);
|
|
98
99
|
}
|
|
99
|
-
function discardMoveFiles(sessionFile, bundleDir, moveId) {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
async function discardMoveFiles(sessionFile, bundleDir, moveId) {
|
|
101
|
+
await rm(bundleDir, { recursive: true, force: true });
|
|
102
|
+
await rm(outgoingStateDir(sessionFile, moveId), { recursive: true, force: true });
|
|
102
103
|
}
|
|
103
104
|
function reclaimed(entries, moveId) {
|
|
104
105
|
return entries.some((entry) => entry.type === "custom" &&
|
|
@@ -106,7 +107,7 @@ function reclaimed(entries, moveId) {
|
|
|
106
107
|
entry.data?.move_id === moveId);
|
|
107
108
|
}
|
|
108
109
|
export async function requeueDroppedInput(sessionFile, bundleDir) {
|
|
109
|
-
if (!
|
|
110
|
+
if (!(await pathExists(bundleDir)))
|
|
110
111
|
return;
|
|
111
112
|
const manifest = await readManifest(bundleDir);
|
|
112
113
|
await requeueInput(sessionFile, manifest.moveId, manifest.droppedUserMessages);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { copyFile, mkdir, readdir, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { normalizePath, resolvePath } from "../../utils/paths.js";
|
|
4
4
|
import { compareRepositoryIdentities, readRepositoryIdentity, } from "../repository-identity.js";
|
|
5
|
-
import { getDefaultSessionDir,
|
|
5
|
+
import { getDefaultSessionDir, loadEntriesFromFileAsync, SessionManager } from "../session-manager.js";
|
|
6
6
|
import { rebindSessionFile } from "../session-rebind.js";
|
|
7
7
|
import { encodedSessionId, serializeByKey } from "../session-sidecar-store.js";
|
|
8
|
+
import { pathExists } from "../tools/path-utils.js";
|
|
8
9
|
import { EXTENSION_STATE_PREFIX, MoveBundleInvalidError, readManifest, sha256File, verifyBundle, } from "./bundle.js";
|
|
9
10
|
import { EXTENSION_STATE_PAYLOAD, movedStateDir } from "./extension-state.js";
|
|
10
11
|
import { MoveFencedError } from "./move-ledger.js";
|
|
@@ -27,13 +28,13 @@ export class MoveTargetOccupiedError extends Error {
|
|
|
27
28
|
this.name = "MoveTargetOccupiedError";
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
const movedInFor = (file, moveId) =>
|
|
31
|
+
const movedInFor = async (file, moveId) => (await loadEntriesFromFileAsync(file)).some((entry) => entry.type === "custom" &&
|
|
31
32
|
entry.customType === SESSION_MOVED_IN &&
|
|
32
33
|
entry.data?.move_id === moveId);
|
|
33
34
|
/** What the session file records for the move: nothing, its import, or its import and then a revoke. */
|
|
34
|
-
function importOutcome(file, moveId) {
|
|
35
|
+
async function importOutcome(file, moveId) {
|
|
35
36
|
let outcome = "absent";
|
|
36
|
-
for (const entry of
|
|
37
|
+
for (const entry of await loadEntriesFromFileAsync(file)) {
|
|
37
38
|
if (entry.type !== "custom")
|
|
38
39
|
continue;
|
|
39
40
|
const data = entry.data;
|
|
@@ -59,11 +60,11 @@ export class StatusPathMismatchError extends Error {
|
|
|
59
60
|
* one revoked), so a file that is not this move's import is refused. A path with no file answers from the ledger.
|
|
60
61
|
*/
|
|
61
62
|
export async function answerMoveStatus(ledger, moveId, sessionFile) {
|
|
62
|
-
const landed = (target) => (
|
|
63
|
-
if (sessionFile === undefined || !
|
|
63
|
+
const landed = async (target) => ((await pathExists(target)) ? importOutcome(target, moveId) : "absent");
|
|
64
|
+
if (sessionFile === undefined || !(await pathExists(sessionFile)))
|
|
64
65
|
return ledger.answerStatus(moveId, { movedIn: false, landed });
|
|
65
|
-
return serializeByKey(moveLockKey(sessionFile), async () => {
|
|
66
|
-
const outcome = importOutcome(sessionFile, moveId);
|
|
66
|
+
return serializeByKey(await moveLockKey(sessionFile), async () => {
|
|
67
|
+
const outcome = await importOutcome(sessionFile, moveId);
|
|
67
68
|
if (outcome === "absent")
|
|
68
69
|
throw new StatusPathMismatchError(moveId, sessionFile);
|
|
69
70
|
return ledger.answerStatus(moveId, { movedIn: true, revoked: outcome === "revoked", landed });
|
|
@@ -83,10 +84,10 @@ export async function importMovedSession(options) {
|
|
|
83
84
|
const target = join(sessionDir, manifest.sessionFileName);
|
|
84
85
|
// Once this move's file landed, the move records no failure: only its `accepted` write can have been lost, so it stays
|
|
85
86
|
// receiving and a re-run accepts it. `fail` asks this under the move's lock, where an overlapping commit cannot race it.
|
|
86
|
-
const landed = () =>
|
|
87
|
-
if (landed()) {
|
|
87
|
+
const landed = async () => (await pathExists(target)) && (await movedInFor(target, manifest.moveId));
|
|
88
|
+
if (await landed()) {
|
|
88
89
|
// The rename already committed; a crash or a failed write may have lost `accepted`, and the file is the authority.
|
|
89
|
-
await options.ledger.confirmLanded(manifest.moveId, target, importOutcome(target, manifest.moveId) === "revoked");
|
|
90
|
+
await options.ledger.confirmLanded(manifest.moveId, target, (await importOutcome(target, manifest.moveId)) === "revoked");
|
|
90
91
|
return { sessionFile: target };
|
|
91
92
|
}
|
|
92
93
|
try {
|
|
@@ -99,7 +100,7 @@ export async function importMovedSession(options) {
|
|
|
99
100
|
}
|
|
100
101
|
const read = options.readIdentity ?? readRepositoryIdentity;
|
|
101
102
|
if (compareRepositoryIdentities(manifest.repository, await read(targetCwd)) !== "same") {
|
|
102
|
-
if (!landed())
|
|
103
|
+
if (!(await landed()))
|
|
103
104
|
await options.ledger.decline(manifest.moveId, "repository");
|
|
104
105
|
throw new MoveRefusedError(targetCwd, manifest.sourceCwd);
|
|
105
106
|
}
|
|
@@ -108,13 +109,13 @@ export async function importMovedSession(options) {
|
|
|
108
109
|
const staging = join(sessionDir, ".move-staging", encodeURIComponent(manifest.moveId));
|
|
109
110
|
try {
|
|
110
111
|
const staged = await stage(options.bundleDir, manifest, staging, options.from ?? "");
|
|
111
|
-
const sessionFile = await options.ledger.commit(manifest.moveId, target, () => rebindSessionFile(staged, targetCwd, sessionDir), () => {
|
|
112
|
-
replacePredecessor(target, manifest.sessionId, sessionDir);
|
|
112
|
+
const sessionFile = await options.ledger.commit(manifest.moveId, target, () => rebindSessionFile(staged, targetCwd, sessionDir), async () => {
|
|
113
|
+
await replacePredecessor(target, manifest.sessionId, sessionDir);
|
|
113
114
|
// Before the rename: a crash here re-copies on retry, and after it the state is already in place.
|
|
114
|
-
placeExtensionState(staging, manifest, movedStateDir(target, manifest.moveId));
|
|
115
|
+
await placeExtensionState(staging, manifest, movedStateDir(target, manifest.moveId));
|
|
115
116
|
});
|
|
116
117
|
moveStep("import:accepted");
|
|
117
|
-
|
|
118
|
+
await rm(staging, { recursive: true, force: true });
|
|
118
119
|
return { sessionFile };
|
|
119
120
|
}
|
|
120
121
|
catch (error) {
|
|
@@ -128,22 +129,22 @@ export async function importMovedSession(options) {
|
|
|
128
129
|
* It is removed before the rename: if the rename then fails, the move is recorded `import_failed`, the sender takes
|
|
129
130
|
* the session back, and nothing writable was lost here (the removed copy could not be written).
|
|
130
131
|
*/
|
|
131
|
-
function replacePredecessor(target, sessionId, sessionDir) {
|
|
132
|
-
if (!
|
|
132
|
+
async function replacePredecessor(target, sessionId, sessionDir) {
|
|
133
|
+
if (!(await pathExists(target)))
|
|
133
134
|
return;
|
|
134
|
-
const entries =
|
|
135
|
+
const entries = await loadEntriesFromFileAsync(target);
|
|
135
136
|
if (headerSessionId(entries) !== sessionId || sessionOwnership(entries).writable) {
|
|
136
137
|
throw new MoveTargetOccupiedError(target);
|
|
137
138
|
}
|
|
138
139
|
const extensions = join(sessionDir, "extensions");
|
|
139
|
-
if (
|
|
140
|
-
for (const extension of
|
|
140
|
+
if (await pathExists(extensions)) {
|
|
141
|
+
for (const extension of await readdir(extensions, { withFileTypes: true })) {
|
|
141
142
|
if (extension.isDirectory())
|
|
142
|
-
|
|
143
|
+
await rm(join(extensions, extension.name, `${encodedSessionId(sessionId)}.json`), { force: true });
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
await rm(join(sessionDir, "resident-blobs", sessionId), { recursive: true, force: true });
|
|
147
|
+
await rm(target, { force: true });
|
|
147
148
|
}
|
|
148
149
|
/** Payload files are staged here, next to the staged session file, and placed from this checked copy. */
|
|
149
150
|
const STAGED_PAYLOADS = ".payloads";
|
|
@@ -152,34 +153,34 @@ const extensionStateFiles = (manifest) => manifest.payloads.filter((payload) =>
|
|
|
152
153
|
async function stagePayloads(bundleDir, manifest, staging) {
|
|
153
154
|
for (const file of manifest.payloads.flatMap((payload) => payload.files)) {
|
|
154
155
|
const to = join(staging, STAGED_PAYLOADS, file.path);
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
await mkdir(dirname(to), { recursive: true });
|
|
157
|
+
await copyFile(join(bundleDir, file.path), to);
|
|
157
158
|
if ((await sha256File(to)) !== file.sha256)
|
|
158
159
|
throw new MoveBundleInvalidError(`${file.path} changed in transit`);
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
|
-
function placeExtensionState(staging, manifest, dir) {
|
|
162
|
+
async function placeExtensionState(staging, manifest, dir) {
|
|
162
163
|
const files = extensionStateFiles(manifest);
|
|
163
164
|
if (files.length === 0)
|
|
164
165
|
return;
|
|
165
|
-
|
|
166
|
+
await rm(dir, { recursive: true, force: true });
|
|
166
167
|
for (const file of files) {
|
|
167
168
|
const to = join(dir, file.path.slice(EXTENSION_STATE_PREFIX.length));
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
await mkdir(dirname(to), { recursive: true });
|
|
170
|
+
await copyFile(join(staging, STAGED_PAYLOADS, file.path), to);
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
async function stage(bundleDir, manifest, staging, from) {
|
|
173
174
|
const file = join(staging, manifest.sessionFileName);
|
|
174
|
-
if (!(
|
|
175
|
-
|
|
175
|
+
if (!((await pathExists(file)) && (await movedInFor(file, manifest.moveId)))) {
|
|
176
|
+
await rm(staging, { recursive: true, force: true });
|
|
176
177
|
for (const item of [manifest.session, ...manifest.sidecars]) {
|
|
177
178
|
const to = item === manifest.session ? file : join(staging, item.path.replace(/^files\//, ""));
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
await mkdir(dirname(to), { recursive: true });
|
|
180
|
+
await copyFile(join(bundleDir, item.path), to);
|
|
180
181
|
}
|
|
181
182
|
await stagePayloads(bundleDir, manifest, staging);
|
|
182
|
-
SessionManager.
|
|
183
|
+
(await SessionManager.openAsync(file, staging)).appendCustomEntry(SESSION_MOVED_IN, {
|
|
183
184
|
move_id: manifest.moveId,
|
|
184
185
|
session_id: manifest.sessionId,
|
|
185
186
|
from,
|
|
@@ -73,7 +73,7 @@ export declare class MoveLedger {
|
|
|
73
73
|
answerStatus(moveId: MoveId, here: {
|
|
74
74
|
readonly movedIn: boolean;
|
|
75
75
|
readonly revoked?: boolean;
|
|
76
|
-
readonly landed?: (target: string) => LandingOutcome
|
|
76
|
+
readonly landed?: (target: string) => LandingOutcome | Promise<LandingOutcome>;
|
|
77
77
|
}): Promise<MoveAnswer>;
|
|
78
78
|
/** Cancel, expiry or a refused chunk: only before the bundle was verified and receiving began, never after. */
|
|
79
79
|
decline(moveId: MoveId, reason: DeclineReason): Promise<MoveRecord>;
|
|
@@ -83,7 +83,7 @@ export declare class MoveLedger {
|
|
|
83
83
|
* commit may land it while this call waits for the lock), nothing is recorded and the current record is returned.
|
|
84
84
|
*/
|
|
85
85
|
fail(moveId: MoveId, reason: FailReason, options?: {
|
|
86
|
-
readonly unlessLanded?: () => boolean
|
|
86
|
+
readonly unlessLanded?: () => boolean | Promise<boolean>;
|
|
87
87
|
}): Promise<MoveRecord | undefined>;
|
|
88
88
|
/**
|
|
89
89
|
* Runs the import's commit (the atomic rename into `target`) under the move's lock. The record is stamped
|
|
@@ -91,7 +91,7 @@ export declare class MoveLedger {
|
|
|
91
91
|
* file at the target then decides (`confirmLanded`, `reconcile`). Only the recorded target may be renamed into.
|
|
92
92
|
* `prepare` runs before the stamp: whatever it does or refuses lands nothing, so a refusal there still records failed.
|
|
93
93
|
*/
|
|
94
|
-
commit<T>(moveId: MoveId, target: string, rename: () => T | Promise<T>, prepare?: () => void): Promise<T>;
|
|
94
|
+
commit<T>(moveId: MoveId, target: string, rename: () => T | Promise<T>, prepare?: () => void | Promise<void>): Promise<T>;
|
|
95
95
|
/**
|
|
96
96
|
* The import found this move's file already landed at `target`: that file proves the commit, so a record a crash
|
|
97
97
|
* left `receiving`, committing or lost becomes accepted there, and revoked when the file is (`revoked`). A target
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFile, rename as renameFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename as renameFile, writeFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { serializeByKey } from "../session-sidecar-store.js";
|
|
5
4
|
import { parseMoveId } from "./bundle.js";
|
|
@@ -138,7 +137,9 @@ export class MoveLedger {
|
|
|
138
137
|
* commit may land it while this call waits for the lock), nothing is recorded and the current record is returned.
|
|
139
138
|
*/
|
|
140
139
|
fail(moveId, reason, options = {}) {
|
|
141
|
-
return this.locked(moveId, () => options.unlessLanded?.()
|
|
140
|
+
return this.locked(moveId, async () => (await options.unlessLanded?.())
|
|
141
|
+
? this.current(moveId)
|
|
142
|
+
: this.settleLocked(moveId, { state: "failed", reason }, []));
|
|
142
143
|
}
|
|
143
144
|
/**
|
|
144
145
|
* Runs the import's commit (the atomic rename into `target`) under the move's lock. The record is stamped
|
|
@@ -152,7 +153,7 @@ export class MoveLedger {
|
|
|
152
153
|
const landing = await this.landing(moveId);
|
|
153
154
|
if (!landsAt(record, landing, target))
|
|
154
155
|
throw new MoveFencedError(moveId, record, landedElsewhere(record, landing));
|
|
155
|
-
prepare?.();
|
|
156
|
+
await prepare?.();
|
|
156
157
|
await this.write(moveId, { state: "receiving" }, { target, committing: true });
|
|
157
158
|
moveStep("import:stamped");
|
|
158
159
|
const result = await rename();
|
|
@@ -188,7 +189,7 @@ export class MoveLedger {
|
|
|
188
189
|
const { target, committing } = await this.landing(moveId);
|
|
189
190
|
if (landed === undefined || target === undefined)
|
|
190
191
|
return record;
|
|
191
|
-
const next = settledLanding(record, committing === true, landed(target));
|
|
192
|
+
const next = settledLanding(record, committing === true, await landed(target));
|
|
192
193
|
if (next === undefined)
|
|
193
194
|
return record;
|
|
194
195
|
await this.write(moveId, next, { target });
|
|
@@ -275,7 +276,7 @@ export class MoveLedger {
|
|
|
275
276
|
locked(moveId, fn) {
|
|
276
277
|
const file = this.path(moveId);
|
|
277
278
|
return serializeByKey(file, async () => {
|
|
278
|
-
|
|
279
|
+
await mkdir(this.dir, { recursive: true });
|
|
279
280
|
return this.#locks.hold(file, fn);
|
|
280
281
|
});
|
|
281
282
|
}
|
|
@@ -8,7 +8,7 @@ import { SessionManager } from "../session-manager.js";
|
|
|
8
8
|
export async function requeueInput(sessionFile, moveId, texts) {
|
|
9
9
|
if (texts.length === 0)
|
|
10
10
|
return;
|
|
11
|
-
const sessionManager = SessionManager.
|
|
11
|
+
const sessionManager = await SessionManager.openAsync(sessionFile);
|
|
12
12
|
let next = 0;
|
|
13
13
|
const inputs = new SessionPendingInputs({ sessionManager, nextQueuedId: () => `move-${moveId}-${next++}` });
|
|
14
14
|
for (const [index, text] of texts.entries()) {
|
|
@@ -7,7 +7,7 @@ export interface RevokeMoveOptions {
|
|
|
7
7
|
/** This device's move record; its accepted record is marked revoked once the copy is sealed. */
|
|
8
8
|
readonly ledger: MoveLedger;
|
|
9
9
|
/** Runs inside the move lock before anything is read; throw to refuse. */
|
|
10
|
-
readonly guard?: () => void
|
|
10
|
+
readonly guard?: () => void | Promise<void>;
|
|
11
11
|
}
|
|
12
12
|
export declare class RevokeMismatchError extends Error {
|
|
13
13
|
readonly code = "move_mismatch";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadEntriesFromFileAsync, SessionManager } from "../session-manager.js";
|
|
2
2
|
import { moveStep } from "./move-steps.js";
|
|
3
3
|
import { headerSessionId, ownershipStep, SESSION_MOVED, SESSION_MOVED_IN, } from "./ownership.js";
|
|
4
4
|
import { withSessionMoveLock } from "./session-lock.js";
|
|
@@ -19,14 +19,14 @@ export const REVOKED = "revoked";
|
|
|
19
19
|
*/
|
|
20
20
|
export function revokeImportedMove(options) {
|
|
21
21
|
return withSessionMoveLock(options.sessionFile, async () => {
|
|
22
|
-
seal(options.sessionFile, options.moveId);
|
|
22
|
+
await seal(options.sessionFile, options.moveId);
|
|
23
23
|
moveStep("revoke:sealed");
|
|
24
24
|
await options.ledger.markRevoked(options.moveId);
|
|
25
25
|
moveStep("revoke:marked");
|
|
26
26
|
}, options.guard);
|
|
27
27
|
}
|
|
28
|
-
function seal(sessionFile, moveId) {
|
|
29
|
-
const entries =
|
|
28
|
+
async function seal(sessionFile, moveId) {
|
|
29
|
+
const entries = await loadEntriesFromFileAsync(sessionFile);
|
|
30
30
|
const sessionId = headerSessionId(entries);
|
|
31
31
|
let ownership = { writable: true };
|
|
32
32
|
let latest;
|
|
@@ -42,7 +42,7 @@ function seal(sessionFile, moveId) {
|
|
|
42
42
|
return;
|
|
43
43
|
if (!(ownership.writable && ofThisMove && latest?.customType === SESSION_MOVED_IN))
|
|
44
44
|
throw new RevokeMismatchError(moveId);
|
|
45
|
-
SessionManager.
|
|
45
|
+
(await SessionManager.openAsync(sessionFile)).appendCustomEntry(SESSION_MOVED, {
|
|
46
46
|
move_id: moveId,
|
|
47
47
|
session_id: sessionId ?? "",
|
|
48
48
|
target: data(latest, "from") ?? "",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export declare function realSessionPath(path: string): string
|
|
2
|
-
export declare
|
|
1
|
+
export declare function realSessionPath(path: string): Promise<string>;
|
|
2
|
+
export declare function orderedRealPath(path: string): Promise<string>;
|
|
3
|
+
export declare const moveLockKey: (sessionFile: string) => Promise<string>;
|
|
3
4
|
/**
|
|
4
5
|
* Runs the sender's steps of a move (export, finish) one at a time per session file, across processes too.
|
|
5
6
|
*
|
|
@@ -11,5 +12,5 @@ export declare const moveLockKey: (sessionFile: string) => string;
|
|
|
11
12
|
* - `guard` runs first inside the queue, so a caller's own check (for example "not open on this host") sees the
|
|
12
13
|
* state the step will act on.
|
|
13
14
|
*/
|
|
14
|
-
export declare function withSessionMoveLock<T>(sessionFile: string, step: () => Promise<T>, guard?: () => void): Promise<T>;
|
|
15
|
+
export declare function withSessionMoveLock<T>(sessionFile: string, step: () => Promise<T>, guard?: () => void | Promise<void>): Promise<T>;
|
|
15
16
|
//# sourceMappingURL=session-lock.d.ts.map
|
|
@@ -1,20 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { withSessionMoveLock as withHolderMoveLock } from "../session-holders.js";
|
|
4
|
-
import {
|
|
4
|
+
import { loadEntriesFromFileAsync } from "../session-manager.js";
|
|
5
5
|
import { serializeByKey } from "../session-sidecar-store.js";
|
|
6
6
|
import { headerSessionId } from "./ownership.js";
|
|
7
|
-
export function realSessionPath(path) {
|
|
7
|
+
export async function realSessionPath(path) {
|
|
8
8
|
const absolute = resolve(path);
|
|
9
9
|
try {
|
|
10
|
-
return
|
|
10
|
+
return await realpath(absolute);
|
|
11
11
|
}
|
|
12
12
|
catch {
|
|
13
13
|
return absolute;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
const lockKey = (resolvedPath) => `session-move:${resolvedPath}`;
|
|
17
|
-
|
|
17
|
+
// Resolutions of one path settle one after another, in call order, so two steps asked for in order enter the
|
|
18
|
+
// per-file queue in that order even though resolving the real path is asynchronous. Each path has its own chain, so
|
|
19
|
+
// one stuck realpath never delays another file's; a chain is dropped once it is idle. `realSessionPath` never rejects.
|
|
20
|
+
const pathChains = new Map();
|
|
21
|
+
export function orderedRealPath(path) {
|
|
22
|
+
const key = resolve(path);
|
|
23
|
+
const next = (pathChains.get(key) ?? Promise.resolve("")).then(() => realSessionPath(path));
|
|
24
|
+
pathChains.set(key, next);
|
|
25
|
+
void next.then(() => {
|
|
26
|
+
if (pathChains.get(key) === next)
|
|
27
|
+
pathChains.delete(key);
|
|
28
|
+
});
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
export const moveLockKey = async (sessionFile) => lockKey(await orderedRealPath(sessionFile));
|
|
18
32
|
/**
|
|
19
33
|
* Runs the sender's steps of a move (export, finish) one at a time per session file, across processes too.
|
|
20
34
|
*
|
|
@@ -26,11 +40,11 @@ export const moveLockKey = (sessionFile) => lockKey(realSessionPath(sessionFile)
|
|
|
26
40
|
* - `guard` runs first inside the queue, so a caller's own check (for example "not open on this host") sees the
|
|
27
41
|
* state the step will act on.
|
|
28
42
|
*/
|
|
29
|
-
export function withSessionMoveLock(sessionFile, step, guard) {
|
|
30
|
-
const path =
|
|
43
|
+
export async function withSessionMoveLock(sessionFile, step, guard) {
|
|
44
|
+
const path = await orderedRealPath(sessionFile);
|
|
31
45
|
return serializeByKey(lockKey(path), async () => {
|
|
32
|
-
guard?.();
|
|
33
|
-
const sessionId = headerSessionId(
|
|
46
|
+
await guard?.();
|
|
47
|
+
const sessionId = headerSessionId(await loadEntriesFromFileAsync(path));
|
|
34
48
|
return sessionId === undefined ? step() : withHolderMoveLock(path, sessionId, step);
|
|
35
49
|
});
|
|
36
50
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RepositoryIdentity, type RepositoryMatch } from "./repository-identity.ts";
|
|
2
|
-
export declare function readRecordedRepositoryIdentity(sessionFile: string): RepositoryIdentity | undefined
|
|
2
|
+
export declare function readRecordedRepositoryIdentity(sessionFile: string): Promise<RepositoryIdentity | undefined>;
|
|
3
3
|
/**
|
|
4
4
|
* Whether `cwd` is the git repository a session was recorded in. The session side prefers the live
|
|
5
5
|
* checkout at its recorded cwd and falls back to the identity the session recorded, because a moved
|