@weapp-vite/devtools-runtime 0.4.24 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,22 +1,128 @@
1
- import { AutomatorElement, AutomatorMiniProgram, AutomatorPage, DevtoolsConnectionInput, DevtoolsContext, DevtoolsElementSnapshot, DevtoolsPageSnapshot, DevtoolsRuntimeHooks, DevtoolsRuntimeSessionOptions, DevtoolsToolResult, MiniProgramElementLike, readDevtoolsElementSnapshot, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, toDevtoolsSerializableValue } from "./mcp.js";
1
+ import { AutomatorElement, AutomatorMiniProgram, AutomatorPage, DevtoolsConnectionInput, DevtoolsContext, DevtoolsElementSnapshot, DevtoolsInstallationContext, DevtoolsPageSnapshot, DevtoolsRuntimeHooks, DevtoolsRuntimeSessionOptions, DevtoolsToolResult, MiniProgramElementLike, readDevtoolsElementSnapshot, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, toDevtoolsSerializableValue } from "./mcp.js";
2
+ //#region src/lease.d.ts
3
+ export interface RuntimeLease {
4
+ root: string;
5
+ token: string;
6
+ released: boolean;
7
+ release: () => Promise<void>;
8
+ }
9
+ /** 同一真实工程跨进程互斥;未知持有者不自动回收。 */
10
+ export declare function acquireRuntimeLease(projectRoot: string): Promise<RuntimeLease>;
11
+ export declare function runWithRuntimeLease<T>(lease: RuntimeLease, run: () => Promise<T>): Promise<T>;
12
+ export declare function withRuntimeLease<T>(projectRoot: string, run: () => Promise<T>): Promise<T>;
13
+ //#endregion
14
+ //#region src/lease/directory.d.ts
15
+ interface LeaseOwner {
16
+ pid: number;
17
+ token: string;
18
+ }
19
+ /** 注册、释放和过期回收共享短临界区;归属未知的临界区不自动删除。 */
20
+ export declare function mutateLease<T>(directory: string, run: () => Promise<T>, options?: {
21
+ timeoutMs?: number;
22
+ }): Promise<T>;
23
+ //#endregion
24
+ //#region src/lease/machineRecoveryState.d.ts
25
+ interface MachineE2ELeaseRecoveryScope {
26
+ readonly id: string;
27
+ readonly owner: Readonly<LeaseOwner>;
28
+ readonly ancestors: readonly string[];
29
+ readonly sealed: boolean;
30
+ readonly completed: boolean;
31
+ readonly cleanupKey?: string;
32
+ }
33
+ interface MachineE2ELeaseSnapshot {
34
+ readonly owner: Readonly<LeaseOwner>;
35
+ readonly scopes: readonly MachineE2ELeaseRecoveryScope[];
36
+ readonly borrowers: readonly {
37
+ readonly pid: number;
38
+ readonly token: string;
39
+ readonly scopes: readonly string[];
40
+ }[];
41
+ }
42
+ //#endregion
43
+ //#region src/lease/machineScopeRecovery.d.ts
44
+ interface MachineE2ERecoverableDescendantScope extends MachineE2ELeaseRecoveryScope {
45
+ readonly cleanupKey: string;
46
+ }
47
+ //#endregion
48
+ //#region src/lease/machineScope.d.ts
49
+ interface MachineE2EChildScopeOptions {
50
+ /** 创建时绑定的清理标识;资源含义及准确归属由调用方核验,创建后不能更改。 */
51
+ cleanupKey?: string;
52
+ }
53
+ interface MachineE2EChildScope {
54
+ environment: Record<string, string>;
55
+ /** 先封存借用入口,再确认该子树已无活跃借用者;失败时仍保持封存。 */
56
+ seal: () => Promise<void>;
57
+ /** 当前命令已停止且日志清理成功后完成本 scope,不覆盖后代的未完成状态。 */
58
+ complete: () => Promise<void>;
59
+ /** 仅接管已停止且有清理绑定的后代;回调成功后逐个完成,不代替本 scope 的清理。 */
60
+ recoverStoppedDescendants: (run: (scope: MachineE2ERecoverableDescendantScope) => Promise<void>) => Promise<void>;
61
+ }
62
+ //#endregion
63
+ //#region src/lease/machine.d.ts
64
+ export interface MachineE2ELeaseOptions {
65
+ /** 仅供隔离测试或嵌入方显式选择;不从工作目录或用户配置环境变量推导。 */
66
+ stateDirectory?: string;
67
+ env?: NodeJS.ProcessEnv;
68
+ }
69
+ export interface MachineE2ELease {
70
+ borrowed: boolean;
71
+ released: boolean;
72
+ environment: NodeJS.ProcessEnv;
73
+ createChildScope: (options?: MachineE2EChildScopeOptions) => Promise<MachineE2EChildScope>;
74
+ release: () => Promise<void>;
75
+ }
76
+ /** 同机同用户的所有 E2E 共用一个租约,子进程凭磁盘核验过的能力票据借用。 */
77
+ export declare function acquireMachineE2ELease(options?: MachineE2ELeaseOptions): Promise<MachineE2ELease>;
78
+ /** 入口持有租约到清理完成;执行与释放同时失败时保留两者,嵌套入口仅释放自己的登记。 */
79
+ export declare function withMachineE2ELease<T>(run: (lease: MachineE2ELease) => Promise<T>, options?: MachineE2ELeaseOptions): Promise<T>;
80
+ //#endregion
81
+ //#region src/lease/machineRecovery.d.ts
82
+ export interface MachineE2ELeaseRecoveryOptions extends MachineE2ELeaseOptions {
83
+ expected: MachineE2ELeaseSnapshot;
84
+ /** 必须绑定已核验的资源日志并完成清理;仅此回调成功才允许完成对应 scope。 */
85
+ recoverScope: (scope: MachineE2ELeaseRecoveryScope) => Promise<void>;
86
+ }
87
+ export interface MachineE2ELeaseRecoveryResult {
88
+ auditFile: string;
89
+ recoveredScopes: string[];
90
+ }
91
+ /** 读取供显式恢复核对的精确登记快照,不修改租约或推导资源清理权限。 */
92
+ export declare function readMachineE2ELeaseSnapshot(options?: MachineE2ELeaseOptions): Promise<MachineE2ELeaseSnapshot>;
93
+ /** 核验当前显式恢复回调的原始 scope 与完整接管快照,不授予普通租约额外清理权限。 */
94
+ export declare function assertMachineE2ELeaseRecoveryScope(scope: MachineE2ELeaseRecoveryScope): Promise<void>;
95
+ /** 同步登记完整领域操作;回调漏等候时保留租约,直到在途操作停止后再报告失败。 */
96
+ export declare function withMachineE2ELeaseRecoveryOperation<T>(scope: MachineE2ELeaseRecoveryScope, run: () => Promise<T>): Promise<T>;
97
+ /**
98
+ * 接管已退出且全部封存的精确租约,按后代优先顺序调用受信清理回调。
99
+ * 回调通过当前根凭证嵌套普通租约,但不能遗留新登记或改变 scope。
100
+ * 失败保留新持有者与未完成 scope,待恢复进程退出后凭新快照重试。
101
+ * 接管中断保留原快照审计;进程崩溃留下的变更锁或混合归属不自动修复。
102
+ */
103
+ export declare function recoverMachineE2ELease(options: MachineE2ELeaseRecoveryOptions): Promise<MachineE2ELeaseRecoveryResult>;
104
+ //#endregion
2
105
  //#region src/index.d.ts
3
106
  export interface MiniProgramEventMap {
4
107
  console: (payload: unknown) => void;
5
108
  exception: (payload: unknown) => void;
6
109
  }
