@actiondock/core 2.20.0 → 2.21.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.
Files changed (67) hide show
  1. package/dist/errors.d.ts +11 -1
  2. package/dist/errors.js +23 -0
  3. package/dist/execution/service.js +36 -5
  4. package/dist/host/host.d.ts +11 -0
  5. package/dist/host/host.js +258 -39
  6. package/dist/host/types.d.ts +10 -1
  7. package/dist/index.d.ts +8 -1
  8. package/dist/index.js +5 -1
  9. package/dist/invocation/types.d.ts +31 -0
  10. package/dist/invocation/types.js +10 -1
  11. package/dist/package/index.d.ts +1 -1
  12. package/dist/package/runtime.js +18 -4
  13. package/dist/platform/node.d.ts +6 -0
  14. package/dist/platform/node.js +59 -16
  15. package/dist/platform/types.d.ts +11 -0
  16. package/dist/process/command-executor.d.ts +35 -0
  17. package/dist/process/command-executor.js +326 -0
  18. package/dist/process/index.d.ts +1 -0
  19. package/dist/process/index.js +1 -0
  20. package/dist/process/process-driver.d.ts +19 -6
  21. package/dist/process/process-driver.js +223 -54
  22. package/dist/process/run-executor.js +101 -47
  23. package/dist/project/dependencies.d.ts +92 -0
  24. package/dist/project/dependencies.js +543 -0
  25. package/dist/project/index.d.ts +3 -0
  26. package/dist/project/index.js +3 -0
  27. package/dist/project/init.js +2 -2
  28. package/dist/project/loader.js +15 -30
  29. package/dist/project/package-manager.d.ts +40 -0
  30. package/dist/project/package-manager.js +224 -0
  31. package/dist/project/preparation.d.ts +8 -0
  32. package/dist/project/preparation.js +130 -0
  33. package/dist/project/transactions.d.ts +5 -1
  34. package/dist/project/transactions.js +35 -75
  35. package/dist/runtime/context.d.ts +21 -2
  36. package/dist/runtime/context.js +42 -17
  37. package/dist/runtime/run-persistence.d.ts +1 -1
  38. package/dist/runtime/run-persistence.js +4 -3
  39. package/dist/security/config-cipher.d.ts +54 -0
  40. package/dist/security/config-cipher.js +190 -0
  41. package/dist/security/config-key.d.ts +35 -0
  42. package/dist/security/config-key.js +469 -0
  43. package/dist/server/routes/config.js +20 -1
  44. package/dist/service/factory.js +3 -0
  45. package/dist/service/types.d.ts +8 -0
  46. package/dist/storage/config-value.d.ts +33 -0
  47. package/dist/storage/config-value.js +102 -0
  48. package/dist/storage/file-lock.js +35 -21
  49. package/dist/storage/index.d.ts +24 -2
  50. package/dist/storage/index.js +65 -2
  51. package/dist/storage/migrations/index.d.ts +40 -0
  52. package/dist/storage/migrations/index.js +96 -0
  53. package/dist/storage/migrations/v2-to-v3.d.ts +16 -0
  54. package/dist/storage/migrations/v2-to-v3.js +116 -0
  55. package/dist/storage/sqlite-config.d.ts +62 -3
  56. package/dist/storage/sqlite-config.js +163 -9
  57. package/dist/storage/sqlite-driver.d.ts +5 -2
  58. package/dist/storage/sqlite-driver.js +24 -3
  59. package/dist/storage/sqlite-schema.d.ts +20 -2
  60. package/dist/storage/sqlite-schema.js +129 -132
  61. package/dist/storage/sqlite.d.ts +7 -2
  62. package/dist/storage/sqlite.js +64 -13
  63. package/dist/storage/types.d.ts +72 -4
  64. package/dist/storage/types.js +7 -2
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/package.json +2 -2
@@ -0,0 +1,102 @@
1
+ import { ActionDockError, CONFIG_DECRYPT_FAILED } from "../errors.js";
2
+ import { decryptConfigValue, encryptConfigValue, } from "../security/config-cipher.js";
3
+ import { isSecretConfigKey } from "./mask.js";
4
+ /**
5
+ * 对保护声明递归剥离脱敏无关信息。
6
+ *
7
+ * 仅保留结构(properties/additionalProperties/items)、类型与 secret
8
+ * 标记;严禁把 default、enum、env、description 等可能携带值的内容
9
+ * 写入保护元数据,即使信封整体已加密也保持最小化。
10
+ */
11
+ function sanitizeProtectionSchema(schema) {
12
+ const copy = {};
13
+ if (schema.type !== undefined)
14
+ copy.type = schema.type;
15
+ if (schema.secret !== undefined)
16
+ copy.secret = schema.secret;
17
+ if (schema.properties) {
18
+ copy.properties = Object.fromEntries(Object.entries(schema.properties).map(([name, child]) => [
19
+ name,
20
+ sanitizeProtectionSchema(child),
21
+ ]));
22
+ }
23
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties === "object") {
24
+ copy.additionalProperties = sanitizeProtectionSchema(schema.additionalProperties);
25
+ }
26
+ if (schema.items !== undefined && typeof schema.items === "object") {
27
+ copy.items = sanitizeProtectionSchema(schema.items);
28
+ }
29
+ return copy;
30
+ }
31
+ /** 对单条保护声明剥离脱敏无关字段(保留 secret/type/schema 结构信息)。 */
32
+ function sanitizeProtectionDeclaration(item) {
33
+ const copy = {};
34
+ if (item.secret !== undefined)
35
+ copy.secret = item.secret;
36
+ if (item.type !== undefined)
37
+ copy.type = item.type;
38
+ if (item.schema)
39
+ copy.schema = sanitizeProtectionSchema(item.schema);
40
+ return copy;
41
+ }
42
+ /**
43
+ * 合并历史保护声明与当前敏感声明,绝不弱化保护强度。
44
+ *
45
+ * - 以规范化 JSON 作为去重标识,防止重复写入导致保护数组无界增长;
46
+ * - 当前声明中仅并入判定敏感的条目(非敏感声明不产生历史保护义务);
47
+ * - 合并结果为空时保守回退为整项敏感,保证密文行始终有脱敏依据。
48
+ */
49
+ export function mergeProtectionDeclarations(stored, current, key) {
50
+ const merged = [];
51
+ const seen = new Set();
52
+ const push = (item) => {
53
+ const sanitized = sanitizeProtectionDeclaration(item);
54
+ const identity = JSON.stringify(sanitized);
55
+ if (!seen.has(identity)) {
56
+ seen.add(identity);
57
+ merged.push(sanitized);
58
+ }
59
+ };
60
+ for (const item of stored ?? []) {
61
+ push(item);
62
+ }
63
+ for (const item of current) {
64
+ if (isSecretConfigKey(key, item)) {
65
+ push(item);
66
+ }
67
+ }
68
+ if (merged.length === 0) {
69
+ merged.push({ secret: true });
70
+ }
71
+ return merged;
72
+ }
73
+ /** 加密业务值与保护声明构成的完整信封,返回规范密文信封字符串。 */
74
+ export function encryptConfigEnvelope(value, protection, scope, key, material) {
75
+ const envelope = { value, protection: [...protection] };
76
+ return encryptConfigValue(envelope, scope, key, material);
77
+ }
78
+ /**
79
+ * 严格解密密文信封并校验负载形态。
80
+ *
81
+ * 信封负载必须为含 value 属性的对象;protection 省略或为声明对象数组。
82
+ * 任何不合规输入抛 CONFIG_DECRYPT_FAILED,绝不把密文降级当作明文返回。
83
+ */
84
+ export function decryptConfigEnvelope(ciphertext, scope, key, material) {
85
+ const payload = decryptConfigValue(ciphertext, scope, key, material);
86
+ if (payload === null ||
87
+ typeof payload !== "object" ||
88
+ Array.isArray(payload) ||
89
+ !Object.prototype.hasOwnProperty.call(payload, "value")) {
90
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Decrypted config payload for key '${key}' in scope '${scope}' is not a valid protection envelope`, { scope, key });
91
+ }
92
+ const envelope = payload;
93
+ if (envelope.protection !== undefined &&
94
+ (!Array.isArray(envelope.protection) ||
95
+ envelope.protection.some((item) => item === null || typeof item !== "object"))) {
96
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Decrypted protection metadata for config key '${key}' in scope '${scope}' is malformed`, { scope, key });
97
+ }
98
+ return {
99
+ value: envelope.value,
100
+ protection: envelope.protection ?? [],
101
+ };
102
+ }
@@ -292,6 +292,10 @@ function* acquireLockMachine(lockPath, options, isAsync) {
292
292
  isDir = s.isDirectory();
293
293
  }
