@actiondock/core 2.19.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.
- package/dist/client/config.d.ts +17 -14
- package/dist/client/config.js +112 -14
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/errors.d.ts +13 -1
- package/dist/errors.js +25 -0
- package/dist/execution/service.js +44 -10
- package/dist/execution/types.d.ts +3 -3
- package/dist/host/host.d.ts +20 -1
- package/dist/host/host.js +430 -66
- package/dist/host/types.d.ts +13 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +5 -1
- package/dist/input/config-override.d.ts +5 -0
- package/dist/input/config-override.js +23 -0
- package/dist/input/input-resolver.d.ts +24 -7
- package/dist/input/input-resolver.js +73 -5
- package/dist/invocation/bindings.d.ts +53 -0
- package/dist/invocation/bindings.js +248 -0
- package/dist/invocation/policy.d.ts +5 -1
- package/dist/invocation/policy.js +16 -9
- package/dist/invocation/remote.d.ts +40 -0
- package/dist/invocation/remote.js +489 -0
- package/dist/invocation/types.d.ts +33 -0
- package/dist/invocation/types.js +11 -1
- package/dist/package/index.d.ts +2 -2
- package/dist/package/runtime.js +48 -15
- package/dist/platform/node.d.ts +6 -0
- package/dist/platform/node.js +59 -16
- package/dist/platform/types.d.ts +59 -1
- package/dist/process/command-executor.d.ts +35 -0
- package/dist/process/command-executor.js +326 -0
- package/dist/process/index.d.ts +1 -0
- package/dist/process/index.js +1 -0
- package/dist/process/process-driver.d.ts +19 -6
- package/dist/process/process-driver.js +223 -54
- package/dist/process/run-executor.js +101 -47
- package/dist/project/config-declarations.d.ts +61 -0
- package/dist/project/config-declarations.js +297 -0
- package/dist/project/dependencies.d.ts +92 -0
- package/dist/project/dependencies.js +543 -0
- package/dist/project/index.d.ts +8 -2
- package/dist/project/index.js +8 -2
- package/dist/project/init.js +2 -2
- package/dist/project/loader.js +19 -30
- package/dist/project/manifest.js +10 -0
- package/dist/project/package-manager.d.ts +40 -0
- package/dist/project/package-manager.js +224 -0
- package/dist/project/preparation.d.ts +8 -0
- package/dist/project/preparation.js +130 -0
- package/dist/project/transactions.d.ts +5 -1
- package/dist/project/transactions.js +35 -75
- package/dist/project/types.d.ts +30 -0
- package/dist/runtime/context.d.ts +27 -5
- package/dist/runtime/context.js +98 -26
- package/dist/runtime/env.js +15 -1
- package/dist/runtime/run-persistence.d.ts +1 -1
- package/dist/runtime/run-persistence.js +4 -3
- package/dist/runtime/standalone-commands.d.ts +2 -2
- package/dist/runtime/standalone-commands.js +113 -47
- package/dist/runtime/standalone-help.d.ts +1 -0
- package/dist/runtime/standalone-help.js +15 -9
- package/dist/runtime/standalone.js +2 -2
- package/dist/security/config-cipher.d.ts +54 -0
- package/dist/security/config-cipher.js +190 -0
- package/dist/security/config-key.d.ts +35 -0
- package/dist/security/config-key.js +469 -0
- package/dist/server/policy.d.ts +24 -107
- package/dist/server/policy.js +134 -291
- package/dist/server/routes/actions.js +10 -8
- package/dist/server/routes/common.d.ts +5 -20
- package/dist/server/routes/common.js +22 -58
- package/dist/server/routes/config.js +84 -7
- package/dist/server/routes/doctor.d.ts +1 -0
- package/dist/server/routes/doctor.js +4 -3
- package/dist/server/routes/info.js +100 -13
- package/dist/server/routes/playbooks.js +1 -3
- package/dist/server/routes/runs.js +96 -18
- package/dist/server/server.js +25 -53
- package/dist/server/types.d.ts +6 -6
- package/dist/service/factory.js +4 -0
- package/dist/service/remote.js +14 -29
- package/dist/service/types.d.ts +28 -5
- package/dist/service/types.js +8 -0
- package/dist/storage/config-value.d.ts +33 -0
- package/dist/storage/config-value.js +102 -0
- package/dist/storage/file-lock.js +35 -21
- package/dist/storage/index.d.ts +24 -2
- package/dist/storage/index.js +65 -2
- package/dist/storage/mask.d.ts +33 -6
- package/dist/storage/mask.js +298 -16
- package/dist/storage/migrations/index.d.ts +40 -0
- package/dist/storage/migrations/index.js +96 -0
- package/dist/storage/migrations/v2-to-v3.d.ts +16 -0
- package/dist/storage/migrations/v2-to-v3.js +116 -0
- package/dist/storage/sqlite-config.d.ts +62 -3
- package/dist/storage/sqlite-config.js +163 -9
- package/dist/storage/sqlite-driver.d.ts +5 -2
- package/dist/storage/sqlite-driver.js +24 -3
- package/dist/storage/sqlite-schema.d.ts +20 -2
- package/dist/storage/sqlite-schema.js +129 -132
- package/dist/storage/sqlite.d.ts +7 -2
- package/dist/storage/sqlite.js +64 -13
- package/dist/storage/types.d.ts +72 -4
- package/dist/storage/types.js +7 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,16 +1,75 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ConfigItemDefinition } from "../project/types.js";
|
|
2
|
+
import type { ConfigKeyProvider } from "../security/config-cipher.js";
|
|
2
3
|
import type { Clock } from "./clock.js";
|
|
4
|
+
import { type ConfigSnapshot, type SqliteDriver } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* 当前版本(3)配置子存储。
|
|
7
|
+
*
|
|
8
|
+
* 前置约定:数据库在存储构造前已由 migrations/ 版本调度确保为当前版本
|
|
9
|
+
* (含 config_encryption 元数据表)。本类不读取或写入 user_version、
|
|
10
|
+
* 不做 CREATE TABLE、不持有任何历史版本兼容逻辑。
|
|
11
|
+
*
|
|
12
|
+
* 行格式:普通值为 JSON 明文,敏感值为 adenc: 密文信封(业务值与历史
|
|
13
|
+
* 保护声明一同加密)。密文行一经写入永不降级明文;敏感判定命中时存量
|
|
14
|
+
* 明文由正常写入直接升级为密文。
|
|
15
|
+
*/
|
|
3
16
|
export declare class SqliteConfigStore {
|
|
4
17
|
private statementCache;
|
|
5
18
|
private driver;
|
|
6
19
|
private packageId;
|
|
7
20
|
private dbPath;
|
|
8
21
|
private clock;
|
|
9
|
-
|
|
22
|
+
private keyProvider;
|
|
23
|
+
constructor(driver: SqliteDriver, packageId: string, dbPath: string, clock: Clock, keyProvider: ConfigKeyProvider);
|
|
10
24
|
private getStatement;
|
|
25
|
+
/** 读取数据库钉住的加密密钥指纹;无行视为元数据缺失。 */
|
|
26
|
+
private readPinnedKeyId;
|
|
27
|
+
/**
|
|
28
|
+
* 读取路径解析密钥:绝不创建密钥,并校验与数据库钉住的指纹一致。
|
|
29
|
+
*
|
|
30
|
+
* 密钥缺失、身份不符均抛 CONFIG_ENCRYPTION_ERROR,严禁回退为明文处理。
|
|
31
|
+
*/
|
|
32
|
+
private resolveKeyForRead;
|
|
33
|
+
/** 库内是否已存在任何密文行(区分「从未加密」与「元数据被删的损坏态」)。 */
|
|
34
|
+
private hasAnyEncryptedRow;
|
|
35
|
+
/**
|
|
36
|
+
* 敏感写入路径解析密钥(必须在调用方事务内执行)。
|
|
37
|
+
*
|
|
38
|
+
* - 已钉住指纹:provider(false) 读取并校验身份一致,确认匹配后再
|
|
39
|
+
* provider(true) 确保存储域身份标记已持久化;跨目录导入(恢复/移动
|
|
40
|
+
* dataDir 后经 env 密钥继续写入)也会因此留下域身份标记。严禁在校验
|
|
41
|
+
* 前先 provider(true) 生成新密钥;
|
|
42
|
+
* - 未钉住指纹:库内存在密文行属损坏状态(元数据被删),拒绝生成替代
|
|
43
|
+
* 密钥重新钉住;确无密文行则为首次加密,provider(true) 允许创建本地
|
|
44
|
+
* 密钥并在同一事务内钉住指纹。
|
|
45
|
+
*/
|
|
46
|
+
private prepareEncryptionMaterial;
|
|
47
|
+
private decryptEnvelope;
|
|
48
|
+
private readRow;
|
|
49
|
+
/**
|
|
50
|
+
* 单次 SELECT 原子解出业务值与历史保护声明。
|
|
51
|
+
*
|
|
52
|
+
* - 键不存在或值为空:返回 undefined(与既有 getConfig 语义一致);
|
|
53
|
+
* - 明文行:常规 JSON 解析,快照仅含 value;
|
|
54
|
+
* - 密文行:严格解密验证后返回 value 与 protection,任何失败均抛错,
|
|
55
|
+
* 绝不把密文当作普通字符串降级返回。
|
|
56
|
+
*/
|
|
57
|
+
getConfigSnapshot(key: string): ConfigSnapshot | undefined;
|
|
11
58
|
getConfig<T = unknown>(key: string): T | undefined;
|
|
59
|
+
getConfigProtection(key: string): readonly ConfigItemDefinition[] | undefined;
|
|
12
60
|
listConfig(): Record<string, unknown>;
|
|
13
|
-
setConfig(key: string, value: unknown
|
|
61
|
+
setConfig(key: string, value: unknown, options?: {
|
|
62
|
+
declarations?: readonly ConfigItemDefinition[];
|
|
63
|
+
}): void;
|
|
64
|
+
/**
|
|
65
|
+
* 写入单个配置键(必须在调用方事务内执行)。
|
|
66
|
+
*
|
|
67
|
+
* 加密判定:当前声明判定敏感,或既有行已是密文(一经加密永不降级明文)。
|
|
68
|
+
* 敏感写入把业务值与合并后的历史保护声明封入同一信封整体加密;声明
|
|
69
|
+
* 变敏感时存量明文由本次写入直接升级为密文。普通值仍以 JSON 明文存储。
|
|
70
|
+
*/
|
|
71
|
+
private applyConfigWrite;
|
|
72
|
+
private upsertRow;
|
|
14
73
|
deleteConfig(key: string): boolean;
|
|
15
74
|
close(): void;
|
|
16
75
|
}
|
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import { ActionDockError, CONFIG_DECRYPT_FAILED, CONFIG_ENCRYPTION_ERROR } from "../errors.js";
|
|
2
|
+
import { isEncryptedConfigValue } from "../security/config-cipher.js";
|
|
3
|
+
import { decryptConfigEnvelope, encryptConfigEnvelope, mergeProtectionDeclarations } from "./config-value.js";
|
|
4
|
+
import { isSecretConfigKey } from "./mask.js";
|
|
1
5
|
import { safeParseStoredJson } from "./utils.js";
|
|
6
|
+
/**
|
|
7
|
+
* 当前版本(3)配置子存储。
|
|
8
|
+
*
|
|
9
|
+
* 前置约定:数据库在存储构造前已由 migrations/ 版本调度确保为当前版本
|
|
10
|
+
* (含 config_encryption 元数据表)。本类不读取或写入 user_version、
|
|
11
|
+
* 不做 CREATE TABLE、不持有任何历史版本兼容逻辑。
|
|
12
|
+
*
|
|
13
|
+
* 行格式:普通值为 JSON 明文,敏感值为 adenc: 密文信封(业务值与历史
|
|
14
|
+
* 保护声明一同加密)。密文行一经写入永不降级明文;敏感判定命中时存量
|
|
15
|
+
* 明文由正常写入直接升级为密文。
|
|
16
|
+
*/
|
|
2
17
|
export class SqliteConfigStore {
|
|
3
18
|
statementCache = new Map();
|
|
4
19
|
driver;
|
|
5
20
|
packageId;
|
|
6
21
|
dbPath;
|
|
7
22
|
clock;
|
|
8
|
-
|
|
23
|
+
keyProvider;
|
|
24
|
+
constructor(driver, packageId, dbPath, clock, keyProvider) {
|
|
9
25
|
this.driver = driver;
|
|
10
26
|
this.packageId = packageId;
|
|
11
27
|
this.dbPath = dbPath;
|
|
12
28
|
this.clock = clock;
|
|
29
|
+
this.keyProvider = keyProvider;
|
|
13
30
|
}
|
|
14
31
|
getStatement(sql) {
|
|
15
32
|
let stmt = this.statementCache.get(sql);
|
|
@@ -19,24 +36,162 @@ export class SqliteConfigStore {
|
|
|
19
36
|
}
|
|
20
37
|
return stmt;
|
|
21
38
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
// --- 加密基础设施 ---
|
|
40
|
+
/** 读取数据库钉住的加密密钥指纹;无行视为元数据缺失。 */
|
|
41
|
+
readPinnedKeyId() {
|
|
42
|
+
try {
|
|
43
|
+
const row = this.getStatement("SELECT key_id FROM config_encryption WHERE id = 1").get();
|
|
44
|
+
return row?.key_id;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption metadata is unreadable for database '${this.dbPath}'`, { dbPath: this.dbPath, reason: err instanceof Error ? err.message : String(err) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 读取路径解析密钥:绝不创建密钥,并校验与数据库钉住的指纹一致。
|
|
52
|
+
*
|
|
53
|
+
* 密钥缺失、身份不符均抛 CONFIG_ENCRYPTION_ERROR,严禁回退为明文处理。
|
|
54
|
+
*/
|
|
55
|
+
resolveKeyForRead() {
|
|
56
|
+
const pinned = this.readPinnedKeyId();
|
|
57
|
+
if (pinned === undefined) {
|
|
58
|
+
throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Encrypted config value found in database '${this.dbPath}' but config_encryption metadata is missing`, { dbPath: this.dbPath });
|
|
59
|
+
}
|
|
60
|
+
const material = this.keyProvider(false);
|
|
61
|
+
if (material.id !== pinned) {
|
|
62
|
+
throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key does not match the key pinned by database '${this.dbPath}'`, { dbPath: this.dbPath, pinnedKeyId: pinned });
|
|
63
|
+
}
|
|
64
|
+
return material;
|
|
65
|
+
}
|
|
66
|
+
/** 库内是否已存在任何密文行(区分「从未加密」与「元数据被删的损坏态」)。 */
|
|
67
|
+
hasAnyEncryptedRow() {
|
|
68
|
+
const row = this.getStatement("SELECT key FROM config WHERE value_json LIKE 'adenc:%' LIMIT 1").get();
|
|
69
|
+
return row !== undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 敏感写入路径解析密钥(必须在调用方事务内执行)。
|
|
73
|
+
*
|
|
74
|
+
* - 已钉住指纹:provider(false) 读取并校验身份一致,确认匹配后再
|
|
75
|
+
* provider(true) 确保存储域身份标记已持久化;跨目录导入(恢复/移动
|
|
76
|
+
* dataDir 后经 env 密钥继续写入)也会因此留下域身份标记。严禁在校验
|
|
77
|
+
* 前先 provider(true) 生成新密钥;
|
|
78
|
+
* - 未钉住指纹:库内存在密文行属损坏状态(元数据被删),拒绝生成替代
|
|
79
|
+
* 密钥重新钉住;确无密文行则为首次加密,provider(true) 允许创建本地
|
|
80
|
+
* 密钥并在同一事务内钉住指纹。
|
|
81
|
+
*/
|
|
82
|
+
prepareEncryptionMaterial() {
|
|
83
|
+
const pinned = this.readPinnedKeyId();
|
|
84
|
+
if (pinned === undefined) {
|
|
85
|
+
if (this.hasAnyEncryptedRow()) {
|
|
86
|
+
throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Encrypted config values exist in database '${this.dbPath}' but config_encryption metadata is missing; refusing to pin a replacement key`, { dbPath: this.dbPath });
|
|
87
|
+
}
|
|
88
|
+
const material = this.keyProvider(true);
|
|
89
|
+
this.getStatement("INSERT INTO config_encryption (id, key_id, created_at) VALUES (1, ?, ?)").run(material.id, this.clock.now().toISOString());
|
|
90
|
+
return material;
|
|
91
|
+
}
|
|
92
|
+
const material = this.resolveKeyForRead();
|
|
93
|
+
const pinnedAgain = this.keyProvider(true);
|
|
94
|
+
if (pinnedAgain.id !== material.id) {
|
|
95
|
+
// 防御:提供器在 create 调用中解析出与已校验身份不同的密钥
|
|
96
|
+
// (如 env 与本地文件不一致),拒绝继续避免写出混合密钥状态
|
|
97
|
+
throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key provider resolved inconsistent key identities for database '${this.dbPath}'`, { dbPath: this.dbPath, pinnedKeyId: material.id });
|
|
98
|
+
}
|
|
99
|
+
return material;
|
|
100
|
+
}
|
|
101
|
+
decryptEnvelope(raw, key) {
|
|
102
|
+
try {
|
|
103
|
+
return decryptConfigEnvelope(raw, this.packageId, key, this.resolveKeyForRead());
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
// 解密异常(认证失败、格式非法、密钥不符)保持原错误码透传,
|
|
107
|
+
// 仅补充数据库上下文,绝不降级为返回原文。
|
|
108
|
+
if (err instanceof ActionDockError) {
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Decryption of config key '${key}' in package '${this.packageId}' failed unexpectedly`, { dbPath: this.dbPath, key, reason: err instanceof Error ? err.message : String(err) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// --- Config 读取 ---
|
|
115
|
+
readRow(key) {
|
|
116
|
+
return this.getStatement("SELECT value_json FROM config WHERE package_id = ? AND key = ?").get(this.packageId, key);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 单次 SELECT 原子解出业务值与历史保护声明。
|
|
120
|
+
*
|
|
121
|
+
* - 键不存在或值为空:返回 undefined(与既有 getConfig 语义一致);
|
|
122
|
+
* - 明文行:常规 JSON 解析,快照仅含 value;
|
|
123
|
+
* - 密文行:严格解密验证后返回 value 与 protection,任何失败均抛错,
|
|
124
|
+
* 绝不把密文当作普通字符串降级返回。
|
|
125
|
+
*/
|
|
126
|
+
getConfigSnapshot(key) {
|
|
127
|
+
const row = this.readRow(key);
|
|
128
|
+
const raw = row?.value_json;
|
|
129
|
+
if (row === undefined || raw === null || raw === undefined) {
|
|
26
130
|
return undefined;
|
|
27
131
|
}
|
|
28
|
-
|
|
132
|
+
if (isEncryptedConfigValue(raw)) {
|
|
133
|
+
const envelope = this.decryptEnvelope(raw, key);
|
|
134
|
+
return { value: envelope.value, protection: envelope.protection };
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
value: safeParseStoredJson(raw, `config db=${this.dbPath} package=${this.packageId} key=${key}`),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
getConfig(key) {
|
|
141
|
+
const snapshot = this.getConfigSnapshot(key);
|
|
142
|
+
return snapshot?.value;
|
|
143
|
+
}
|
|
144
|
+
getConfigProtection(key) {
|
|
145
|
+
return this.getConfigSnapshot(key)?.protection;
|
|
29
146
|
}
|
|
30
147
|
listConfig() {
|
|
31
148
|
const stmt = this.getStatement("SELECT key, value_json FROM config WHERE package_id = ?");
|
|
32
149
|
const rows = stmt.all(this.packageId);
|
|
33
150
|
const result = {};
|
|
34
151
|
for (const row of rows) {
|
|
35
|
-
|
|
152
|
+
if (row.value_json !== null && row.value_json !== undefined && isEncryptedConfigValue(row.value_json)) {
|
|
153
|
+
const envelope = this.decryptEnvelope(row.value_json, row.key);
|
|
154
|
+
result[row.key] = envelope.value;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
result[row.key] = safeParseStoredJson(row.value_json, `config db=${this.dbPath} package=${this.packageId} key=${row.key}`);
|
|
158
|
+
}
|
|
36
159
|
}
|
|
37
160
|
return result;
|
|
38
161
|
}
|
|
39
|
-
|
|
162
|
+
// --- Config 写入 ---
|
|
163
|
+
setConfig(key, value, options) {
|
|
164
|
+
// 读后写升级场景:事务开始即取得写保留锁,避免并发提交下快照升级 SQLITE_BUSY
|
|
165
|
+
this.driver.transaction(() => {
|
|
166
|
+
this.applyConfigWrite(key, value, options?.declarations ?? []);
|
|
167
|
+
}, "immediate");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 写入单个配置键(必须在调用方事务内执行)。
|
|
171
|
+
*
|
|
172
|
+
* 加密判定:当前声明判定敏感,或既有行已是密文(一经加密永不降级明文)。
|
|
173
|
+
* 敏感写入把业务值与合并后的历史保护声明封入同一信封整体加密;声明
|
|
174
|
+
* 变敏感时存量明文由本次写入直接升级为密文。普通值仍以 JSON 明文存储。
|
|
175
|
+
*/
|
|
176
|
+
applyConfigWrite(key, value, declarations) {
|
|
177
|
+
const currentSensitive = declarations.some((item) => isSecretConfigKey(key, item));
|
|
178
|
+
const existingRow = this.readRow(key);
|
|
179
|
+
const existingRaw = existingRow?.value_json;
|
|
180
|
+
const existingEncrypted = typeof existingRaw === "string" && isEncryptedConfigValue(existingRaw);
|
|
181
|
+
if (currentSensitive || existingEncrypted) {
|
|
182
|
+
// 事务内重读既有密文行,合并历史保护声明,防止并发写弱化保护
|
|
183
|
+
const storedProtection = existingEncrypted
|
|
184
|
+
? this.decryptEnvelope(existingRaw, key).protection
|
|
185
|
+
: [];
|
|
186
|
+
const protection = mergeProtectionDeclarations(storedProtection, declarations, key);
|
|
187
|
+
const material = this.prepareEncryptionMaterial();
|
|
188
|
+
const ciphertext = encryptConfigEnvelope(value, protection, this.packageId, key, material);
|
|
189
|
+
this.upsertRow(key, ciphertext);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.upsertRow(key, JSON.stringify(value));
|
|
193
|
+
}
|
|
194
|
+
upsertRow(key, valJson) {
|
|
40
195
|
const stmt = this.getStatement(`
|
|
41
196
|
INSERT INTO config (package_id, key, value_json, updated_at)
|
|
42
197
|
VALUES (?, ?, ?, ?)
|
|
@@ -44,7 +199,6 @@ export class SqliteConfigStore {
|
|
|
44
199
|
value_json = excluded.value_json,
|
|
45
200
|
updated_at = excluded.updated_at
|
|
46
201
|
`);
|
|
47
|
-
const valJson = JSON.stringify(value);
|
|
48
202
|
const now = this.clock.now().toISOString();
|
|
49
203
|
stmt.run(this.packageId, key, valJson, now);
|
|
50
204
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { normalizeSqliteParams } from "./params.js";
|
|
3
|
-
import type { SqliteDriver, SqliteStatement } from "./types.js";
|
|
3
|
+
import type { SqliteDriver, SqliteStatement, SqliteTransactionMode } from "./types.js";
|
|
4
4
|
export { normalizeSqliteParams };
|
|
5
5
|
/**
|
|
6
6
|
|
|
@@ -28,8 +28,11 @@ export declare class NodeSqliteDriver implements SqliteDriver {
|
|
|
28
28
|
prepare(sql: string): SqliteStatement;
|
|
29
29
|
/**
|
|
30
30
|
* 执行同步事务,严格拒绝并拦截异步 Promise。
|
|
31
|
+
*
|
|
32
|
+
* mode 缺省为 deferred(历史行为);读后写升级场景应传 immediate,
|
|
33
|
+
* 在事务开始即取得写保留锁,避免并发提交下快照升级失败。
|
|
31
34
|
*/
|
|
32
|
-
transaction<T>(fn: () => T extends PromiseLike<unknown> ? never : T): T;
|
|
35
|
+
transaction<T>(fn: () => T extends PromiseLike<unknown> ? never : T, mode?: SqliteTransactionMode): T;
|
|
33
36
|
/**
|
|
34
37
|
* 关闭底层数据库连接并释放资源。
|
|
35
38
|
*/
|
|
@@ -73,10 +73,18 @@ export class NodeSqliteDriver {
|
|
|
73
73
|
}
|
|
74
74
|
/**
|
|
75
75
|
* 执行同步事务,严格拒绝并拦截异步 Promise。
|
|
76
|
+
*
|
|
77
|
+
* mode 缺省为 deferred(历史行为);读后写升级场景应传 immediate,
|
|
78
|
+
* 在事务开始即取得写保留锁,避免并发提交下快照升级失败。
|
|
76
79
|
*/
|
|
77
|
-
transaction(fn) {
|
|
80
|
+
transaction(fn, mode) {
|
|
78
81
|
this.assertOpen();
|
|
79
|
-
|
|
82
|
+
const beginSql = mode === "immediate"
|
|
83
|
+
? "BEGIN IMMEDIATE"
|
|
84
|
+
: mode === "exclusive"
|
|
85
|
+
? "BEGIN EXCLUSIVE"
|
|
86
|
+
: "BEGIN";
|
|
87
|
+
this.db.exec(beginSql);
|
|
80
88
|
let result;
|
|
81
89
|
try {
|
|
82
90
|
result = fn();
|
|
@@ -102,7 +110,20 @@ export class NodeSqliteDriver {
|
|
|
102
110
|
}
|
|
103
111
|
throw new ActionDockError(ASYNC_TRANSACTION_UNSUPPORTED, "Async transactions are not allowed in SQLite");
|
|
104
112
|
}
|
|
105
|
-
|
|
113
|
+
// 提交本身也可能失败(如锁竞争下的 EXCLUSIVE 升级被拒):必须回滚释放
|
|
114
|
+
// 事务后重抛原始错误,严禁让连接停留在未决事务内导致后续操作全部失败
|
|
115
|
+
try {
|
|
116
|
+
this.db.exec("COMMIT");
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
try {
|
|
120
|
+
this.db.exec("ROLLBACK");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// 忽略回滚阶段发生的级联异常
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
106
127
|
return result;
|
|
107
128
|
}
|
|
108
129
|
/**
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { type SqliteDriver } from "./types.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* config_encryption 密钥元数据表 DDL 单一事实源。
|
|
4
|
+
*
|
|
5
|
+
* 单行约束(id = 1)钉住数据库的加密密钥指纹。当前版本(3)数据库
|
|
6
|
+
* 总是携带该表:新库建库时创建,V2 迁移时补齐;无敏感写入时表保持
|
|
7
|
+
* 空置(不加载或创建任何密钥),首次敏感写入时在同一事务内钉住指纹。
|
|
4
8
|
*/
|
|
5
|
-
export declare
|
|
9
|
+
export declare const CONFIG_ENCRYPTION_TABLE_DDL = "\n CREATE TABLE IF NOT EXISTS config_encryption (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n key_id TEXT NOT NULL,\n created_at TEXT NOT NULL\n );\n";
|
|
10
|
+
/**
|
|
11
|
+
* 在调用方写事务内创建当前版本完整结构并钉版本号。
|
|
12
|
+
*
|
|
13
|
+
* 事务所有权归版本调度器(migrations/index.ts):锁内重读版本后调用,
|
|
14
|
+
* 本函数不自建嵌套事务。
|
|
15
|
+
*/
|
|
16
|
+
export declare function createCurrentSchema(driver: SqliteDriver): void;
|
|
17
|
+
/**
|
|
18
|
+
* 校验当前版本数据库结构完整性:核心表与 runs 关键列必须齐备。
|
|
19
|
+
*
|
|
20
|
+
* 声明为当前版本的库缺失关键表或关键列属于损坏(或半升级)状态,
|
|
21
|
+
* 必须明确拒绝,严禁在打开路径上静默 ALTER / CREATE 修补。
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateCurrentSchema(driver: SqliteDriver, dbPath: string): void;
|
|
@@ -1,147 +1,144 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, chmodSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
1
|
import { UNSUPPORTED_STORAGE_SCHEMA } from "../errors.js";
|
|
4
2
|
import { STORAGE_SCHEMA_VERSION } from "./types.js";
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
4
|
+
* config_encryption 密钥元数据表 DDL 单一事实源。
|
|
5
|
+
*
|
|
6
|
+
* 单行约束(id = 1)钉住数据库的加密密钥指纹。当前版本(3)数据库
|
|
7
|
+
* 总是携带该表:新库建库时创建,V2 迁移时补齐;无敏感写入时表保持
|
|
8
|
+
* 空置(不加载或创建任何密钥),首次敏感写入时在同一事务内钉住指纹。
|
|
7
9
|
*/
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
10
|
+
export const CONFIG_ENCRYPTION_TABLE_DDL = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS config_encryption (
|
|
12
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
13
|
+
key_id TEXT NOT NULL,
|
|
14
|
+
created_at TEXT NOT NULL
|
|
15
|
+
);
|
|
16
|
+
`;
|
|
17
|
+
/** 当前版本数据库必须存在的核心表集合(缺任一即视为损坏状态)。 */
|
|
18
|
+
const REQUIRED_TABLES = [
|
|
19
|
+
"config",
|
|
20
|
+
"state",
|
|
21
|
+
"runs",
|
|
22
|
+
"idempotency_keys",
|
|
23
|
+
"events",
|
|
24
|
+
"config_encryption",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* 当前版本(3)完整结构 DDL。
|
|
28
|
+
*
|
|
29
|
+
* 仅描述当前版本形态:新库一次性创建全部表与索引并落 user_version。
|
|
30
|
+
* 历史版本的结构差异(如旧 runs 列形态、缺失 events 表)一律由
|
|
31
|
+
* migrations/v2-to-v3 负责,本模块不保留任何旧版本补齐分支。
|
|
32
|
+
*/
|
|
33
|
+
const CURRENT_SCHEMA_DDL = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
35
|
+
package_id TEXT NOT NULL,
|
|
36
|
+
key TEXT NOT NULL,
|
|
37
|
+
value_json TEXT,
|
|
38
|
+
updated_at TEXT NOT NULL,
|
|
39
|
+
PRIMARY KEY (package_id, key)
|
|
40
|
+
);
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
CREATE TABLE IF NOT EXISTS state (
|
|
43
|
+
package_id TEXT NOT NULL,
|
|
44
|
+
namespace TEXT NOT NULL,
|
|
45
|
+
key TEXT NOT NULL,
|
|
46
|
+
value_json TEXT,
|
|
47
|
+
updated_at TEXT NOT NULL,
|
|
48
|
+
expires_at TEXT,
|
|
49
|
+
PRIMARY KEY (package_id, namespace, key)
|
|
50
|
+
);
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
root_run_id TEXT NOT NULL,
|
|
55
|
+
parent_run_id TEXT,
|
|
56
|
+
origin_json TEXT,
|
|
57
|
+
package_id TEXT NOT NULL,
|
|
58
|
+
package_instance_id TEXT NOT NULL,
|
|
59
|
+
action_id TEXT NOT NULL,
|
|
60
|
+
generation_id TEXT NOT NULL,
|
|
61
|
+
owner_id TEXT NOT NULL,
|
|
62
|
+
host_session_id TEXT,
|
|
63
|
+
host_pid INTEGER,
|
|
64
|
+
heartbeat_at TEXT,
|
|
65
|
+
status TEXT NOT NULL,
|
|
66
|
+
input_json TEXT,
|
|
67
|
+
output_json TEXT,
|
|
68
|
+
error_json TEXT,
|
|
69
|
+
started_at TEXT NOT NULL,
|
|
70
|
+
finished_at TEXT,
|
|
71
|
+
duration_ms INTEGER
|
|
72
|
+
);
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_runs_action ON runs(package_id, action_id);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_runs_root ON runs(root_run_id);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_runs_host_session ON runs(host_session_id);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);
|
|
75
79
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
|
81
|
+
owner_id TEXT NOT NULL,
|
|
82
|
+
action_ref TEXT NOT NULL,
|
|
83
|
+
request_id TEXT NOT NULL,
|
|
84
|
+
input_digest TEXT NOT NULL,
|
|
85
|
+
run_id TEXT NOT NULL,
|
|
86
|
+
created_at TEXT NOT NULL,
|
|
87
|
+
PRIMARY KEY (owner_id, action_ref, request_id)
|
|
88
|
+
);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_idemp_run ON idempotency_keys(run_id);
|
|
86
90
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
package_id TEXT NOT NULL,
|
|
94
|
+
event_type TEXT NOT NULL,
|
|
95
|
+
payload_json TEXT,
|
|
96
|
+
created_at TEXT NOT NULL
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_events_type ON events(package_id, event_type);
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
${CONFIG_ENCRYPTION_TABLE_DDL}
|
|
101
|
+
|
|
102
|
+
PRAGMA user_version = ${STORAGE_SCHEMA_VERSION};
|
|
103
|
+
`;
|
|
104
|
+
/**
|
|
105
|
+
* 在调用方写事务内创建当前版本完整结构并钉版本号。
|
|
106
|
+
*
|
|
107
|
+
* 事务所有权归版本调度器(migrations/index.ts):锁内重读版本后调用,
|
|
108
|
+
* 本函数不自建嵌套事务。
|
|
109
|
+
*/
|
|
110
|
+
export function createCurrentSchema(driver) {
|
|
111
|
+
driver.exec(CURRENT_SCHEMA_DDL);
|
|
112
|
+
}
|
|
113
|
+
/** 当前版本 runs 表必须存在的迁移新增列(缺失即半升级或损坏状态)。 */
|
|
114
|
+
const REQUIRED_RUNS_COLUMNS = [
|
|
115
|
+
"origin_json",
|
|
116
|
+
"host_session_id",
|
|
117
|
+
"host_pid",
|
|
118
|
+
"heartbeat_at",
|
|
119
|
+
];
|
|
120
|
+
/**
|
|
121
|
+
* 校验当前版本数据库结构完整性:核心表与 runs 关键列必须齐备。
|
|
122
|
+
*
|
|
123
|
+
* 声明为当前版本的库缺失关键表或关键列属于损坏(或半升级)状态,
|
|
124
|
+
* 必须明确拒绝,严禁在打开路径上静默 ALTER / CREATE 修补。
|
|
125
|
+
*/
|
|
126
|
+
export function validateCurrentSchema(driver, dbPath) {
|
|
127
|
+
const rows = driver
|
|
128
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
129
|
+
.all();
|
|
130
|
+
const present = new Set(rows.map((row) => row.name));
|
|
131
|
+
const missing = REQUIRED_TABLES.filter((table) => !present.has(table));
|
|
132
|
+
if (missing.length > 0) {
|
|
133
|
+
const err = new Error(`UNSUPPORTED_STORAGE_SCHEMA: Database '${dbPath}' declares schema version ${STORAGE_SCHEMA_VERSION} but is missing required tables: ${missing.join(", ")}. The database is in a corrupted or half-upgraded state and silent repair is prohibited.`);
|
|
102
134
|
err.code = UNSUPPORTED_STORAGE_SCHEMA;
|
|
103
135
|
throw err;
|
|
104
136
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
if (Array.isArray(columns) && !columns.some((c) => c.name === "host_session_id")) {
|
|
113
|
-
driver.exec("ALTER TABLE runs ADD COLUMN host_session_id TEXT;");
|
|
114
|
-
}
|
|
115
|
-
// 存活判定依据列:记录所属宿主进程标识与最后一次活跃心跳,旧库以可空列兼容补充
|
|
116
|
-
if (Array.isArray(columns) && !columns.some((c) => c.name === "host_pid")) {
|
|
117
|
-
driver.exec("ALTER TABLE runs ADD COLUMN host_pid INTEGER;");
|
|
118
|
-
}
|
|
119
|
-
if (Array.isArray(columns) && !columns.some((c) => c.name === "heartbeat_at")) {
|
|
120
|
-
driver.exec("ALTER TABLE runs ADD COLUMN heartbeat_at TEXT;");
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// 忽略非结构化表或兼容驱动异常
|
|
125
|
-
}
|
|
126
|
-
// 确保 events 表存在以支持新增的审计事件仓储
|
|
127
|
-
try {
|
|
128
|
-
driver.exec(`
|
|
129
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
130
|
-
id TEXT PRIMARY KEY,
|
|
131
|
-
package_id TEXT NOT NULL,
|
|
132
|
-
event_type TEXT NOT NULL,
|
|
133
|
-
payload_json TEXT,
|
|
134
|
-
created_at TEXT NOT NULL
|
|
135
|
-
);
|
|
136
|
-
CREATE INDEX IF NOT EXISTS idx_events_type ON events(package_id, event_type);
|
|
137
|
-
`);
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
// 忽略
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (recoverOrphans) {
|
|
144
|
-
recoverDeadSessionFn();
|
|
145
|
-
cleanExpiredRunsFn();
|
|
137
|
+
const runColumns = new Set(driver.prepare("PRAGMA table_info(runs);").all().map((column) => column.name));
|
|
138
|
+
const missingColumns = REQUIRED_RUNS_COLUMNS.filter((column) => !runColumns.has(column));
|
|
139
|
+
if (missingColumns.length > 0) {
|
|
140
|
+
const err = new Error(`UNSUPPORTED_STORAGE_SCHEMA: Database '${dbPath}' declares schema version ${STORAGE_SCHEMA_VERSION} but table 'runs' is missing required columns: ${missingColumns.join(", ")}. The database is in a corrupted or half-upgraded state and silent repair is prohibited.`);
|
|
141
|
+
err.code = UNSUPPORTED_STORAGE_SCHEMA;
|
|
142
|
+
throw err;
|
|
146
143
|
}
|
|
147
144
|
}
|