7
- export declare function resolveSharedMiniProgramSessionKey(options: Pick<DevtoolsRuntimeSessionOptions, 'port' | 'projectPath' | 'sessionId'>): string;
110
+ export type SharedMiniProgramSessionOptions = Pick<DevtoolsRuntimeSessionOptions, 'port' | 'projectPath' | 'sessionId' | 'cliPath' | 'installationId' | 'runtimeProvider'>;
111
+ export declare function resolveSharedMiniProgramSessionKey(options: SharedMiniProgramSessionOptions): string;
8
112
  /**
9
113
  * @description 获取指定项目的共享 automator 会话;若不存在则自动创建。
10
114
  */
11
115
  export declare function acquireSharedMiniProgram(hooks: DevtoolsRuntimeHooks, options: DevtoolsRuntimeSessionOptions): Promise<AutomatorMiniProgram>;
116
+ /** 使用创建连接时的精确键释放订阅。 */
117
+ export declare function releaseSharedMiniProgramByKey(sessionKey: string): void;
12
118
  /**
13
119
  * @description 释放指定项目的共享会话引用;会话对象会继续缓存,直到显式关闭或重置。
14
120
  */
15
- export declare function releaseSharedMiniProgram(projectPath: string, sessionIdOrPort?: string | number): void;
121
+ export declare function releaseSharedMiniProgram(projectPath: string, sessionIdOrPort?: string | number, installation?: Omit<SharedMiniProgramSessionOptions, 'projectPath'>): void;
16
122
  /**
17
123
  * @description 关闭并移除指定项目的共享 automator 会话。
18
124
  */
19
- export declare function closeSharedMiniProgram(projectPath: string, sessionIdOrPort?: string | number): Promise<void>;
125
+ export declare function closeSharedMiniProgram(projectPath: string, sessionIdOrPort?: string | number, installation?: Omit<SharedMiniProgramSessionOptions, 'projectPath'>): Promise<void>;
20
126
  /**
21
127
  * @description 获取当前共享会话数量,供测试断言使用。
22
128
  */
@@ -25,5 +131,11 @@ export declare function getSharedMiniProgramSessionCount(): number;
25
131
  * @description 统一管理 automator 会话生命周期。
26
132
  */
27
133
  export declare function withMiniProgram<T>(hooks: DevtoolsRuntimeHooks, options: DevtoolsRuntimeSessionOptions, runner: (miniProgram: AutomatorMiniProgram) => Promise<T>): Promise<T>;
134
+ /** 检查缓存,供调用方记录其实际创建的连接。 */
135
+ export declare function hasSharedMiniProgram(options: SharedMiniProgramSessionOptions): boolean;
136
+ /** 只清理仍由调用方创建、且已无活动引用的连接。 */
137
+ export declare function closeOwnedSharedMiniProgram(options: SharedMiniProgramSessionOptions, owned: unknown): Promise<void>;
138
+ /** 单独保留管理器订阅,避免与正在执行的操作共享引用计数。 */
139
+ export declare function retainSharedMiniProgram(sessionKey: string): void;
28
140
  //#endregion
29
- export { type AutomatorElement, type AutomatorMiniProgram, type AutomatorPage, type DevtoolsConnectionInput, type DevtoolsContext, type DevtoolsElementSnapshot, type DevtoolsPageSnapshot, type DevtoolsRuntimeHooks, type DevtoolsRuntimeSessionOptions, type DevtoolsToolResult, type MiniProgramElementLike, readDevtoolsElementSnapshot, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, toDevtoolsSerializableValue };
141
+ export { type AutomatorElement, type AutomatorMiniProgram, type AutomatorPage, type DevtoolsConnectionInput, type DevtoolsContext, type DevtoolsElementSnapshot, type DevtoolsInstallationContext, type DevtoolsPageSnapshot, type DevtoolsRuntimeHooks, type DevtoolsRuntimeSessionOptions, type DevtoolsToolResult, type MachineE2EChildScope, type MachineE2EChildScopeOptions, type MachineE2ELeaseRecoveryScope, type MachineE2ELeaseSnapshot, type MachineE2ERecoverableDescendantScope, type MiniProgramElementLike, readDevtoolsElementSnapshot, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, toDevtoolsSerializableValue };
package/dist/index.js CHANGED
@@ -1,4 +1,820 @@
1
1
  import { readDevtoolsElementSnapshot, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, toDevtoolsSerializableValue } from "./mcp.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { appendFile, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import { setTimeout } from "node:timers/promises";