294
294
  catch { }
295
+ // 所有权仲裁:陈旧目录在守卫独占保护下不可能被他人新建,可直接接管;
296
+ // 陈旧单文件被 unlink 后存在创建间隙,仅当本回收者亲自 mkdir 成功才可宣称所有权,
297
+ // 否则主锁已被他人在守卫外真实创建,严禁覆盖现存元数据或删除赢家
298
+ let mainLockClaimable = isDir;
295
299
  if (!isDir) {
296
300
  try {
297
301
  yield (d) => d.unlink(lockPath);
@@ -299,35 +303,45 @@ function* acquireLockMachine(lockPath, options, isAsync) {
299
303
  catch { }
300
304
  try {
301
305
  yield (d) => d.mkdir(lockPath, { mode: 0o700 });
306
+ mainLockClaimable = true;
302
307
  }
303
308
  catch (err) {
304
309
  if (err.code !== "EEXIST")
305
310
  throw err;
306
311
  }
312
+ if (!mainLockClaimable && !isAsync) {
313
+ // 同步语义:主锁已被他人真实创建,当轮快速失败,不得向调用方泄露原始 EEXIST
314
+ if (options?.createLockError) {
315
+ throw options.createLockError(`Lock '${lockPath}' is currently held by another active process`);
316
+ }
317
+ throw new ActionDockError(STORAGE_BUSY, `Lock '${lockPath}' is currently held by another active process`);
318
+ }
307
319
  }
308
- // 在接管守卫独占保护下写入并核验元数据,杜绝外部并发读取到残余陈旧元数据
309
- token = randomUUID();
310
- metadata = {
311
- pid: process.pid,
312
- createdAt: clock.now().toISOString(),
313
- ...options?.metadata,
314
- token,
315
- };
316
- const metaPath = join(lockPath, LOCK_METADATA_FILE);
317
- const tmpPath = join(lockPath, `meta.tmp.${process.pid}.${randomUUID().slice(0, 8)}`);
318
- yield (d) => d.writeFile(tmpPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
319
- yield (d) => d.rename(tmpPath, metaPath);
320
- const raw = yield (d) => d.readFile(metaPath, "utf-8");
321
- const verify = JSON.parse(raw);
322
- if (verify?.token !== token) {
323
- if (options?.createLockError) {
324
- throw options.createLockError(`Failed to verify acquired lock ownership in '${lockPath}'`);
320
+ if (mainLockClaimable) {
321
+ // 在接管守卫独占保护下写入并核验元数据,杜绝外部并发读取到残余陈旧元数据
322
+ token = randomUUID();
323
+ metadata = {
324
+ pid: process.pid,
325
+ createdAt: clock.now().toISOString(),
326
+ ...options?.metadata,
327
+ token,
328
+ };
329
+ const metaPath = join(lockPath, LOCK_METADATA_FILE);
330
+ const tmpPath = join(lockPath, `meta.tmp.${process.pid}.${randomUUID().slice(0, 8)}`);
331
+ yield (d) => d.writeFile(tmpPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
332
+ yield (d) => d.rename(tmpPath, metaPath);
333
+ const raw = yield (d) => d.readFile(metaPath, "utf-8");
334
+ const verify = JSON.parse(raw);
335
+ if (verify?.token !== token) {
336
+ if (options?.createLockError) {
337
+ throw options.createLockError(`Failed to verify acquired lock ownership in '${lockPath}'`);
338
+ }
339
+ throw new ActionDockError(STORAGE_BUSY, `Failed to verify acquired lock ownership in '${lockPath}'`);
325
340
  }
326
- throw new ActionDockError(STORAGE_BUSY, `Failed to verify acquired lock ownership in '${lockPath}'`);
341
+ activeLockPaths.add(resolvedPath);
342
+ acquired = true;
343
+ reclaimMetadataWritten = true;
327
344
  }
328
- activeLockPaths.add(resolvedPath);
329
- acquired = true;
330
- reclaimMetadataWritten = true;
331
345
  }
332
346
  finally {
333
347
  try {
@@ -1,4 +1,6 @@
1
- import type { RuntimeStorage } from "./types.js";
1
+ import type { ConfigItemDefinition } from "../project/types.js";
2
+ import { type RedactResult } from "./mask.js";
3
+ import type { ConfigSnapshot, RuntimeStorage } from "./types.js";
2
4
  export { DataDirLock, type DataDirLockInfo, isProcessAlive, } from "./data-dir-lock.js";
3
5
  export * from "./clock.js";
4
6
  export { NodeSqliteDriver } from "./sqlite-driver.js";
@@ -34,7 +36,7 @@ export declare function resolveDatabasePath(packageId: string, options?: {
34
36
  * 执行宿主路径应传入 recoverOrphans: true 或后续显式调用 recoverDeadSessionRuns。
35
37
  *
36
38
  * @param packageId 目标 Package ID
37
- * @param options 存储配置参数(支持 dataDir, inMemory, customHome, recoverOrphans)
39
+ * @param options 存储配置参数(支持 dataDir, inMemory, customHome, recoverOrphans, configDeclarations)
38
40
  */
39
41
  export declare function createStorage(packageId: string, options?: {
40
42
  projectRoot?: string;
@@ -43,6 +45,7 @@ export declare function createStorage(packageId: string, options?: {
43
45
  customHome?: string;
44
46
  recoverOrphans?: boolean;
45
47
  retentionPolicy?: import("./types.js").RunsRetentionPolicy;
48
+ configDeclarations?: readonly Record<string, ConfigItemDefinition>[];
46
49
  }): RuntimeStorage;
47
50
  /**
48
51
  * 解析全局共享数据库(global.db)文件的绝对路径。
@@ -70,4 +73,23 @@ export declare function createGlobalStorage(customHomeOrOptions?: string | {
70
73
  customHome?: string;
71
74
  dataDir?: string;
72
75
  inMemory?: boolean;
76
+ configDeclarations?: readonly Record<string, ConfigItemDefinition>[];
73
77
  }, dataDirArg?: string): RuntimeStorage;
78
+ /**
79
+ * 读取配置值与配套历史保护声明的原子快照。
80
+ *
81
+ * 优先使用存储的 getConfigSnapshot 单次 SELECT 配对读取值与保护规则,
82
+ * 消除先读值后读保护之间行被并发删除或改写造成的错配竞态;
83
+ * 存储未提供快照能力(如 MemoryStorage 或外部旧实现)时回退为
84
+ * 原值读取加可选保护读取的既有配对行为。
85
+ */
86
+ export declare function readConfigWithProtection(storage: RuntimeStorage, key: string): ConfigSnapshot | undefined;
87
+ /**
88
+ * 依据当前声明与历史保护声明合并脱敏管理视图配置值。
89
+ *
90
+ * 仅在存在历史保护声明(值曾按敏感声明加密写入)时使用:当前声明与历史
91
+ * 保护声明合并后取脱敏并集;合并后仍未产生任何脱敏结果时保守隐藏整项,
92
+ * 杜绝加密值因声明变更或跨宿主加载差异重新外泄明文。
93
+ * 无历史保护时由调用方沿用各自既有脱敏原语,保持行为不变。
94
+ */
95
+ export declare function redactConfigValueWithProtection(value: unknown, items: readonly ConfigItemDefinition[], protection: readonly ConfigItemDefinition[] | undefined): RedactResult;
@@ -1,7 +1,9 @@
1
1
  import { join, relative, resolve } from "node:path";
2
2
  import { assertValidPackageId, getActionDockHome, isPathOutsideBoundary } from "../utils/index.js";
3
3
  import { ActionDockError, PATH_TRAVERSAL } from "../errors.js";
4
+ import { createConfigKeyProvider } from "../security/config-key.js";
4
5
  import { SqliteRuntimeStorage } from "./sqlite.js";
6
+ import { redactConfigValueForDeclarations } from "./mask.js";
5
7
  export { DataDirLock, isProcessAlive, } from "./data-dir-lock.js";
6
8
  export * from "./clock.js";
7
9
  export { NodeSqliteDriver } from "./sqlite-driver.js";
@@ -56,7 +58,7 @@ export function resolveDatabasePath(packageId, options = {}) {
56
58
  * 执行宿主路径应传入 recoverOrphans: true 或后续显式调用 recoverDeadSessionRuns。
57
59
  *
58
60
  * @param packageId 目标 Package ID
59
- * @param options 存储配置参数(支持 dataDir, inMemory, customHome, recoverOrphans)
61
+ * @param options 存储配置参数(支持 dataDir, inMemory, customHome, recoverOrphans, configDeclarations)
60
62
  */
61
63
  export function createStorage(packageId, options = {}) {
62
64
  const dbPath = resolveDatabasePath(packageId, options);
@@ -65,6 +67,17 @@ export function createStorage(packageId, options = {}) {
65
67
  packageId,
66
68
  recoverOrphans: options.recoverOrphans,
67
69
  retentionPolicy: options.retentionPolicy,
70
+ ...(options.configDeclarations?.length ? { configDeclarations: options.configDeclarations } : {}),
71
+ // 持久化库统一注入懒加载密钥提供器:密钥与数据库共享同一有效 dataDir/customHome 域;
72
+ // 内存库不注入,由存储内部使用随机内存密钥,确保内存运行不触碰宿主密钥源
73
+ ...(dbPath !== ":memory:"
74
+ ? {
75
+ configKeyProvider: createConfigKeyProvider({
76
+ customHome: options.customHome,
77
+ dataDir: options.dataDir,
78
+ }),
79
+ }
80
+ : {}),
68
81
  });
69
82
  }
70
83
  /**
@@ -94,10 +107,12 @@ export function createGlobalStorage(customHomeOrOptions, dataDirArg) {
94
107
  let customHome;
95
108
  let dataDir;
96
109
  let inMemory = false;
110
+ let configDeclarations;
97
111
  if (typeof customHomeOrOptions === "object" && customHomeOrOptions !== null) {
98
112
  customHome = customHomeOrOptions.customHome;
99
113
  dataDir = customHomeOrOptions.dataDir;
100
114
  inMemory = Boolean(customHomeOrOptions.inMemory);
115
+ configDeclarations = customHomeOrOptions.configDeclarations;
101
116
  }
102
117
  else {
103
118
  customHome = customHomeOrOptions;
@@ -107,5 +122,53 @@ export function createGlobalStorage(customHomeOrOptions, dataDirArg) {
107
122
  return new SqliteRuntimeStorage({ dbPath: ":memory:", packageId: "__global__" });
108
123
  }
109
124
  const dbPath = resolveGlobalDatabasePath({ dataDir, customHome });
110
- return new SqliteRuntimeStorage({ dbPath, packageId: "__global__" });
125
+ return new SqliteRuntimeStorage({
126
+ dbPath,
127
+ packageId: "__global__",
128
+ configKeyProvider: createConfigKeyProvider({ customHome, dataDir }),
129
+ ...(configDeclarations?.length ? { configDeclarations } : {}),
130
+ });
131
+ }
132
+ /**
133
+ * 读取配置值与配套历史保护声明的原子快照。
134
+ *
135
+ * 优先使用存储的 getConfigSnapshot 单次 SELECT 配对读取值与保护规则,
136
+ * 消除先读值后读保护之间行被并发删除或改写造成的错配竞态;
137
+ * 存储未提供快照能力(如 MemoryStorage 或外部旧实现)时回退为
138
+ * 原值读取加可选保护读取的既有配对行为。
139
+ */
140
+ export function readConfigWithProtection(storage, key) {
141
+ if (typeof storage.getConfigSnapshot === "function") {
142
+ return storage.getConfigSnapshot(key);
143
+ }
144
+ const value = storage.getConfig(key);
145
+ if (value === undefined) {
146
+ return undefined;
147
+ }
148
+ if (typeof storage.getConfigProtection === "function") {
149
+ const protection = storage.getConfigProtection(key);
150
+ return protection !== undefined ? { value, protection } : { value };
151
+ }
152
+ return { value };
153
+ }
154
+ /**
155
+ * 依据当前声明与历史保护声明合并脱敏管理视图配置值。
156
+ *
157
+ * 仅在存在历史保护声明(值曾按敏感声明加密写入)时使用:当前声明与历史
158
+ * 保护声明合并后取脱敏并集;合并后仍未产生任何脱敏结果时保守隐藏整项,
159
+ * 杜绝加密值因声明变更或跨宿主加载差异重新外泄明文。
160
+ * 无历史保护时由调用方沿用各自既有脱敏原语,保持行为不变。
161
+ */
162
+ export function redactConfigValueWithProtection(value, items, protection) {
163
+ if (protection === undefined) {
164
+ return redactConfigValueForDeclarations(value, [...items]);
165
+ }
166
+ const merged = [...items, ...protection];
167
+ const result = redactConfigValueForDeclarations(value, merged);
168
+ if (!result.secret) {
169
+ return value === undefined
170
+ ? { value: undefined, secret: true, redactedPaths: [] }
171
+ : { value: undefined, secret: true, redactedPaths: [""] };
172
+ }
173
+ return result;
111
174
  }
@@ -0,0 +1,40 @@
1
+ import type { ConfigItemDefinition } from "../../project/types.js";
2
+ import type { ConfigKeyProvider } from "../../security/config-cipher.js";
3
+ import type { Clock } from "../clock.js";
4
+ import { type SqliteDriver } from "../types.js";
5
+ /**
6
+ * 版本迁移执行上下文:迁移所需的最小依赖集合。
7
+ *
8
+ * 由存储装配入口(SqliteRuntimeStorage)提供底层驱动、目标命名空间、
9
+ * 配置声明集合与密钥提供器;主干层只装配数据,不做任何迁移判断。
10
+ */
11
+ export interface SchemaMigrationContext {
12
+ driver: SqliteDriver;
13
+ dbPath: string;
14
+ packageId: string;
15
+ declarations: readonly Record<string, ConfigItemDefinition>[];
16
+ keyProvider: ConfigKeyProvider;
17
+ clock: Clock;
18
+ }
19
+ /** 单个版本迁移步骤:from → to 的原子转换。 */
20
+ export interface StorageMigration {
21
+ from: number;
22
+ to: number;
23
+ run(context: SchemaMigrationContext): void;
24
+ }
25
+ /**
26
+ * 存储打开前的版本就绪入口:新库建库、旧库迁移、当前库校验。
27
+ *
28
+ * 调用时机:先于任何正常子存储构造(当前版本存储实现始终假定数据库
29
+ * 已经是当前版本),也先于运行记录恢复(恢复依赖子存储)。
30
+ *
31
+ * - 版本等于当前:锁外只读校验核心表齐备,缺失即拒绝(半升级或损坏
32
+ * 状态不得静默修补);
33
+ * - 其余版本(含全新库):统一进入 BEGIN IMMEDIATE 写事务,锁内重读
34
+ * 版本后决策——全新库执行当前版本 DDL,旧版本沿注册链迁移并逐步更新
35
+ * 版本号,无支持路径(如版本 1 或未来版本)明确拒绝;
36
+ * - 结构完整性校验在事务内、提交前执行:任何失败(含迁移步骤与最终
37
+ * 校验)都会把数据、结构与版本号整体回滚,数据库保持进入前状态可重试;
38
+ * - 并发连接已抢先完成同一升级时,锁内版本已是当前版本,校验后直接通过。
39
+ */
40
+ export declare function ensureStorageSchema(context: SchemaMigrationContext): void;
@@ -0,0 +1,96 @@
1
+ import { ActionDockError, UNSUPPORTED_STORAGE_SCHEMA } from "../../errors.js";
2
+ import { createCurrentSchema, validateCurrentSchema } from "../sqlite-schema.js";
3
+ import { STORAGE_SCHEMA_VERSION } from "../types.js";
4
+ import { migrateV2ToV3 } from "./v2-to-v3.js";
5
+ /**
6
+ * 已注册的版本迁移链。
7
+ *
8
+ * 每个历史版本的全部转换逻辑封装在各自迁移文件中;下线某旧版本支持时,
9
+ * 删除对应迁移文件与注册项即可,当前版本读写代码不感知历史版本。
10
+ */
11
+ const MIGRATIONS = [
12
+ { from: 2, to: 3, run: migrateV2ToV3 },
13
+ ];
14
+ /** 读取数据库当前 schema 版本号。 */
15
+ function readUserVersion(driver) {
16
+ const row = driver.prepare("PRAGMA user_version;").get();
17
+ return Number(row?.user_version ?? 0);
18
+ }
19
+ /** 构造不支持版本的统一拒绝错误。 */
20
+ function unsupportedVersionError(version, dbPath) {
21
+ return new ActionDockError(UNSUPPORTED_STORAGE_SCHEMA, `Database schema version ${version} of '${dbPath}' has no supported migration path to the current version ${STORAGE_SCHEMA_VERSION}; upgrade the database first with a build that still supports version ${version}.`, { dbPath, version, current: STORAGE_SCHEMA_VERSION });
22
+ }
23
+ /**
24
+ * 沿注册链推导从 from 到当前版本的迁移步骤序列。
25
+ *
26
+ * 返回 null 表示无支持路径:from 非法(<= 0)、不低于当前版本(未来版本
27
+ * 一律拒绝,结构完整也不得当作当前版本打开),或链上存在缺口、注册步骤
28
+ * 无法精确抵达当前版本。
29
+ */
30
+ function buildMigrationChain(from) {
31
+ if (from <= 0 || from >= STORAGE_SCHEMA_VERSION) {
32
+ return null;
33
+ }
34
+ const chain = [];
35
+ let version = from;
36
+ while (version < STORAGE_SCHEMA_VERSION) {
37
+ const step = MIGRATIONS.find((migration) => migration.from === version);
38
+ if (!step) {
39
+ return null;
40
+ }
41
+ chain.push(step);
42
+ version = step.to;
43
+ }
44
+ if (version !== STORAGE_SCHEMA_VERSION) {
45
+ return null;
46
+ }
47
+ return chain;
48
+ }
49
+ /**
50
+ * 存储打开前的版本就绪入口:新库建库、旧库迁移、当前库校验。
51
+ *
52
+ * 调用时机:先于任何正常子存储构造(当前版本存储实现始终假定数据库
53
+ * 已经是当前版本),也先于运行记录恢复(恢复依赖子存储)。
54
+ *
55
+ * - 版本等于当前:锁外只读校验核心表齐备,缺失即拒绝(半升级或损坏
56
+ * 状态不得静默修补);
57
+ * - 其余版本(含全新库):统一进入 BEGIN IMMEDIATE 写事务,锁内重读
58
+ * 版本后决策——全新库执行当前版本 DDL,旧版本沿注册链迁移并逐步更新
59
+ * 版本号,无支持路径(如版本 1 或未来版本)明确拒绝;
60
+ * - 结构完整性校验在事务内、提交前执行:任何失败(含迁移步骤与最终
61
+ * 校验)都会把数据、结构与版本号整体回滚,数据库保持进入前状态可重试;
62
+ * - 并发连接已抢先完成同一升级时,锁内版本已是当前版本,校验后直接通过。
63
+ */
64
+ export function ensureStorageSchema(context) {
65
+ const { driver, dbPath } = context;
66
+ // 跨进程写并发基线:优先设置 busy_timeout,确保后续 PRAGMA(如
67
+ // journal_mode = WAL)与迁移事务在锁争用时能够重试等待
68
+ driver.exec("PRAGMA busy_timeout = 5000;");
69
+ driver.exec("PRAGMA journal_mode = WAL;");
70
+ driver.exec("PRAGMA synchronous = NORMAL;");
71
+ if (readUserVersion(driver) === STORAGE_SCHEMA_VERSION) {
72
+ validateCurrentSchema(driver, dbPath);
73
+ return;
74
+ }
75
+ driver.transaction(() => {
76
+ const locked = readUserVersion(driver);
77
+ if (locked === STORAGE_SCHEMA_VERSION) {
78
+ validateCurrentSchema(driver, dbPath);
79
+ return;
80
+ }
81
+ if (locked === 0) {
82
+ createCurrentSchema(driver);
83
+ }
84
+ else {
85
+ const chain = buildMigrationChain(locked);
86
+ if (chain === null) {
87
+ throw unsupportedVersionError(locked, dbPath);
88
+ }
89
+ for (const step of chain) {
90
+ step.run(context);
91
+ driver.exec(`PRAGMA user_version = ${step.to};`);
92
+ }
93
+ }
94
+ validateCurrentSchema(driver, dbPath);
95
+ }, "immediate");
96
+ }
@@ -0,0 +1,16 @@
1
+ import type { SchemaMigrationContext } from "./index.js";
2
+ /**
3
+ * V2 → V3 专用迁移:V2 数据库全部历史知识的唯一归属地。
4
+ *
5
+ * 在调用方维护事务(BEGIN IMMEDIATE)内完成:
6
+ * - 结构补齐:runs 表新增列(origin_json / host_session_id / host_pid /
7
+ * heartbeat_at)、events 审计事件表、config_encryption 密钥元数据表;
8
+ * - 敏感明文转密文:按声明集合(各字典内具体键优先于模板、跨字典取并集)
9
+ * 判定当前命名空间的配置键,整键加密并保留业务值与 updated_at;
10
+ * - 密钥管理:存在待加密行时才解析并持久化密钥(提供器在迁移事务内先落盘
11
+ * 密钥与身份标记,再由本事务提交密文),同一事务内钉住密钥指纹。
12
+ *
13
+ * 直接操作底层 SQL,不构造也不调用当前版本存储对象:将来修改正常写入
14
+ * 行为不会意外改变历史迁移语义。本文件与注册项在下线 V2 支持时整体删除。
15
+ */
16
+ export declare function migrateV2ToV3(context: SchemaMigrationContext): void;
@@ -0,0 +1,116 @@
1
+ import { ActionDockError, CONFIG_ENCRYPTION_ERROR } from "../../errors.js";
2
+ import { resolveConfigDeclaration } from "../../project/config-declarations.js";
3
+ import { isEncryptedConfigValue } from "../../security/config-cipher.js";
4
+ import { isSecretConfigKey } from "../mask.js";
5
+ import { CONFIG_ENCRYPTION_TABLE_DDL } from "../sqlite-schema.js";
6
+ import { encryptConfigEnvelope, mergeProtectionDeclarations } from "../config-value.js";
7
+ import { safeParseStoredJson } from "../utils.js";
8
+ /**
9
+ * V2 → V3 专用迁移:V2 数据库全部历史知识的唯一归属地。
10
+ *
11
+ * 在调用方维护事务(BEGIN IMMEDIATE)内完成:
12
+ * - 结构补齐:runs 表新增列(origin_json / host_session_id / host_pid /
13
+ * heartbeat_at)、events 审计事件表、config_encryption 密钥元数据表;
14
+ * - 敏感明文转密文:按声明集合(各字典内具体键优先于模板、跨字典取并集)
15
+ * 判定当前命名空间的配置键,整键加密并保留业务值与 updated_at;
16
+ * - 密钥管理:存在待加密行时才解析并持久化密钥(提供器在迁移事务内先落盘
17
+ * 密钥与身份标记,再由本事务提交密文),同一事务内钉住密钥指纹。
18
+ *
19
+ * 直接操作底层 SQL,不构造也不调用当前版本存储对象:将来修改正常写入
20
+ * 行为不会意外改变历史迁移语义。本文件与注册项在下线 V2 支持时整体删除。
21
+ */
22
+ export function migrateV2ToV3(context) {
23
+ upgradeRunsColumns(context.driver);
24
+ ensureEventsTable(context.driver);
25
+ context.driver.exec(CONFIG_ENCRYPTION_TABLE_DDL);
26
+ encryptSensitivePlaintextConfigs(context);
27
+ }
28
+ /** 补齐 runs 表历史新增列(旧库形态可能全部缺失)。 */
29
+ function upgradeRunsColumns(driver) {
30
+ const columns = driver.prepare("PRAGMA table_info(runs);").all();
31
+ const present = new Set(columns.map((column) => column.name));
32
+ const additions = [
33
+ ["origin_json", "TEXT"],
34
+ ["host_session_id", "TEXT"],
35
+ ["host_pid", "INTEGER"],
36
+ ["heartbeat_at", "TEXT"],
37
+ ];
38
+ for (const [column, type] of additions) {
39
+ if (!present.has(column)) {
40
+ driver.exec(`ALTER TABLE runs ADD COLUMN ${column} ${type};`);
41
+ }
42
+ }
43
+ }
44
+ /** 补齐 events 审计事件表(V3 必备结构)。 */
45
+ function ensureEventsTable(driver) {
46
+ driver.exec(`
47
+ CREATE TABLE IF NOT EXISTS events (
48
+ id TEXT PRIMARY KEY,
49
+ package_id TEXT NOT NULL,
50
+ event_type TEXT NOT NULL,
51
+ payload_json TEXT,
52
+ created_at TEXT NOT NULL
53
+ );
54
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(package_id, event_type);
55
+ `);
56
+ }
57
+ /** 解析目标键在各声明字典下的有效声明并集(各字典内具体键优先于模板)。 */
58
+ function resolveDeclarations(declarations, key) {
59
+ const items = [];
60
+ for (const dict of declarations) {
61
+ const resolved = resolveConfigDeclaration(dict, key)?.item;
62
+ if (resolved !== undefined) {
63
+ items.push(resolved);
64
+ }
65
+ }
66
+ return items;
67
+ }
68
+ /**
69
+ * 按声明集合加密当前命名空间的敏感明文配置行。
70
+ *
71
+ * - 只处理非空明文行:普通明文与非敏感键保持原状(普通明文与密文在
72
+ * 当前版本内合法共存);空串与空值行留待正常写入路径处理;
73
+ * - 先对全部目标行完成严格 JSON 解析,再解析密钥并钉住指纹,避免为
74
+ * 注定失败的迁移提前创建密钥文件;任何一步失败由调用方事务整体回滚;
75
+ * - 迁移只替换 value_json,业务原值与 updated_at 完整保留。
76
+ */
77
+ function encryptSensitivePlaintextConfigs(context) {
78
+ const { driver, packageId, declarations, keyProvider, clock, dbPath } = context;
79
+ const rows = driver
80
+ .prepare("SELECT key, value_json FROM config WHERE package_id = ?")
81
+ .all(packageId);
82
+ const targets = [];
83
+ for (const row of rows) {
84
+ const raw = row.value_json;
85
+ if (typeof raw !== "string" || raw === "" || isEncryptedConfigValue(raw)) {
86
+ continue;
87
+ }
88
+ const items = resolveDeclarations(declarations, row.key);
89
+ if (!items.some((item) => isSecretConfigKey(row.key, item))) {
90
+ continue;
91
+ }
92
+ const value = safeParseStoredJson(raw, `v2-migration config db=${dbPath} package=${packageId} key=${row.key}`, { strict: true });
93
+ targets.push({ key: row.key, value, items });
94
+ }
95
+ if (targets.length === 0) {
96
+ return;
97
+ }
98
+ const material = keyProvider(true);
99
+ const pinned = driver
100
+ .prepare("SELECT key_id FROM config_encryption WHERE id = 1")
101
+ .get();
102
+ if (pinned === undefined) {
103
+ driver
104
+ .prepare("INSERT INTO config_encryption (id, key_id, created_at) VALUES (1, ?, ?)")
105
+ .run(material.id, clock.now().toISOString());
106
+ }
107
+ else if (pinned.key_id !== material.id) {
108
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption metadata exists with a different key identity in database '${dbPath}'`, { dbPath, pinnedKeyId: pinned.key_id });
109
+ }
110
+ const update = driver.prepare("UPDATE config SET value_json = ? WHERE package_id = ? AND key = ?");
111
+ for (const target of targets) {
112
+ const protection = mergeProtectionDeclarations([], target.items, target.key);
113
+ const ciphertext = encryptConfigEnvelope(target.value, protection, packageId, target.key, material);
114
+ update.run(ciphertext, packageId, target.key);
115
+ }
116
+ }