@steerable/agent-shell 0.6.56 → 0.6.57

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.
@@ -5,6 +5,9 @@
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
+ import { getUserDataDir } from '../runtime.js';
9
+ import { fileLockPath } from '../storage/process-locks.js';
10
+ import { acquireWriteLease } from '../storage/write-lease.js';
8
11
  import { getSkillsDir, getUserSkillsDir, listSkillRoots } from './skill-loader.js';
9
12
  export function resolveUserPath(inputPath) {
10
13
  const expanded = inputPath.startsWith('~')
@@ -36,8 +39,14 @@ export function installSkillFromDirectory(sourceDir) {
36
39
  }
37
40
  const skillName = parseSkillNameFromMarkdown(fs.readFileSync(skillMdPath, 'utf8'), path.basename(sourceDir));
38
41
  const dest = path.join(getUserSkillsDir(), skillName);
39
- fs.mkdirSync(dest, { recursive: true });
40
- fs.cpSync(sourceDir, dest, { recursive: true });
42
+ const lease = acquireWriteLease(fileLockPath(getUserDataDir(), 'user-skills'), 5_000);
43
+ try {
44
+ fs.mkdirSync(dest, { recursive: true });
45
+ fs.cpSync(sourceDir, dest, { recursive: true });
46
+ }
47
+ finally {
48
+ lease.release();
49
+ }
41
50
  return { name: skillName, dest };
42
51
  }