9
+ import { isDeepStrictEqual } from "node:util";
10
+ //#region src/lease/machineScopeState.ts
11
+ /** 未完成的本租约命令不能因持有进程退出而被新验收直接接管。 */
12
+ async function assertNoIncompleteMachineScopes(directory) {
13
+ const scopes = path.join(directory, "scopes");
14
+ const files = await readdir(scopes).catch((error) => {
15
+ if (error.code === "ENOENT") return [];
16
+ throw error;
17
+ });
18
+ for (const file of files) {
19
+ const record = JSON.parse(await readFile(path.join(scopes, file), "utf8"));
20
+ if (!record || typeof record !== "object" || !("sealed" in record) || record.sealed !== true || !("completed" in record) || record.completed !== true) throw new Error("Runtime busy: an E2E command scope has unfinished cleanup; preserve the machine lease and its journals.");
21
+ }
22
+ }
23
+ //#endregion
24
+ //#region src/lease/directory.ts
25
+ function isProcessAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (error) {
30
+ return error.code !== "ESRCH";
31
+ }
32
+ }
33
+ async function readLeaseOwner(file) {
34
+ const value = await readFile(file, "utf8").then(JSON.parse).catch(() => void 0);
35
+ if (!value || typeof value !== "object" || !("pid" in value) || !("token" in value) || !Number.isInteger(value.pid) || Number(value.pid) <= 0 || typeof value.token !== "string" || !value.token) return;
36
+ return {
37
+ pid: Number(value.pid),
38
+ token: value.token
39
+ };
40
+ }
41
+ /** 注册、释放和过期回收共享短临界区;归属未知的临界区不自动删除。 */
42
+ async function mutateLease(directory, run, options = {}) {
43
+ const guard = `${directory}.recovery`;
44
+ const timeoutMs = options.timeoutMs ?? 200;
45
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("Lease mutation timeout must be a finite non-negative number.");
46
+ const deadline = Date.now() + timeoutMs;
47
+ while (true) try {
48
+ await mkdir(guard, { mode: 448 });
49
+ break;
50
+ } catch (error) {
51
+ const code = error.code;
52
+ const retryPermissionError = process.platform === "win32" && code === "EPERM";
53
+ if (code !== "EEXIST" && !retryPermissionError) throw error;
54
+ if (Date.now() >= deadline) {
55
+ if (retryPermissionError) throw error;
56
+ throw new Error("Runtime busy: lease ownership update or recovery is in progress.");
57
+ }
58
+ await setTimeout(Math.min(10, Math.max(0, deadline - Date.now())));
59
+ }
60
+ try {
61
+ return await run();
62
+ } finally {
63
+ await rm(guard, { recursive: true });
64
+ }
65
+ }
66
+ /** 活跃借用方和不完整登记均阻止回收,避免父进程退出后孤儿任务继续运行。 */
67
+ async function assertNoLeaseBorrowers(directory) {
68
+ const members = path.join(directory, "borrowers");
69
+ const files = await readdir(members).catch((error) => {
70
+ if (error.code === "ENOENT") return [];
71
+ throw error;
72
+ });
73
+ for (const file of files) {
74
+ const borrower = await readLeaseOwner(path.join(members, file));
75
+ if (!borrower || isProcessAlive(borrower.pid)) throw new Error("Runtime busy: an E2E child operation still owns this machine lease.");
76
+ }
77
+ }
78
+ /** 目录创建负责原子占用;只有有效且已退出的持有者允许被回收。 */
79
+ async function acquireDirectoryLease(directory, busyMessage) {
80
+ await mkdir(path.dirname(directory), {
81
+ recursive: true,
82
+ mode: 448
83
+ });
84
+ const owner = {
85
+ pid: process.pid,
86
+ token: randomUUID()
87
+ };
88
+ const create = async () => {
89
+ await mkdir(directory, { mode: 448 });
90
+ try {
91
+ await writeFile(path.join(directory, "owner.json"), JSON.stringify(owner), { mode: 384 });
92
+ } catch (error) {
93
+ await rm(directory, { recursive: true });
94
+ throw error;
95
+ }
96
+ };
97
+ try {
98
+ await create();
99
+ } catch (error) {
100
+ if (error.code !== "EEXIST") throw error;
101
+ await mutateLease(directory, async () => {
102
+ const previous = await readLeaseOwner(path.join(directory, "owner.json"));
103
+ if (!previous || isProcessAlive(previous.pid)) throw new Error(busyMessage);
104
+ await assertNoLeaseBorrowers(directory);
105
+ await assertNoIncompleteMachineScopes(directory);
106
+ await rm(directory, { recursive: true });
107
+ try {
108
+ await create();
109
+ } catch (error) {
110
+ if (error.code === "EEXIST") throw new Error(busyMessage);
111
+ throw error;
112
+ }
113
+ });
114
+ }
115
+ let releasing;
116
+ const lease = {
117
+ directory,
118
+ owner,
119
+ released: false,
120
+ release: () => {
121
+ if (lease.released) return Promise.resolve();
122
+ releasing ??= mutateLease(directory, async () => {
123
+ const current = await readLeaseOwner(path.join(directory, "owner.json"));
124
+ if (current?.token !== owner.token || current.pid !== owner.pid) throw new Error("Runtime lease ownership changed; refusing cleanup.");
125
+ await assertNoLeaseBorrowers(directory);
126
+ await assertNoIncompleteMachineScopes(directory);
127
+ await rm(directory, { recursive: true });
128
+ lease.released = true;
129
+ }).finally(() => {
130
+ releasing = void 0;
131
+ });
132
+ return releasing;
133
+ }
134
+ };
135
+ return lease;
136
+ }
137
+ //#endregion
138
+ //#region src/lease.ts
139
+ const currentLease = new AsyncLocalStorage();
140
+ /** 同一真实工程跨进程互斥;未知持有者不自动回收。 */
141
+ async function acquireRuntimeLease(projectRoot) {
142
+ const root = await realpath(projectRoot);
143
+ const directory = path.join(process.env.WEAPP_AGENT_STATE_DIR ?? path.join(homedir(), ".local", "state", "weapp-agent"), "runtime-locks");
144
+ const owned = await acquireDirectoryLease(path.join(directory, createHash("sha256").update(root).digest("hex")), "Runtime busy: another operation owns this project. Query or cancel its acceptance task first.");
145
+ return {
146
+ root,
147
+ token: owned.owner.token,
148
+ get released() {
149
+ return owned.released;
150
+ },
151
+ release: owned.release
152
+ };
153
+ }
154
+ function runWithRuntimeLease(lease, run) {
155
+ if (lease.released) throw new Error("Runtime lease already released");
156
+ return currentLease.run(lease, run);
157
+ }
158
+ async function withRuntimeLease(projectRoot, run) {
159
+ const root = await realpath(projectRoot);
160
+ const active = currentLease.getStore();
161
+ if (active?.root === root && !active.released) return run();
162
+ const lease = await acquireRuntimeLease(root);
163
+ try {
164
+ return await runWithRuntimeLease(lease, run);
165
+ } finally {
166
+ await lease.release();
167
+ }
168
+ }
169
+ //#endregion
170
+ //#region src/lease/machineRecoveryState.ts
171
+ async function assertRecoveryNode(file, kind) {
172
+ const stat = await lstat(file);
173
+ if (stat.isSymbolicLink() || (kind === "directory" ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1)) throw new Error("Machine lease recovery refuses redirected or non-regular filesystem entries.");
174
+ }
175
+ function assertKeys(value, keys, optionalKeys = []) {
176
+ if (!value || typeof value !== "object" || Array.isArray(value) || !isDeepStrictEqual(Object.keys(value).filter((key) => !optionalKeys.includes(key)).sort(), keys.toSorted())) throw new Error("Machine lease recovery found unknown or missing record fields.");
177
+ }
178
+ async function readRecord(file, keys, optionalKeys = []) {
179
+ await assertRecoveryNode(file, "file");
180
+ const value = JSON.parse(await readFile(file, "utf8"));
181
+ assertKeys(value, keys, optionalKeys);
182
+ return value;
183
+ }
184
+ async function entries(directory) {
185
+ return assertRecoveryNode(directory, "directory").then(() => readdir(directory)).catch((error) => {
186
+ if (error.code === "ENOENT") return [];
187
+ throw error;
188
+ });
189
+ }
190
+ /** 仅在机器租约短临界区内读取,拒绝无法还原归属链的登记。 */
191
+ async function readMachineSnapshot(directory) {
192
+ await assertRecoveryNode(directory, "directory").catch((error) => {
193
+ if (error.code !== "ENOENT") throw error;
194
+ throw new Error("Machine lease recovery requires an existing, verifiable owner.");
195
+ });
196
+ const rootEntries = await readdir(directory);
197
+ if (!rootEntries.includes("owner.json") || rootEntries.some((entry) => ![
198
+ "owner.json",
199
+ "scopes",
200
+ "borrowers"
201
+ ].includes(entry))) throw new Error("Machine lease recovery found an unknown or missing lease root entry.");
202
+ const rawOwner = await readRecord(path.join(directory, "owner.json"), ["pid", "token"]);
203
+ const { pid, token } = parseMachineCredential(JSON.stringify(rawOwner));
204
+ const owner = {
205
+ pid,
206
+ token
207
+ };
208
+ const scopes = [];
209
+ for (const file of (await entries(path.join(directory, "scopes"))).sort()) {
210
+ const id = file.endsWith(".json") ? file.slice(0, -5) : "";
211
+ assertKeys((await readRecord(path.join(directory, "scopes", file), [
212
+ "owner",
213
+ "ancestors",
214
+ "sealed",
215
+ "completed"
216
+ ], ["cleanupKey"])).owner, ["pid", "token"]);
217
+ scopes.push({
218
+ id,
219
+ ...await readScope(directory, id)
220
+ });
221
+ }
222
+ const byId = new Map(scopes.map((scope) => [scope.id, scope]));
223
+ for (const scope of scopes) if (!isDeepStrictEqual(scope.owner, owner) || scope.ancestors.includes(scope.id) || scope.ancestors.some((id, index) => !isDeepStrictEqual(byId.get(id)?.ancestors, scope.ancestors.slice(0, index))) || !scope.completed && scope.ancestors.some((id) => byId.get(id)?.completed) || scope.completed && !scope.sealed) throw new Error("Machine lease recovery found an unverifiable scope ownership chain.");
224
+ const borrowers = [];
225
+ for (const file of (await entries(path.join(directory, "borrowers"))).sort()) {
226
+ const raw = await readRecord(path.join(directory, "borrowers", file), [
227
+ "pid",
228
+ "token",
229
+ "scopes"
230
+ ]);
231
+ const borrower = parseMachineCredential(JSON.stringify(raw));
232
+ if (file !== `${borrower.token}.json` || borrower.scopes.some((id, index) => !isDeepStrictEqual(byId.get(id)?.ancestors, borrower.scopes.slice(0, index)))) throw new Error("Machine lease recovery found an unverifiable borrower ownership chain.");
233
+ borrowers.push(borrower);
234
+ }
235
+ return {
236
+ owner,
237
+ scopes,
238
+ borrowers
239
+ };
240
+ }
241
+ function assertSnapshotStopped(snapshot, requireOwnerStopped) {
242
+ if (requireOwnerStopped && isProcessAlive(snapshot.owner.pid) || snapshot.borrowers.some((borrower) => isProcessAlive(borrower.pid))) throw new Error("Runtime busy: machine lease recovery requires the owner and registered borrowers to have stopped.");
243
+ if (snapshot.scopes.some((scope) => !scope.sealed)) throw new Error("Machine lease recovery requires every command scope to be explicitly sealed.");
244
+ }
245
+ async function assertMachineSnapshot(directory, expected) {
246
+ const actual = await readMachineSnapshot(directory);
247
+ if (!isDeepStrictEqual(actual, expected)) throw new Error("Machine lease recovery snapshot changed; refusing cleanup of unexpected ownership, scopes, or borrowers.");
248
+ assertSnapshotStopped(actual, false);
249
+ }
250
+ function recoveryScopeCopy(scope) {
251
+ return Object.freeze({
252
+ ...scope,
253
+ owner: Object.freeze({ ...scope.owner }),
254
+ ancestors: Object.freeze([...scope.ancestors])
255
+ });
256
+ }
257
+ //#endregion
258
+ //#region src/lease/machineScopeRecovery.ts
259
+ const activeRecoveries = /* @__PURE__ */ new Map();
260
+ /** 活借用登记阻止跨进程接管;进程内保留的父子能力还需防止重叠回调。 */
261
+ function claimSubtree(directory, credential) {
262
+ let active = activeRecoveries.get(directory);
263
+ if (!active) {
264
+ active = /* @__PURE__ */ new Set();
265
+ activeRecoveries.set(directory, active);
266
+ }
267
+ if ([...active].some((other) => other.scopes.includes(credential.token) || credential.scopes.includes(other.token))) throw new Error("Runtime busy: overlapping machine descendant recovery is already in progress.");
268
+ active.add(credential);
269
+ return () => {
270
+ active.delete(credential);
271
+ if (!active.size) activeRecoveries.delete(directory);
272
+ };
273
+ }
274
+ function subtree(snapshot, token) {
275
+ return {
276
+ owner: snapshot.owner,
277
+ scopes: snapshot.scopes.filter((scope) => scope.id === token || scope.ancestors.includes(token)),
278
+ borrowers: snapshot.borrowers.filter((borrower) => borrower.scopes.includes(token))
279
+ };
280
+ }
281
+ function assertStopped(snapshot) {
282
+ if (snapshot.borrowers.some((borrower) => isProcessAlive(borrower.pid))) throw new Error("Runtime busy: an E2E command child is still running; preserve its journal before descendant recovery.");
283
+ }
284
+ async function writeScope(directory, scope) {
285
+ const { id, ...record } = scope;
286
+ await writeFile(path.join(directory, "scopes", `${id}.json`), JSON.stringify(record), { mode: 384 });
287
+ }
288
+ /** 父能力只接管本子树;未登记清理标识、未知借用者或未确认停止均不能推导清理权。 */
289
+ async function recoverSubtree(directory, credential, run) {
290
+ const token = credential.scopes.at(-1);
291
+ if (!token) throw new Error("Descendant recovery requires an explicit parent command scope.");
292
+ let expected = await mutateLease(directory, async () => {
293
+ await assertMachineCredential(directory, credential, true);
294
+ let selected = subtree(await readMachineSnapshot(directory), token);
295
+ const parent = selected.scopes.find((scope) => scope.id === token);
296
+ if (!parent.sealed) {
297
+ await writeScope(directory, {
298
+ ...parent,
299
+ sealed: true
300
+ });
301
+ selected = subtree(await readMachineSnapshot(directory), token);
302
+ }
303
+ assertStopped(selected);
304
+ const pending = selected.scopes.filter((scope) => scope.id !== token && !scope.completed);
305
+ if (pending.some((scope) => !scope.cleanupKey)) throw new Error("Machine descendant recovery requires an explicit cleanupKey for every unfinished descendant.");
306
+ for (const scope of pending) if (!scope.sealed) await writeScope(directory, {
307
+ ...scope,
308
+ sealed: true
309
+ });
310
+ return subtree(await readMachineSnapshot(directory), token);
311
+ });
312
+ const assertUnchanged = async () => {
313
+ await assertMachineCredential(directory, credential, true);
314
+ const actual = subtree(await readMachineSnapshot(directory), token);
315
+ if (!isDeepStrictEqual(actual, expected)) throw new Error("Machine descendant recovery snapshot changed; preserve unexpected scopes, bindings, or borrowers.");
316
+ assertStopped(actual);
317
+ };
318
+ const pending = expected.scopes.filter((scope) => scope.id !== token && !scope.completed).sort((left, right) => right.ancestors.length - left.ancestors.length);
319
+ for (const scope of pending) {
320
+ await mutateLease(directory, assertUnchanged);
321
+ await run(recoveryScopeCopy(scope));
322
+ await mutateLease(directory, async () => {
323
+ await assertUnchanged();
324
+ await writeScope(directory, {
325
+ ...scope,
326
+ completed: true
327
+ });
328
+ expected = {
329
+ ...expected,
330
+ scopes: expected.scopes.map((item) => item.id === scope.id ? {
331
+ ...item,
332
+ completed: true
333
+ } : item)
334
+ };
335
+ });
336
+ }
337
+ await mutateLease(directory, assertUnchanged);
338
+ }
339
+ /** 已确认停止的子树串行接管,失败释放当前进程的回调占用但保留磁盘证据。 */
340
+ async function recoverStoppedDescendantScopes(directory, credential, run) {
341
+ const releaseClaim = claimSubtree(directory, credential);
342
+ try {
343
+ await recoverSubtree(directory, credential, run);
344
+ } finally {
345
+ releaseClaim();
346
+ }
347
+ }
348
+ //#endregion
349
+ //#region src/lease/machineScope.ts
350
+ const INHERITED_LEASE_ENV = "WEAPP_VITE_E2E_MACHINE_LEASE";
351
+ const UUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
352
+ function invalid() {
353
+ return /* @__PURE__ */ new Error("Runtime busy: inherited E2E lease is invalid or its command scope is sealed; refusing unverified child ownership.");
354
+ }
355
+ function scopesOf(value) {
356
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !UUID.test(item)) || new Set(value).size !== value.length) throw invalid();
357
+ return value;
358
+ }
359
+ function ownerOf(value) {
360
+ if (!value || typeof value !== "object" || !("pid" in value) || !("token" in value) || !Number.isInteger(value.pid) || Number(value.pid) <= 0 || typeof value.token !== "string" || !value.token) throw invalid();
361
+ return {
362
+ pid: Number(value.pid),
363
+ token: value.token
364
+ };
365
+ }
366
+ function parseMachineCredential(value) {
367
+ const parsed = JSON.parse(value);
368
+ const owner = ownerOf(parsed);
369
+ const scopes = parsed && typeof parsed === "object" && "scopes" in parsed ? scopesOf(parsed.scopes) : [];
370
+ return {
371
+ ...owner,
372
+ scopes
373
+ };
374
+ }
375
+ function sameOwner(left, right) {
376
+ return left.pid === right.pid && left.token === right.token;
377
+ }
378
+ function sameScopes(left, right) {
379
+ return left.length === right.length && left.every((token, index) => token === right[index]);
380
+ }
381
+ function scopeFile(directory, token) {
382
+ if (!UUID.test(token)) throw invalid();
383
+ return path.join(directory, "scopes", `${token}.json`);
384
+ }
385
+ async function readScope(directory, token) {
386
+ const value = JSON.parse(await readFile(scopeFile(directory, token), "utf8"));
387
+ if (!value || typeof value !== "object" || !("owner" in value) || !("ancestors" in value) || !("sealed" in value) || typeof value.sealed !== "boolean" || !("completed" in value) || typeof value.completed !== "boolean") throw invalid();
388
+ if ("cleanupKey" in value && (typeof value.cleanupKey !== "string" || !value.cleanupKey.trim())) throw invalid();
389
+ return {
390
+ owner: ownerOf(value.owner),
391
+ ancestors: scopesOf(value.ancestors),
392
+ sealed: value.sealed,
393
+ completed: value.completed,
394
+ ..."cleanupKey" in value ? { cleanupKey: value.cleanupKey } : {}
395
+ };
396
+ }
397
+ /** 调用方持有同一机器租约变更锁;派生 token 丢失祖先链时不能降级成根凭证。 */
398
+ async function assertMachineCredential(directory, credential, allowSealed = false) {
399
+ const owner = await readLeaseOwner(path.join(directory, "owner.json"));
400
+ if (!owner || owner.pid !== credential.pid || !isProcessAlive(owner.pid) || credential.token !== (credential.scopes.at(-1) ?? owner.token)) throw invalid();
401
+ for (const [index, token] of credential.scopes.entries()) {
402
+ const record = await readScope(directory, token).catch(() => {
403
+ throw invalid();
404
+ });
405
+ if (!sameOwner(record.owner, owner) || !sameScopes(record.ancestors, credential.scopes.slice(0, index)) || !allowSealed && record.sealed) throw invalid();
406
+ }
407
+ return owner;
408
+ }
409
+ async function assertScopeStopped(directory, token) {
410
+ const borrowers = path.join(directory, "borrowers");
411
+ const files = await readdir(borrowers).catch((error) => {
412
+ if (error.code === "ENOENT") return [];
413
+ throw error;
414
+ });
415
+ for (const file of files) {
416
+ const value = JSON.parse(await readFile(path.join(borrowers, file), "utf8"));
417
+ const owner = ownerOf(value);
418
+ if (!value || typeof value !== "object" || !("scopes" in value)) throw new Error("Runtime busy: an E2E borrower has no verifiable command scope; preserve its journal.");
419
+ if (scopesOf(value.scopes).includes(token) && isProcessAlive(owner.pid)) throw new Error("Runtime busy: an E2E command child is still running; preserve its journal before cleanup.");
420
+ }
421
+ }
422
+ async function assertDescendantScopesCompleted(directory, token) {
423
+ for (const file of await readdir(path.join(directory, "scopes"))) {
424
+ const record = await readScope(directory, file.endsWith(".json") ? file.slice(0, -5) : "");
425
+ if (record.ancestors.includes(token) && !record.completed) throw new Error("Runtime busy: an E2E descendant scope has unfinished cleanup; preserve its journal before parent cleanup.");
426
+ }
427
+ }
428
+ /** 使用现有租约目录与变更锁封存命令子树,阻止存活或迟到子任务与父清理竞争。 */
429
+ async function createMachineChildScope(directory, credential, isHeld, options = {}) {
430
+ const cleanupKey = options.cleanupKey;
431
+ if (cleanupKey !== void 0 && (typeof cleanupKey !== "string" || !cleanupKey.trim())) throw new Error("Machine command scope cleanupKey must be a non-empty string.");
432
+ const token = randomUUID();
433
+ const scoped = {
434
+ pid: credential.pid,
435
+ token,
436
+ scopes: [...credential.scopes, token]
437
+ };
438
+ await mutateLease(directory, async () => {
439
+ if (!isHeld()) throw invalid();
440
+ const owner = await assertMachineCredential(directory, credential);
441
+ await mkdir(path.join(directory, "scopes"), {
442
+ recursive: true,
443
+ mode: 448
444
+ });
445
+ await writeFile(scopeFile(directory, token), JSON.stringify({
446
+ owner,
447
+ ancestors: credential.scopes,
448
+ sealed: false,
449
+ completed: false,
450
+ ...cleanupKey === void 0 ? {} : { cleanupKey }
451
+ }), {
452
+ mode: 384,
453
+ flag: "wx"
454
+ });
455
+ });
456
+ let recovering;
457
+ return {
458
+ environment: { [INHERITED_LEASE_ENV]: JSON.stringify(scoped) },
459
+ recoverStoppedDescendants: (run) => {
460
+ recovering ??= recoverStoppedDescendantScopes(directory, scoped, run).finally(() => {
461
+ recovering = void 0;
462
+ });
463
+ return recovering;
464
+ },
465
+ seal: () => mutateLease(directory, async () => {
466
+ await assertMachineCredential(directory, scoped, true);
467
+ const record = await readScope(directory, token);
468
+ if (!record.sealed) await writeFile(scopeFile(directory, token), JSON.stringify({
469
+ ...record,
470
+ sealed: true
471
+ }), { mode: 384 });
472
+ await assertScopeStopped(directory, token);
473
+ await assertDescendantScopesCompleted(directory, token);
474
+ }),
475
+ complete: () => mutateLease(directory, async () => {
476
+ await assertMachineCredential(directory, scoped, true);
477
+ if (!(await readScope(directory, token)).sealed) throw new Error("Runtime busy: E2E command scope must be sealed before completing its cleanup.");
478
+ await assertScopeStopped(directory, token);
479
+ await assertDescendantScopesCompleted(directory, token);
480
+ const record = await readScope(directory, token);
481
+ await writeFile(scopeFile(directory, token), JSON.stringify({
482
+ ...record,
483
+ completed: true
484
+ }), { mode: 384 });
485
+ })
486
+ };
487
+ }
488
+ //#endregion
489
+ //#region src/lease/machineContext.ts
490
+ const currentMachineLease = new AsyncLocalStorage();
491
+ const publishedCredentials = /* @__PURE__ */ new WeakMap();
492
+ function machineStateDirectory(options) {
493
+ return path.resolve(options.stateDirectory ?? path.join(homedir(), ".local", "state", "weapp-agent"));
494
+ }
495
+ /** 同一凭证仅由最后退出的作用域恢复,避免并发嵌套过早移除父进程环境。 */
496
+ function publishCredential(environment, value) {
497
+ let published = publishedCredentials.get(environment);
498
+ if (!published) {
499
+ if (environment["WEAPP_VITE_E2E_MACHINE_LEASE"] === value) return () => {};
500
+ published = {
501
+ value,
502
+ previous: environment[INHERITED_LEASE_ENV],
503
+ users: 0
504
+ };
505
+ publishedCredentials.set(environment, published);
506
+ environment[INHERITED_LEASE_ENV] = value;
507
+ }
508
+ if (published.value !== value) throw new Error("Runtime busy: another machine lease scope owns this process environment.");
509
+ published.users++;
510
+ return () => {
511
+ if (--published.users > 0) return;
512
+ publishedCredentials.delete(environment);
513
+ if (environment["WEAPP_VITE_E2E_MACHINE_LEASE"] !== published.value) return;
514
+ if (published.previous === void 0) delete environment[INHERITED_LEASE_ENV];
515
+ else environment[INHERITED_LEASE_ENV] = published.previous;
516
+ };
517
+ }
518
+ function inheritedMachineEnvironment(options) {
519
+ const environment = options.env ?? process.env;
520
+ const active = currentMachineLease.getStore();
521
+ if (active?.directory === machineStateDirectory(options) && !active.lease.released) return {
522
+ ...environment,
523
+ ...active.lease.environment
524
+ };
525
+ if (publishedCredentials.has(environment)) {
526
+ const inherited = { ...environment };
527
+ delete inherited[INHERITED_LEASE_ENV];
528
+ return inherited;
529
+ }
530
+ return environment;
531
+ }
532
+ /** 只发布当前持有者上下文;调用方决定何时释放,失败恢复不能隐式完成租约。 */
533
+ async function withMachineLeaseContext(lease, run, options) {
534
+ const restore = publishCredential(options.env ?? process.env, lease.environment[INHERITED_LEASE_ENV]);
535
+ try {
536
+ return await currentMachineLease.run({
537
+ directory: machineStateDirectory(options),
538
+ lease
539
+ }, run);
540
+ } finally {
541
+ restore();
542
+ }
543
+ }
544
+ //#endregion
545
+ //#region src/lease/machine.ts
546
+ /** 同机同用户的所有 E2E 共用一个租约,子进程凭磁盘核验过的能力票据借用。 */
547
+ async function acquireMachineE2ELease(options = {}) {
548
+ const state = machineStateDirectory(options);
549
+ const directory = path.join(state, "machine-e2e");
550
+ const inherited = (options.env ?? process.env)[INHERITED_LEASE_ENV];
551
+ if (!inherited) {
552
+ const owned = await acquireDirectoryLease(directory, "Runtime busy: another E2E operation owns this machine. Wait for its owner to finish before retrying.");
553
+ return {
554
+ borrowed: false,
555
+ get released() {
556
+ return owned.released;
557
+ },
558
+ environment: { [INHERITED_LEASE_ENV]: JSON.stringify(owned.owner) },
559
+ createChildScope: (options) => createMachineChildScope(directory, {
560
+ ...owned.owner,
561
+ scopes: []
562
+ }, () => !owned.released, options),
563
+ release: owned.release
564
+ };
565
+ }
566
+ let credential;
567
+ try {
568
+ credential = parseMachineCredential(inherited);
569
+ } catch {
570
+ throw new Error("Runtime busy: inherited E2E lease is invalid; refusing to bypass the machine lock.");
571
+ }
572
+ const member = {
573
+ pid: process.pid,
574
+ token: randomUUID(),
575
+ scopes: credential.scopes
576
+ };
577
+ const memberFile = path.join(directory, "borrowers", `${member.token}.json`);
578
+ await mkdir(state, {
579
+ recursive: true,
580
+ mode: 448
581
+ });
582
+ await mutateLease(directory, async () => {
583
+ await assertMachineCredential(directory, credential);
584
+ await mkdir(path.dirname(memberFile), {
585
+ recursive: true,
586
+ mode: 448
587
+ });
588
+ await writeFile(memberFile, JSON.stringify(member), { mode: 384 });
589
+ });
590
+ let releasing;
591
+ const lease = {
592
+ borrowed: true,
593
+ released: false,
594
+ environment: { [INHERITED_LEASE_ENV]: inherited },
595
+ createChildScope: (options) => createMachineChildScope(directory, credential, () => !lease.released, options),
596
+ release: () => {
597
+ if (lease.released) return Promise.resolve();
598
+ releasing ??= mutateLease(directory, async () => {
599
+ const registered = await readLeaseOwner(memberFile);
600
+ if (registered?.token !== member.token || registered.pid !== member.pid) throw new Error("Runtime lease borrower ownership changed; refusing cleanup.");
601
+ await rm(memberFile);
602
+ lease.released = true;
603
+ }).finally(() => {
604
+ releasing = void 0;
605
+ });
606
+ return releasing;
607
+ }
608
+ };
609
+ return lease;
610
+ }
611
+ /** 入口持有租约到清理完成;执行与释放同时失败时保留两者,嵌套入口仅释放自己的登记。 */
612
+ async function withMachineE2ELease(run, options = {}) {
613
+ const lease = await acquireMachineE2ELease({
614
+ ...options,
615
+ env: inheritedMachineEnvironment(options)
616
+ });
617
+ let entered = false;
618
+ let failure;
619
+ const release = async () => {
620
+ try {
621
+ await lease.release();
622
+ } catch (releaseError) {
623
+ if (failure) throw new AggregateError([failure.error, releaseError], "E2E operation failed and machine lease cleanup did not complete.", { cause: failure.error });
624
+ throw releaseError;
625
+ }
626
+ };
627
+ try {
628
+ return await withMachineLeaseContext(lease, async () => {
629
+ entered = true;
630
+ try {
631
+ return await run(lease);
632
+ } catch (error) {
633
+ failure = { error };
634
+ throw error;
635
+ } finally {
636
+ await release();
637
+ }
638
+ }, options);
639
+ } catch (error) {
640
+ failure = { error };
641
+ throw error;
642
+ } finally {
643
+ if (!entered) await release();
644
+ }
645
+ }
646
+ //#endregion
647
+ //#region src/lease/machineRecovery.ts
648
+ const currentRecovery = new AsyncLocalStorage();
649
+ /** 读取供显式恢复核对的精确登记快照,不修改租约或推导资源清理权限。 */
650
+ async function readMachineE2ELeaseSnapshot(options = {}) {
651
+ const directory = path.join(machineStateDirectory(options), "machine-e2e");
652
+ return mutateLease(directory, () => readMachineSnapshot(directory));
653
+ }
654
+ function activeRecovery(scope) {
655
+ const context = currentRecovery.getStore();
656
+ if (!context?.active || context.scope !== scope || context.snapshot.scopes.find((item) => item.id === scope.id)?.cleanupKey !== scope.cleanupKey) throw new Error("Machine lease recovery requires the active explicit recovery callback and its original scope.");
657
+ return context;
658
+ }
659
+ /** 核验当前显式恢复回调的原始 scope 与完整接管快照,不授予普通租约额外清理权限。 */
660
+ async function assertMachineE2ELeaseRecoveryScope(scope) {
661
+ const context = activeRecovery(scope);
662
+ await mutateLease(context.directory, async () => {
663
+ activeRecovery(scope);
664
+ await assertMachineSnapshot(context.directory, context.snapshot);
665
+ activeRecovery(scope);
666
+ });
667
+ activeRecovery(scope);
668
+ }
669
+ /** 同步登记完整领域操作;回调漏等候时保留租约,直到在途操作停止后再报告失败。 */
670
+ function withMachineE2ELeaseRecoveryOperation(scope, run) {
671
+ let context;
672
+ try {
673
+ context = activeRecovery(scope);
674
+ } catch (error) {
675
+ return Promise.reject(error);
676
+ }
677
+ const operation = (async () => {
678
+ await assertMachineE2ELeaseRecoveryScope(scope);
679
+ const result = await run();
680
+ await assertMachineE2ELeaseRecoveryScope(scope);
681
+ return result;
682
+ })();
683
+ context.operations.add(operation);
684
+ operation.then(() => context.operations.delete(operation), () => context.operations.delete(operation));
685
+ return operation;
686
+ }
687
+ /**
688
+ * 接管已退出且全部封存的精确租约,按后代优先顺序调用受信清理回调。
689
+ * 回调通过当前根凭证嵌套普通租约,但不能遗留新登记或改变 scope。
690
+ * 失败保留新持有者与未完成 scope,待恢复进程退出后凭新快照重试。
691
+ * 接管中断保留原快照审计;进程崩溃留下的变更锁或混合归属不自动修复。
692
+ */
693
+ async function recoverMachineE2ELease(options) {
694
+ const state = machineStateDirectory(options);
695
+ const directory = path.join(state, "machine-e2e");
696
+ const expected = structuredClone(options.expected);
697
+ const owner = {
698
+ pid: process.pid,
699
+ token: randomUUID()
700
+ };
701
+ const auditFile = path.join(state, "machine-e2e-recoveries", `${owner.token}.jsonl`);
702
+ const adopted = {
703
+ ...expected,
704
+ owner,
705
+ scopes: expected.scopes.map((scope) => ({
706
+ ...scope,
707
+ owner
708
+ }))
709
+ };
710
+ const record = (event) => appendFile(auditFile, `${JSON.stringify({
711
+ at: (/* @__PURE__ */ new Date()).toISOString(),
712
+ ...event
713
+ })}\n`, { mode: 384 });
714
+ await mutateLease(directory, async () => {
715
+ await assertMachineSnapshot(directory, expected);
716
+ assertSnapshotStopped(expected, true);
717
+ await mkdir(path.dirname(auditFile), {
718
+ recursive: true,
719
+ mode: 448
720
+ });
721
+ await assertRecoveryNode(path.dirname(auditFile), "directory");
722
+ await writeFile(auditFile, `${JSON.stringify({
723
+ event: "adopting",
724
+ at: (/* @__PURE__ */ new Date()).toISOString(),
725
+ expected,
726
+ owner
727
+ })}\n`, {
728
+ mode: 384,
729
+ flag: "wx"
730
+ });
731
+ for (const scope of adopted.scopes) {
732
+ const { id, ...value } = scope;
733
+ await writeFile(path.join(directory, "scopes", `${id}.json`), JSON.stringify(value), { mode: 384 });
734
+ }
735
+ await writeFile(path.join(directory, "owner.json"), JSON.stringify(owner), { mode: 384 });
736
+ await record({ event: "adopted" });
737
+ });
738
+ let snapshot = adopted;
739
+ const recoveredScopes = [];
740
+ const lease = {
741
+ borrowed: false,
742
+ released: false,
743
+ environment: { [INHERITED_LEASE_ENV]: JSON.stringify(owner) },
744
+ createChildScope: async () => {
745
+ throw new Error("Recovery callbacks must not create command scopes.");
746
+ },
747
+ release: () => mutateLease(directory, async () => {
748
+ await assertMachineSnapshot(directory, snapshot);
749
+ if (snapshot.scopes.some((scope) => !scope.completed)) throw new Error("Machine lease recovery cannot release incomplete scopes.");
750
+ await record({
751
+ event: "releasing",
752
+ recoveredScopes
753
+ });
754
+ await rm(directory, { recursive: true });
755
+ lease.released = true;
756
+ })
757
+ };
758
+ try {
759
+ await withMachineLeaseContext(lease, async () => {
760
+ const pending = adopted.scopes.filter((scope) => !scope.completed).sort((a, b) => b.ancestors.length - a.ancestors.length);
761
+ for (const scope of pending) {
762
+ await mutateLease(directory, () => assertMachineSnapshot(directory, snapshot));
763
+ const context = {
764
+ directory,
765
+ snapshot,
766
+ scope: recoveryScopeCopy(scope),
767
+ active: true,
768
+ operations: /* @__PURE__ */ new Set()
769
+ };
770
+ let failure;
771
+ try {
772
+ await currentRecovery.run(context, () => options.recoverScope(context.scope));
773
+ } catch (error) {
774
+ failure = { error };
775
+ } finally {
776
+ context.active = false;
777
+ }
778
+ const unfinished = [...context.operations];
779
+ if (unfinished.length) await Promise.allSettled(unfinished);
780
+ if (failure) throw failure.error;
781
+ if (unfinished.length) throw new Error("Machine lease recovery callback ended with unfinished recovery operations; preserving its incomplete scope.");
782
+ await mutateLease(directory, async () => {
783
+ await assertMachineSnapshot(directory, snapshot);
784
+ const { id, ...value } = scope;
785
+ await writeFile(path.join(directory, "scopes", `${id}.json`), JSON.stringify({
786
+ ...value,
787
+ completed: true
788
+ }), { mode: 384 });
789
+ snapshot = {
790
+ ...snapshot,
791
+ scopes: snapshot.scopes.map((item) => item.id === id ? {
792
+ ...item,
793
+ completed: true
794
+ } : item)
795
+ };
796
+ recoveredScopes.push(id);
797
+ await record({
798
+ event: "scope-completed",
799
+ scopeId: id
800
+ });
801
+ });
802
+ }
803
+ await lease.release();
804
+ }, options);
805
+ return {
806
+ auditFile,
807
+ recoveredScopes
808
+ };
809
+ } catch (error) {
810
+ await record({
811
+ event: "failed",
812
+ error: error instanceof Error ? error.message : String(error)
813
+ }).catch(() => {});
814
+ throw error;
815
+ }
816
+ }
817
+ //#endregion
2
818
  //#region src/index.ts
3
819
  const sharedMiniProgramSessions = /* @__PURE__ */ new Map();
4
820
  function normalizeError(hooks, error) {
@@ -6,13 +822,27 @@ function normalizeError(hooks, error) {
6
822
  return normalized instanceof Error ? normalized : new Error(String(normalized));
7
823
  }
8
824
  function resolveSharedMiniProgramSessionKey(options) {
9
- const sessionSuffix = options.sessionId || (options.port ? `port:${options.port}` : "default");
10
- return `${options.projectPath}#${sessionSuffix}`;
825
+ const installation = options.installationId || options.cliPath || options.runtimeProvider || "unresolved";
826
+ return JSON.stringify([
827
+ installation,
828
+ options.projectPath,
829
+ options.sessionId || null,
830
+ options.port || null
831
+ ]);
832
+ }
833
+ function resolveExistingSessionKey(options) {
834
+ const matching = [...sharedMiniProgramSessions].filter(([, entry]) => {
835
+ const current = entry.options;
836
+ const sessionMatches = options.sessionId ? current.sessionId === options.sessionId && (!options.port || current.port === options.port) : !current.sessionId && current.port === options.port;
837
+ return (!options.installationId || current.installationId === options.installationId) && (!options.cliPath || current.cliPath === options.cliPath) && (!options.runtimeProvider || current.runtimeProvider === options.runtimeProvider) && current.projectPath === options.projectPath && sessionMatches;
838
+ });
839
+ return matching.length === 1 ? matching[0][0] : void 0;
11
840
  }
12
841
  /**
13
842
  * @description 获取指定项目的共享 automator 会话;若不存在则自动创建。
14
843
  */
15
844
  async function acquireSharedMiniProgram(hooks, options) {
845
+ options = await hooks.resolveSessionOptions?.(options) ?? options;
16
846
  const sessionKey = resolveSharedMiniProgramSessionKey(options);
17
847
  const existing = sharedMiniProgramSessions.get(sessionKey);
18
848
  if (existing) {
@@ -22,39 +852,52 @@ async function acquireSharedMiniProgram(hooks, options) {
22
852
  const session = hooks.connectMiniProgram(options);
23
853
  const entry = {
24
854
  refs: 1,
855
+ options,
25
856
  session
26
857
  };
27
858
  sharedMiniProgramSessions.set(sessionKey, entry);
28
859
  try {
29
860
  return await session;
30
861
  } catch (error) {
31
- sharedMiniProgramSessions.delete(sessionKey);
862
+ if (sharedMiniProgramSessions.get(sessionKey) === entry) sharedMiniProgramSessions.delete(sessionKey);
32
863
  throw normalizeError(hooks, error);
33
864
  }
34
865
  }
866
+ /** 使用创建连接时的精确键释放订阅。 */
867
+ function releaseSharedMiniProgramByKey(sessionKey) {
868
+ const entry = sharedMiniProgramSessions.get(sessionKey);
869
+ if (entry) {
870
+ entry.refs = Math.max(0, entry.refs - 1);
871
+ if (entry.refs === 0 && entry.closeWhenUnused) {
872
+ sharedMiniProgramSessions.delete(sessionKey);
873
+ entry.session.then((program) => program.disconnect()).catch(() => void 0);
874
+ }
875
+ }
876
+ }
35
877
  /**
36
878
  * @description 释放指定项目的共享会话引用;会话对象会继续缓存,直到显式关闭或重置。
37
879
  */
38
- function releaseSharedMiniProgram(projectPath, sessionIdOrPort) {
39
- const entry = sharedMiniProgramSessions.get(resolveSharedMiniProgramSessionKey({
880
+ function releaseSharedMiniProgram(projectPath, sessionIdOrPort, installation = {}) {
881
+ const key = resolveExistingSessionKey({
882
+ ...installation,
40
883
  projectPath,
41
884
  ...typeof sessionIdOrPort === "number" ? { port: sessionIdOrPort } : {},
42
885
  ...typeof sessionIdOrPort === "string" ? { sessionId: sessionIdOrPort } : {}
43
- }));
44
- if (!entry) return;
45
- entry.refs = Math.max(0, entry.refs - 1);
886
+ });
887
+ if (key) releaseSharedMiniProgramByKey(key);
46
888
  }
47
889
  /**
48
890
  * @description 关闭并移除指定项目的共享 automator 会话。
49
891
  */
50
- async function closeSharedMiniProgram(projectPath, sessionIdOrPort) {
51
- const sessionKey = resolveSharedMiniProgramSessionKey({
892
+ async function closeSharedMiniProgram(projectPath, sessionIdOrPort, installation = {}) {
893
+ const sessionKey = resolveExistingSessionKey({
894
+ ...installation,
52
895
  projectPath,
53
896
  ...typeof sessionIdOrPort === "number" ? { port: sessionIdOrPort } : {},
54
897
  ...typeof sessionIdOrPort === "string" ? { sessionId: sessionIdOrPort } : {}
55
898
  });
56
- const entry = sharedMiniProgramSessions.get(sessionKey);
57
- if (!entry) return;
899
+ const entry = sessionKey ? sharedMiniProgramSessions.get(sessionKey) : void 0;
900
+ if (!entry || !sessionKey) return;
58
901
  sharedMiniProgramSessions.delete(sessionKey);
59
902
  (await entry.session.catch(() => null))?.disconnect();
60
903
  }
@@ -69,15 +912,16 @@ function getSharedMiniProgramSessionCount() {
69
912
  */
70
913
  async function withMiniProgram(hooks, options, runner) {
71
914
  if (options.miniProgram) return await runner(options.miniProgram);
915
+ options = await hooks.resolveSessionOptions?.(options) ?? options;
72
916
  if (options.sharedSession) {
73
917
  const miniProgram = await acquireSharedMiniProgram(hooks, options);
74
918
  try {
75
919
  return await runner(miniProgram);
76
920
  } catch (error) {
77
- await closeSharedMiniProgram(options.projectPath, options.sessionId || options.port);
921
+ await closeSharedMiniProgram(options.projectPath, options.sessionId || options.port, options);
78
922
  throw normalizeError(hooks, error);
79
923
  } finally {
80
- releaseSharedMiniProgram(options.projectPath, options.sessionId || options.port);
924
+ releaseSharedMiniProgram(options.projectPath, options.sessionId || options.port, options);
81
925
  }
82
926
  }
83
927
  let miniProgram = null;
@@ -90,5 +934,28 @@ async function withMiniProgram(hooks, options, runner) {
90
934
  if (miniProgram) miniProgram.disconnect();
91
935
  }
92
936
  }
937
+ /** 检查缓存,供调用方记录其实际创建的连接。 */
938
+ function hasSharedMiniProgram(options) {
939
+ return sharedMiniProgramSessions.has(resolveSharedMiniProgramSessionKey(options));
940
+ }
941
+ /** 只清理仍由调用方创建、且已无活动引用的连接。 */
942
+ async function closeOwnedSharedMiniProgram(options, owned) {
943
+ const key = resolveSharedMiniProgramSessionKey(options);
944
+ const entry = sharedMiniProgramSessions.get(key);
945
+ if (!entry) return;
946
+ const program = await entry.session.catch(() => null);
947
+ if (program !== owned || sharedMiniProgramSessions.get(key) !== entry) return;
948
+ if (entry.refs !== 0) {
949
+ entry.closeWhenUnused = true;
950
+ return;
951
+ }
952
+ sharedMiniProgramSessions.delete(key);
953
+ program?.disconnect();
954
+ }
955
+ /** 单独保留管理器订阅,避免与正在执行的操作共享引用计数。 */
956
+ function retainSharedMiniProgram(sessionKey) {
957
+ const entry = sharedMiniProgramSessions.get(sessionKey);
958
+ if (entry) entry.refs += 1;
959
+ }
93
960
  //#endregion
94
- export { acquireSharedMiniProgram, closeSharedMiniProgram, getSharedMiniProgramSessionCount, readDevtoolsElementSnapshot, releaseSharedMiniProgram, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, resolveSharedMiniProgramSessionKey, toDevtoolsSerializableValue, withMiniProgram };
961
+ export { acquireMachineE2ELease, acquireRuntimeLease, acquireSharedMiniProgram, assertMachineE2ELeaseRecoveryScope, closeOwnedSharedMiniProgram, closeSharedMiniProgram, getSharedMiniProgramSessionCount, hasSharedMiniProgram, mutateLease, readDevtoolsElementSnapshot, readMachineE2ELeaseSnapshot, recoverMachineE2ELease, releaseSharedMiniProgram, releaseSharedMiniProgramByKey, resolveDevtoolsProjectPath, resolveDevtoolsWorkspacePath, resolveSharedMiniProgramSessionKey, retainSharedMiniProgram, runWithRuntimeLease, toDevtoolsSerializableValue, withMachineE2ELease, withMachineE2ELeaseRecoveryOperation, withMiniProgram, withRuntimeLease };
package/dist/mcp.d.ts CHANGED
@@ -8,7 +8,12 @@ export type AutomatorPage = InstanceType<typeof Page>;
8
8
  export type AutomatorElement = InstanceType<typeof Element> & {
9
9
  input?: (value: string) => Promise<void>;
10
10
  };
11
- export interface DevtoolsRuntimeSessionOptions {
11
+ export interface DevtoolsInstallationContext {
12
+ cliPath?: string;
13
+ installationId?: string;
14
+ runtimeProvider?: 'devtools' | 'headless';
15
+ }
16
+ export interface DevtoolsRuntimeSessionOptions extends DevtoolsInstallationContext {
12
17
  miniProgram?: AutomatorMiniProgram;
13
18
  openedOnly?: boolean;
14
19
  preferOpenedSession?: boolean;
@@ -20,10 +25,14 @@ export interface DevtoolsRuntimeSessionOptions {
20
25
  timeout?: number;
21
26
  }
22
27
  export interface DevtoolsRuntimeHooks {
28
+ /** 在缓存查找前固定安装上下文,供连接、复用和清理共用。 */
29
+ resolveSessionOptions?: (options: DevtoolsRuntimeSessionOptions) => Promise<DevtoolsRuntimeSessionOptions>;
30
+ /** 项目编译后沿用已固定的安装上下文刷新 IDE。 */
31
+ prepareProject?: (projectPath: string, signal: AbortSignal, options?: DevtoolsInstallationContext) => Promise<unknown>;
23
32
  connectMiniProgram: (options: DevtoolsRuntimeSessionOptions) => Promise<AutomatorMiniProgram>;
24
33
  normalizeConnectionError?: (error: unknown) => unknown;
25
34
  }
26
- export interface DevtoolsConnectionInput {
35
+ export interface DevtoolsConnectionInput extends DevtoolsInstallationContext {
27
36
  projectPath: string;
28
37
  timeout?: number;
29
38
  openedOnly?: boolean;
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Buffer } from "node:buffer";
2
1
  import path from "node:path";
2
+ import { Buffer } from "node:buffer";
3
3
  //#region src/mcp.ts
4
4
  function resolveDevtoolsProjectPath(workspaceRoot, projectPath) {
5
5
  return path.isAbsolute(projectPath) ? path.normalize(projectPath) : path.resolve(workspaceRoot, projectPath);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@weapp-vite/devtools-runtime",
3
3
  "type": "module",
4
- "version": "0.4.24",
4
+ "version": "0.5.0",
5
5
  "description": "Shared WeChat DevTools runtime automation helpers for weapp-vite packages",
6
6
  "author": "ice breaker <1324318532@qq.com>",
7
7
  "license": "MIT",
@@ -49,13 +49,15 @@
49
49
  "registry": "https://registry.npmjs.org"
50
50
  },
51
51
  "dependencies": {
52
- "@weapp-vite/miniprogram-automator": "1.2.23"
52
+ "@weapp-vite/miniprogram-automator": "1.3.0"
53
53
  },
54
54
  "scripts": {
55
55
  "dev": "tsdown -w --sourcemap",
56
56
  "build": "tsdown",
57
57
  "test": "vitest run",
58
58
  "test:dev": "vitest",
59
+ "test:types": "pnpm build && pnpm run test:types:check",
60
+ "test:types:check": "tsd --files \"test-d/**/*.test-d.ts\"",
59
61
  "typecheck": "tsc --noEmit",
60
62
  "lint": "eslint .",
61
63
  "lint:fix": "eslint . --fix"