43
52
  function isInside(candidate, root) {
package/dist/log.d.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
2
+ /**
3
+ * 终端回显范围。`routine` 关掉 log/info/debug/warn,error 仍走 stderr;
4
+ * `all` 连 error 一起关掉,给占满整个终端的界面用。日志文件照写。
5
+ */
6
+ export type LogConsoleSilence = 'routine' | 'all';
7
+ /**
8
+ * 关掉宿主日志和 console 的终端回显,直到 {@link restoreLogConsole}。
9
+ * 命令行占用 stdout 输出结果或画界面时调用。
10
+ * @param scope 关掉的范围。
11
+ */
12
+ export declare function silenceLogConsole(scope: LogConsoleSilence): void;
13
+ /** 恢复调用 {@link silenceLogConsole} 之前的 console。 */
14
+ export declare function restoreLogConsole(): void;
2
15
  export declare const log: {
3
16
  error: (...args: unknown[]) => void;
4
17
  warn: (...args: unknown[]) => void;
package/dist/log.js CHANGED
@@ -8,6 +8,27 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { getUserDataDir } from './runtime.js';
11
+ const consoleMethods = ['log', 'info', 'debug', 'warn', 'error'];
12
+ const routineMethods = ['log', 'info', 'debug', 'warn'];
13
+ let saved = null;
14
+ /**
15
+ * 关掉宿主日志和 console 的终端回显,直到 {@link restoreLogConsole}。
16
+ * 命令行占用 stdout 输出结果或画界面时调用。
17
+ * @param scope 关掉的范围。
18
+ */
19
+ export function silenceLogConsole(scope) {
20
+ saved ??= Object.fromEntries(consoleMethods.map((name) => [name, console[name]]));
21
+ for (const name of scope === 'all' ? consoleMethods : routineMethods)
22
+ console[name] = () => { };
23
+ }
24
+ /** 恢复调用 {@link silenceLogConsole} 之前的 console。 */
25
+ export function restoreLogConsole() {
26
+ if (!saved)
27
+ return;
28
+ for (const name of consoleMethods)
29
+ console[name] = saved[name];
30
+ saved = null;
31
+ }
11
32
  function formatArg(part) {
12
33
  if (typeof part === 'string')
13
34
  return part;
@@ -5,4 +5,4 @@
5
5
  *
6
6
  * 契约全文与设计约束见 packages/pack-sdk/types/index.d.ts 头注释。
7
7
  */
8
- export type { ScenarioId, ToolMode, ToolExposure, PackToolContext, PackToolHandler, ToolContribution, ServiceFactory, ServiceCreateContext, MigrationContribution, SkillContribution, AgentSeedIdentity, AgentSeed, HttpRouteContribution, HttpRouteRequest, MainContribution, ComponentRef, SettingsPanelContribution, RouteContribution, ChatSlotContribution, CardRendererContribution, RendererContribution, BrandSpec, PackagingSpec, ScenarioPack, PackBackendRouteResponse, PackBackendRouteRequest, PackBackendRoute, PackTurnObserver, PackTurnHooks, } from '@steerable/pack-sdk';
8
+ export type { ScenarioId, ToolMode, ToolExposure, PackToolContext, PackToolHandler, ToolContribution, ServiceFactory, ServiceCreateContext, MigrationContribution, SkillContribution, AgentSeedIdentity, AgentSeed, HttpRouteContribution, HttpRouteRequest, MainContribution, ComponentRef, SettingsPanelContribution, RouteContribution, ChatSlotContribution, CardRendererContribution, RendererContribution, BrandSpec, PackagingSpec, CliCommandIO, CliCommandSpec, ScenarioPack, PackBackendRouteResponse, PackBackendRouteRequest, PackBackendRoute, PackTurnObserver, PackTurnHooks, } from '@steerable/pack-sdk';
@@ -75,9 +75,13 @@ export function createProcessSpawnHandler() {
75
75
  function runHelper(helperPath, spec) {
76
76
  return new Promise((resolve, reject) => {
77
77
  const child = spawn(helperPath, [], {
78
- stdio: ['pipe', 'pipe', 'inherit'],
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
79
  windowsHide: true,
80
80
  });
81
+ createInterface({ input: child.stderr }).on('line', (line) => {
82
+ if (line.trim())
83
+ log.warn('[host-spawn] helper', line);
84
+ });
81
85
  let stdout = '';
82
86
  let stderr = '';
83
87
  let truncated = false;
@@ -34,6 +34,8 @@ export interface StorageDriver {
34
34
  scoped(scope: TenantScope): ScopedStore;
35
35
  packAccess(scope: TenantScope): PackDbAccess;
36
36
  applyPackMigrations(): Promise<void>;
37
+ /** Notifies when another connection commits. Optional for non-sqlite drivers. */
38
+ watchChanges?(onChange: () => void): void;
37
39
  close(): Promise<void>;
38
40
  }
39
41
  export type StorageDriverFactory = () => StorageDriver | Promise<StorageDriver>;
@@ -53,5 +55,7 @@ export declare function initializeStorage(): Promise<StorageDriver>;
53
55
  export declare function getScopedStore(scope: TenantScope): ScopedStore;
54
56
  /** Returns scope-bound scenario-pack access after initialization. */
55
57
  export declare function getPackDbAccess(scope: TenantScope): PackDbAccess;
58
+ /** Forwards another connection's commits to the host event bus. */
59
+ export declare function watchStorageChanges(onChange: () => void): void;
56
60
  /** Closes the active driver. Mainly used by host shutdown and tests. */
57
61
  export declare function closeStorage(): Promise<void>;
@@ -90,6 +90,10 @@ export function getPackDbAccess(scope) {
90
90
  }
91
91
  return activeDriver.packAccess(scope);
92
92
  }
93
+ /** Forwards another connection's commits to the host event bus. */
94
+ export function watchStorageChanges(onChange) {
95
+ activeDriver?.watchChanges?.(onChange);
96
+ }
93
97
  /** Closes the active driver. Mainly used by host shutdown and tests. */
94
98
  export async function closeStorage() {
95
99
  const driver = activeDriver;
@@ -301,9 +301,8 @@ export declare class SqliteScopedStore implements ScopedStore {
301
301
  */
302
302
  saveTaskProcess(taskId: string, processJson: string): Promise<void>;
303
303
  /**
304
- * 启动清扫:running 任务只可能属于上一个已死的进程(任务流不跨进程
305
- * 存活),全部落成 failed/interrupted——与 W7-1 turn_active 检测器同
306
- * 理,残留 running 是崩溃签名,不是真相。
304
+ * 启动清扫:只把所属实例已经退出的 running / blocked 任务标成 failed。
305
+ * 没有 owner 的旧行视为已退出。仍被实例锁占住的任务保持原状态。
307
306
  */
308
307
  failRunningTasks(reason: string): Promise<number>;
309
308
  /**
@@ -343,6 +342,12 @@ export declare class SqliteScopedStore implements ScopedStore {
343
342
  kind?: InsightKind;
344
343
  limit?: number;
345
344
  }): Promise<InsightOutboxRow[]>;
345
+ /**
346
+ * Claims pending outbox rows for this process before upload.
347
+ * A row owned by a live instance is left alone. Without a bound instance
348
+ * this falls back to a plain list (single-process callers and tests).
349
+ */
350
+ claimInsightsForUpload(limit: number): Promise<InsightOutboxRow[]>;
346
351
  markInsightUploaded(id: string): Promise<void>;
347
352
  markInsightUploadError(id: string, error: string): Promise<void>;
348
353
  insightStats(): Promise<{
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from 'crypto';
2
+ import path from 'node:path';
2
3
  import { ALL_ROUND_ASSISTANT_AGENT_ID, LOCAL_ASSISTANT_AGENT_ID, getBrand, } from '../brand.js';
3
4
  import { isShellBuiltinAgentEnabled } from '../product-config.js';
4
5
  import { DEFAULT_LLM_SETTINGS, llmSettingsCarryExpiredBakedKey, llmSettingsMatchOllamaDefaults, mergeLlmSettings, } from './llm-settings.js';
5
6
  import { MESSAGE_ORDER_DESC } from './message-order.js';
7
+ import { currentStorageInstanceId, instanceOwnerIsLive, } from './process-locks.js';
6
8
  import { mergeTelemetrySettings, } from './telemetry-settings.js';
7
9
  import { mergeWebSearchSettings, } from './web-search-settings.js';
8
10
  import { mergeInsightsProfile, mergeInsightsSettings, } from './insights-settings.js';
@@ -325,6 +327,10 @@ export class SqliteScopedStore {
325
327
  this.ensureColumn('tasks', 'depends_on', 'TEXT');
326
328
  // 后台任务推理过程:流事件回写,不bump updated_at(见 saveTaskProcess)。
327
329
  this.ensureColumn('tasks', 'process_json', 'TEXT');
330
+ // 创建该任务的宿主实例。清扫只处理所属实例已经退出的任务。
331
+ this.ensureColumn('tasks', 'owner_instance_id', 'TEXT');
332
+ this.ensureColumn('insights_outbox', 'claimed_by', 'TEXT');
333
+ this.ensureColumn('insights_outbox', 'claimed_at', 'TEXT');
328
334
  // 智能体技能面(增量迁移):load_all_skills = 无视触发条件全量加载。
329
335
  // 此前这个行为是 router 里 `chatAgentId === 'all-round-assistant'` 的
330
336
  // 硬编码,自建智能体拿不到;建列时一次性回填给内置「智能助手」,之后
@@ -1013,12 +1019,12 @@ export class SqliteScopedStore {
1013
1019
  tenant_id, user_id,
1014
1020
  id, chat_id, task, status, answer, error,
1015
1021
  worktree_path, worktree_branch, worktree_state, record_id, trace_id,
1016
- depends_on, created_at, updated_at
1017
- ) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, NULL, ?, ?, ?)
1022
+ depends_on, owner_instance_id, created_at, updated_at
1023
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, ?)
1018
1024
  `).run(this.scope.tenantId, this.scope.userId, id, input.chatId, input.task, input.initialStatus ?? 'running', input.worktreePath ?? null, input.worktreeBranch ?? null,
1019
1025
  // worktree 任务一创建就进入 pending(等待终态后合并/丢弃);
1020
1026
  // 非 worktree 任务该列无意义,保持 NULL。
1021
- input.worktreePath ? 'pending' : null, input.recordId ?? null, input.dependsOn?.length ? JSON.stringify(input.dependsOn) : null, now, now);
1027
+ input.worktreePath ? 'pending' : null, input.recordId ?? null, input.dependsOn?.length ? JSON.stringify(input.dependsOn) : null, currentStorageInstanceId(), now, now);
1022
1028
  const row = this.db.prepare(`SELECT * FROM tasks WHERE tenant_id = ? AND user_id = ? AND id = ?`).get(this.scope.tenantId, this.scope.userId, id);
1023
1029
  return this.mapTask(row);
1024
1030
  }
@@ -1071,29 +1077,31 @@ export class SqliteScopedStore {
1071
1077
  .run(processJson, this.scope.tenantId, this.scope.userId, taskId);
1072
1078
  }
1073
1079
  /**
1074
- * 启动清扫:running 任务只可能属于上一个已死的进程(任务流不跨进程
1075
- * 存活),全部落成 failed/interrupted——与 W7-1 turn_active 检测器同
1076
- * 理,残留 running 是崩溃签名,不是真相。
1080
+ * 启动清扫:只把所属实例已经退出的 running / blocked 任务标成 failed。
1081
+ * 没有 owner 的旧行视为已退出。仍被实例锁占住的任务保持原状态。
1077
1082
  */
1078
1083
  async failRunningTasks(reason) {
1079
- const info = this.db
1080
- .prepare(`UPDATE tasks SET status = 'failed', error = ?, updated_at = ?
1081
- WHERE tenant_id = ? AND user_id = ? AND status = 'running'`)
1082
- .run(reason, new Date().toISOString(), this.scope.tenantId, this.scope.userId);
1083
- // 级联:blocked 任务的点火能力在 TaskService(进程内),不跨进程存活;
1084
- // 重启后统一标 failed,模型经 task_status 看到原因后可重跑。
1085
- const blocked = this.db
1086
- .prepare(`SELECT id FROM tasks
1087
- WHERE tenant_id = ? AND user_id = ? AND status = 'blocked'`)
1084
+ const rows = this.db
1085
+ .prepare(`SELECT id, status, owner_instance_id AS owner
1086
+ FROM tasks
1087
+ WHERE tenant_id = ? AND user_id = ? AND status IN ('running', 'blocked')`)
1088
1088
  .all(this.scope.tenantId, this.scope.userId);
1089
+ const dataDir = path.dirname(this.db.name);
1089
1090
  const now = new Date().toISOString();
1090
- for (const row of blocked) {
1091
- this.db
1091
+ let swept = 0;
1092
+ for (const row of rows) {
1093
+ if (row.owner && instanceOwnerIsLive(dataDir, row.owner))
1094
+ continue;
1095
+ const error = row.status === 'blocked'
1096
+ ? '进程重启中断了编排等待(依赖任务已随进程终止);请重新 task_run。'
1097
+ : reason;
1098
+ const info = this.db
1092
1099
  .prepare(`UPDATE tasks SET status = 'failed', error = ?, updated_at = ?
1093
- WHERE tenant_id = ? AND user_id = ? AND id = ?`)
1094
- .run(`进程重启中断了编排等待(依赖任务已随进程终止);请重新 task_run。`, now, this.scope.tenantId, this.scope.userId, row.id);
1100
+ WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = ?`)
1101
+ .run(error, now, this.scope.tenantId, this.scope.userId, row.id, row.status);
1102
+ swept += info.changes;
1095
1103
  }
1096
- return info.changes + blocked.length;
1104
+ return swept;
1097
1105
  }
1098
1106
  // ─── W6-9 用量与成本归因 ─────────────────────────────────────────────
1099
1107
  /**
@@ -1247,6 +1255,37 @@ export class SqliteScopedStore {
1247
1255
  const rows = this.db.prepare(sql).all(...params);
1248
1256
  return rows.map((row) => this.mapInsightOutbox(row));
1249
1257
  }
1258
+ /**
1259
+ * Claims pending outbox rows for this process before upload.
1260
+ * A row owned by a live instance is left alone. Without a bound instance
1261
+ * this falls back to a plain list (single-process callers and tests).
1262
+ */
1263
+ async claimInsightsForUpload(limit) {
1264
+ const me = currentStorageInstanceId();
1265
+ if (!me)
1266
+ return this.listInsightOutbox({ uploaded: false, limit });
1267
+ const capped = Math.max(1, Math.min(limit, 500));
1268
+ const pending = this.db.prepare(`SELECT id, claimed_by AS claimedBy FROM insights_outbox
1269
+ WHERE tenant_id = ? AND user_id = ? AND uploaded_at IS NULL
1270
+ ORDER BY created_at ASC LIMIT ?`).all(this.scope.tenantId, this.scope.userId, capped);
1271
+ const dataDir = path.dirname(this.db.name);
1272
+ const now = new Date().toISOString();
1273
+ const claimed = [];
1274
+ for (const row of pending) {
1275
+ if (row.claimedBy && row.claimedBy !== me && instanceOwnerIsLive(dataDir, row.claimedBy)) {
1276
+ continue;
1277
+ }
1278
+ const info = this.db.prepare(`UPDATE insights_outbox SET claimed_by = ?, claimed_at = ?
1279
+ WHERE tenant_id = ? AND user_id = ? AND id = ? AND uploaded_at IS NULL
1280
+ AND (claimed_by IS NULL OR claimed_by = ? OR claimed_by IS ?)`).run(me, now, this.scope.tenantId, this.scope.userId, row.id, me, row.claimedBy);
1281
+ if (info.changes !== 1)
1282
+ continue;
1283
+ const stored = this.db.prepare(`SELECT * FROM insights_outbox WHERE tenant_id = ? AND user_id = ? AND id = ?`).get(this.scope.tenantId, this.scope.userId, row.id);
1284
+ if (stored)
1285
+ claimed.push(this.mapInsightOutbox(stored));
1286
+ }
1287
+ return claimed;
1288
+ }
1250
1289
  async markInsightUploaded(id) {
1251
1290
  this.db.prepare(`UPDATE insights_outbox SET uploaded_at = ?, upload_error = NULL
1252
1291
  WHERE tenant_id = ? AND user_id = ? AND id = ?`).run(new Date().toISOString(), this.scope.tenantId, this.scope.userId, id);
@@ -0,0 +1,32 @@
1
+ import { type HeldWriteLease } from './write-lease.js';
2
+ export declare const HOST_SCHEMA_VERSION = 1;
3
+ /** How long a starting host waits for another host to finish migrating. */
4
+ export declare const OPEN_LOCK_WAIT_MS = 15000;
5
+ export declare class ChatBusyError extends Error {
6
+ readonly chatId: string;
7
+ readonly code = "chat_busy";
8
+ constructor(chatId: string);
9
+ }
10
+ export declare class StorageUpgradeBlockedError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ /** Records which instance owns rows created by this process. */
14
+ export declare function bindStorageInstance(instanceId: string | null): void;
15
+ export declare function currentStorageInstanceId(): string | null;
16
+ export declare function chatLockPath(userDataDir: string, chatId: string): string;
17
+ export declare function instanceLockPath(userDataDir: string, instanceId: string): string;
18
+ export declare function fileLockPath(userDataDir: string, name: string): string;
19
+ export declare function openLockPath(dbPath: string): string;
20
+ /** Exclusive lock for one chat. Fails immediately when another process holds it. */
21
+ export declare function acquireChatWriteLock(userDataDir: string, chatId: string): HeldWriteLease;
22
+ export interface InstanceLease {
23
+ readonly instanceId: string;
24
+ release(): void;
25
+ }
26
+ /** Held until this host process exits. A dead process's lock can be taken. */
27
+ export declare function acquireInstanceLease(userDataDir: string): InstanceLease;
28
+ /**
29
+ * True when `instanceId` still holds its lease.
30
+ * A missing or unlocked file means the owner is dead.
31
+ */
32
+ export declare function instanceOwnerIsLive(userDataDir: string, instanceId: string): boolean;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Per-chat and per-process locks for one user-data directory.
3
+ *
4
+ * The host database itself is shared (WAL). These locks are the units that
5
+ * stay exclusive: one running turn per chat, and one live owner per task.
6
+ */
7
+ import { randomUUID } from 'node:crypto';
8
+ import path from 'node:path';
9
+ import { acquireWriteLease, StoreAlreadyOwnedError, } from './write-lease.js';
10
+ export const HOST_SCHEMA_VERSION = 1;
11
+ /** How long a starting host waits for another host to finish migrating. */
12
+ export const OPEN_LOCK_WAIT_MS = 15_000;
13
+ export class ChatBusyError extends Error {
14
+ chatId;
15
+ code = 'chat_busy';
16
+ constructor(chatId) {
17
+ super(`chat busy: ${chatId}`);
18
+ this.chatId = chatId;
19
+ this.name = 'ChatBusyError';
20
+ }
21
+ }
22
+ export class StorageUpgradeBlockedError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'StorageUpgradeBlockedError';
26
+ }
27
+ }
28
+ let boundInstanceId = null;
29
+ /** Records which instance owns rows created by this process. */
30
+ export function bindStorageInstance(instanceId) {
31
+ boundInstanceId = instanceId;
32
+ }
33
+ export function currentStorageInstanceId() {
34
+ return boundInstanceId;
35
+ }
36
+ function assertLockId(id) {
37
+ if (id.length === 0 || id.length > 200 || /[\\/\0]/.test(id)) {
38
+ throw new Error(`invalid lock id: ${id}`);
39
+ }
40
+ }
41
+ export function chatLockPath(userDataDir, chatId) {
42
+ assertLockId(chatId);
43
+ return path.join(userDataDir, 'locks', 'chat', `${chatId}.lock`);
44
+ }
45
+ export function instanceLockPath(userDataDir, instanceId) {
46
+ assertLockId(instanceId);
47
+ return path.join(userDataDir, 'locks', 'instance', `${instanceId}.lock`);
48
+ }
49
+ export function fileLockPath(userDataDir, name) {
50
+ assertLockId(name);
51
+ return path.join(userDataDir, 'locks', 'file', `${name}.lock`);
52
+ }
53
+ export function openLockPath(dbPath) {
54
+ const parsed = path.parse(path.resolve(dbPath));
55
+ return path.join(parsed.dir, `${parsed.name}.open.lock`);
56
+ }
57
+ /** Exclusive lock for one chat. Fails immediately when another process holds it. */
58
+ export function acquireChatWriteLock(userDataDir, chatId) {
59
+ try {
60
+ return acquireWriteLease(chatLockPath(userDataDir, chatId));
61
+ }
62
+ catch (error) {
63
+ if (error instanceof StoreAlreadyOwnedError)
64
+ throw new ChatBusyError(chatId);
65
+ throw error;
66
+ }
67
+ }
68
+ /** Held until this host process exits. A dead process's lock can be taken. */
69
+ export function acquireInstanceLease(userDataDir) {
70
+ const instanceId = randomUUID();
71
+ const lease = acquireWriteLease(instanceLockPath(userDataDir, instanceId));
72
+ return {
73
+ instanceId,
74
+ release: () => lease.release(),
75
+ };
76
+ }
77
+ /**
78
+ * True when `instanceId` still holds its lease.
79
+ * A missing or unlocked file means the owner is dead.
80
+ */
81
+ export function instanceOwnerIsLive(userDataDir, instanceId) {
82
+ let lockPath;
83
+ try {
84
+ lockPath = instanceLockPath(userDataDir, instanceId);
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ try {
90
+ const probe = acquireWriteLease(lockPath);
91
+ probe.release();
92
+ return false;
93
+ }
94
+ catch (error) {
95
+ if (error instanceof StoreAlreadyOwnedError)
96
+ return true;
97
+ throw error;
98
+ }
99
+ }
@@ -96,6 +96,7 @@ export interface ScopedStore {
96
96
  kind?: InsightKind;
97
97
  limit?: number;
98
98
  }): Promise<InsightOutboxRow[]>;
99
+ claimInsightsForUpload(limit: number): Promise<InsightOutboxRow[]>;
99
100
  markInsightUploaded(id: string): Promise<void>;
100
101
  markInsightUploadError(id: string, error: string): Promise<void>;
101
102
  insightStats(): Promise<{
@@ -3,13 +3,15 @@ import type { ScopedStore } from './scoped-store.js';
3
3
  /** Default local SQLite storage driver. */
4
4
  export declare class SqliteStorageDriver implements StorageDriver {
5
5
  private db;
6
- private writeLease;
6
+ private openLease;
7
+ private changeTimer;
7
8
  private localSqliteStore;
8
9
  private readonly stores;
9
10
  initialize(): Promise<void>;
10
11
  scoped(scope: TenantScope): ScopedStore;
11
12
  packAccess(scope: TenantScope): PackDbAccess;
12
13
  applyPackMigrations(): Promise<void>;
14
+ watchChanges(onChange: () => void): void;
13
15
  close(): Promise<void>;
14
16
  private requireDb;
15
17
  private scopeKey;
@@ -1,11 +1,14 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import Database from 'better-sqlite3';
3
4
  import { getProductConfig } from '../product-config.js';
4
5
  import { getUserDataDir } from '../runtime.js';
5
6
  import { LOCAL_SCOPE } from './driver.js';
6
7
  import { SqliteScopedStore } from './index.js';
7
- import { acquireWriteLease, lockPathForDb, } from './write-lease.js';
8
+ import { HOST_SCHEMA_VERSION, OPEN_LOCK_WAIT_MS, StorageUpgradeBlockedError, openLockPath, } from './process-locks.js';
9
+ import { acquireSharedLease, acquireWriteLease, lockPathForDb, StoreAlreadyOwnedError, } from './write-lease.js';
8
10
  import { acquireWriteLeaseOrExit } from './write-lease-error.js';
11
+ const MAIN_DB_BUSY_TIMEOUT_MS = 5_000;
9
12
  function invokeStatement(statement, operation, params) {
10
13
  if (Array.isArray(params))
11
14
  return statement[operation](...params);
@@ -71,32 +74,92 @@ class SqlitePackDbAccess {
71
74
  }
72
75
  }
73
76
  }
77
+ function readUserVersion(dbPath) {
78
+ if (!existsSync(dbPath))
79
+ return 0;
80
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 5_000 });
81
+ try {
82
+ return Number(db.pragma('user_version', { simple: true }));
83
+ }
84
+ finally {
85
+ db.close();
86
+ }
87
+ }
88
+ function blockUpgrade(message) {
89
+ console.error(`[bs] storage upgrade blocked: ${message}`);
90
+ queueMicrotask(() => process.exit(75));
91
+ throw new StorageUpgradeBlockedError(message);
92
+ }
74
93
  /** Default local SQLite storage driver. */
75
94
  export class SqliteStorageDriver {
76
95
  db = null;
77
- writeLease = null;
96
+ openLease = null;
97
+ changeTimer = null;
78
98
  localSqliteStore = null;
79
99
  stores = new Map();
80
100
  async initialize() {
81
101
  if (this.db)
82
102
  return;
83
103
  const dbPath = path.join(getUserDataDir(), getProductConfig().dbFileName ?? 'agent-shell.db');
84
- this.writeLease = acquireWriteLeaseOrExit(() => acquireWriteLease(lockPathForDb(dbPath)));
104
+ // A previous build held this lock for the whole process. If it is still
105
+ // held, that process cannot share the database, so refuse to migrate
106
+ // or to open alongside it.
107
+ acquireWriteLeaseOrExit(() => {
108
+ const legacy = acquireWriteLease(lockPathForDb(dbPath));
109
+ legacy.release();
110
+ });
111
+ const gate = openLockPath(dbPath);
112
+ let version = readUserVersion(dbPath);
113
+ if (version > HOST_SCHEMA_VERSION) {
114
+ blockUpgrade(`本地数据库版本 ${version} 高于本程序支持的版本 ${HOST_SCHEMA_VERSION}。请升级后再打开。`);
115
+ }
116
+ let exclusive = null;
117
+ if (version === HOST_SCHEMA_VERSION) {
118
+ // Make sure the open-lock table exists, then register as a shared holder.
119
+ // A live shared holder rejects this exclusive probe immediately.
120
+ try {
121
+ acquireWriteLease(gate).release();
122
+ }
123
+ catch (error) {
124
+ if (!(error instanceof StoreAlreadyOwnedError))
125
+ throw error;
126
+ }
127
+ }
128
+ else {
129
+ try {
130
+ exclusive = acquireWriteLease(gate, OPEN_LOCK_WAIT_MS);
131
+ }
132
+ catch (error) {
133
+ if (!(error instanceof StoreAlreadyOwnedError))
134
+ throw error;
135
+ version = readUserVersion(dbPath);
136
+ if (version !== HOST_SCHEMA_VERSION) {
137
+ blockUpgrade('本地数据库需要升级,但仍被另一个进程打开。请先关闭正在运行的应用及其命令行任务后再升级。');
138
+ }
139
+ }
140
+ }
85
141
  let db = null;
86
142
  try {
87
- db = new Database(dbPath);
143
+ db = new Database(dbPath, { timeout: MAIN_DB_BUSY_TIMEOUT_MS });
144
+ db.pragma(`busy_timeout = ${MAIN_DB_BUSY_TIMEOUT_MS}`);
88
145
  db.pragma('journal_mode = WAL');
89
146
  db.pragma('foreign_keys = ON');
90
147
  this.db = db;
91
148
  const local = new SqliteScopedStore(db, LOCAL_SCOPE);
92
149
  await local.initialize();
150
+ db.pragma(`user_version = ${HOST_SCHEMA_VERSION}`);
93
151
  this.localSqliteStore = local;
94
152
  this.stores.set(this.scopeKey(LOCAL_SCOPE), local);
153
+ exclusive?.release();
154
+ exclusive = null;
155
+ this.openLease = acquireSharedLease(gate);
95
156
  }
96
157
  catch (error) {
158
+ exclusive?.release();
97
159
  db?.close();
98
- this.writeLease.release();
99
- this.writeLease = null;
160
+ this.db = null;
161
+ this.openLease?.release();
162
+ this.openLease = null;
100
163
  throw error;
101
164
  }
102
165
  }
@@ -116,13 +179,31 @@ export class SqliteStorageDriver {
116
179
  async applyPackMigrations() {
117
180
  this.localSqliteStore?.applyPackMigrations();
118
181
  }
182
+ watchChanges(onChange) {
183
+ const db = this.requireDb();
184
+ if (this.changeTimer)
185
+ return;
186
+ let version = Number(db.pragma('data_version', { simple: true }));
187
+ this.changeTimer = setInterval(() => {
188
+ const next = Number(db.pragma('data_version', { simple: true }));
189
+ if (next === version)
190
+ return;
191
+ version = next;
192
+ onChange();
193
+ }, 1_000);
194
+ this.changeTimer.unref?.();
195
+ }
119
196
  async close() {
197
+ if (this.changeTimer) {
198
+ clearInterval(this.changeTimer);
199
+ this.changeTimer = null;
200
+ }
120
201
  this.stores.clear();
121
202
  this.db?.close();
122
203
  this.db = null;
123
204
  this.localSqliteStore = null;
124
- this.writeLease?.release();
125
- this.writeLease = null;
205
+ this.openLease?.release();
206
+ this.openLease = null;
126
207
  }
127
208
  requireDb() {
128
209
  if (!this.db)
@@ -23,4 +23,10 @@ export interface HeldWriteLease {
23
23
  release(): void;
24
24
  }
25
25
  export declare function lockPathForDb(dbPath: string): string;
26
- export declare function acquireWriteLease(lockPath: string): HeldWriteLease;
26
+ export declare function acquireWriteLease(lockPath: string, timeoutMs?: number): HeldWriteLease;
27
+ /**
28
+ * Shared open registration. Many processes may hold this at once.
29
+ * An exclusive lease on the same file waits until every shared holder closes.
30
+ * The lock file must already contain the lease table (the exclusive opener creates it).
31
+ */
32
+ export declare function acquireSharedLease(lockPath: string): HeldWriteLease;
@@ -36,17 +36,18 @@ export function lockPathForDb(dbPath) {
36
36
  const parsed = path.parse(path.resolve(dbPath));
37
37
  return path.join(parsed.dir, `${parsed.name}.lock`);
38
38
  }
39
- export function acquireWriteLease(lockPath) {
39
+ export function acquireWriteLease(lockPath, timeoutMs = 0) {
40
+ const waitMs = Number.isFinite(timeoutMs) ? Math.max(0, Math.trunc(timeoutMs)) : 0;
40
41
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
41
42
  // Loaded lazily so tests that only exercise lockPathForDb do not need
42
43
  // better-sqlite3's Electron ABI.
43
44
  const Database = require('better-sqlite3');
44
- const db = new Database(lockPath, { timeout: 0 });
45
+ const db = new Database(lockPath, { timeout: waitMs });
45
46
  try {
46
47
  // Every statement here can hit the incumbent's lock, including the
47
48
  // journal_mode pragma — it rewrites the header and so needs the same
48
- // write lock. busy_timeout 0 makes that immediate instead of a stall.
49
- db.pragma('busy_timeout = 0');
49
+ // write lock. timeoutMs 0 makes that immediate instead of a stall.
50
+ db.pragma(`busy_timeout = ${waitMs}`);
50
51
  db.pragma('journal_mode = DELETE');
51
52
  db.exec('CREATE TABLE IF NOT EXISTS lease (id INTEGER PRIMARY KEY)');
52
53
  db.exec('BEGIN EXCLUSIVE');
@@ -72,3 +73,33 @@ export function acquireWriteLease(lockPath) {
72
73
  },
73
74
  };
74
75
  }
76
+ /**
77
+ * Shared open registration. Many processes may hold this at once.
78
+ * An exclusive lease on the same file waits until every shared holder closes.
79
+ * The lock file must already contain the lease table (the exclusive opener creates it).
80
+ */
81
+ export function acquireSharedLease(lockPath) {
82
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
83
+ const Database = require('better-sqlite3');
84
+ const db = new Database(lockPath, { timeout: 5_000 });
85
+ try {
86
+ db.pragma('busy_timeout = 5000');
87
+ db.exec('BEGIN');
88
+ db.prepare('SELECT id FROM lease LIMIT 1').get();
89
+ }
90
+ catch (err) {
91
+ db.close();
92
+ if (isSqliteBusy(err))
93
+ throw new StoreAlreadyOwnedError(lockPath);
94
+ throw err;
95
+ }
96
+ let released = false;
97
+ return {
98
+ release() {
99
+ if (released)
100
+ return;
101
+ released = true;
102
+ db.close();
103
+ },
104
+ };
105
+ }