@actiondock/core 2.0.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/README.md +50 -0
- package/package.json +51 -0
- package/src/build/builder.ts +205 -0
- package/src/build/index.ts +2 -0
- package/src/build/templates.ts +59 -0
- package/src/doctor/doctor.ts +332 -0
- package/src/doctor/index.ts +2 -0
- package/src/doctor/types.ts +25 -0
- package/src/export/index.ts +2 -0
- package/src/export/skill.ts +349 -0
- package/src/export/templates.ts +258 -0
- package/src/filter/index.ts +1 -0
- package/src/filter/intent.ts +154 -0
- package/src/index.ts +13 -0
- package/src/profile/client.ts +302 -0
- package/src/profile/index.ts +3 -0
- package/src/profile/manager.ts +341 -0
- package/src/profile/types.ts +71 -0
- package/src/project/index.ts +3 -0
- package/src/project/init.ts +194 -0
- package/src/project/loader.ts +382 -0
- package/src/project/types.ts +62 -0
- package/src/registry/index.ts +2 -0
- package/src/registry/registry.ts +703 -0
- package/src/registry/types.ts +127 -0
- package/src/runtime/context.ts +232 -0
- package/src/runtime/env.ts +172 -0
- package/src/runtime/execution-manager.ts +74 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/runner.ts +368 -0
- package/src/runtime/standalone.ts +429 -0
- package/src/schema/validator.ts +61 -0
- package/src/server/body.ts +112 -0
- package/src/server/index.ts +6 -0
- package/src/server/runtime-registry.ts +80 -0
- package/src/server/security.ts +115 -0
- package/src/server/server.ts +572 -0
- package/src/server/types.ts +42 -0
- package/src/storage/index.ts +64 -0
- package/src/storage/mask.ts +34 -0
- package/src/storage/sqlite.ts +578 -0
- package/src/storage/types.ts +111 -0
- package/src/utils/index.ts +60 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { RuntimeError, RunRecord } from "@actiondock/sdk";
|
|
5
|
+
import type { RuntimeStorage, StateEntry, StorageOptions, TerminalRunStatus } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 基于 Bun 内置原生 SQLite (`bun:sqlite`) 实现的高性能运行时存储。
|
|
9
|
+
*
|
|
10
|
+
* 关键特性:
|
|
11
|
+
* 1. 零网络与外部进程开销:直接通过 Bun C-API 高速操作 SQLite 数据库文件。
|
|
12
|
+
* 2. 高并发优化:开启 `WAL`(Write-Ahead Logging)模式与 `synchronous = NORMAL`,支持高并发读写。
|
|
13
|
+
* 3. 严格的安全权限:自动配置目录权限为 0700,数据库文件权限为 0600。
|
|
14
|
+
* 4. 自动版本迁移:基于 SQLite `user_version` PRAGMA 实现无损自动 Schema 迁移。
|
|
15
|
+
* 5. TTL 自动过期:支持状态的过期清理。
|
|
16
|
+
*/
|
|
17
|
+
export class SqliteRuntimeStorage implements RuntimeStorage {
|
|
18
|
+
private db: Database;
|
|
19
|
+
private packageId: string;
|
|
20
|
+
|
|
21
|
+
constructor(options: StorageOptions) {
|
|
22
|
+
this.packageId = options.packageId;
|
|
23
|
+
const dbPath = options.dbPath || ":memory:";
|
|
24
|
+
|
|
25
|
+
if (dbPath !== ":memory:") {
|
|
26
|
+
const dir = dirname(dbPath);
|
|
27
|
+
if (!existsSync(dir)) {
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
chmodSync(dir, 0o700);
|
|
31
|
+
} catch {
|
|
32
|
+
// 忽略系统权限设置失败(如只读文件系统)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.db = new Database(dbPath);
|
|
38
|
+
if (dbPath !== ":memory:" && existsSync(dbPath)) {
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(dbPath, 0o600);
|
|
41
|
+
} catch {
|
|
42
|
+
// 忽略文件权限设置异常
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
this.init();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 初始化数据库 Schema 并执行版本迁移。
|
|
50
|
+
*/
|
|
51
|
+
private init(): void {
|
|
52
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
53
|
+
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
54
|
+
|
|
55
|
+
// 读取 Schema 版本号
|
|
56
|
+
const versionRes = this.db.query("PRAGMA user_version;").get() as {
|
|
57
|
+
user_version: number;
|
|
58
|
+
};
|
|
59
|
+
const version = versionRes?.user_version ?? 0;
|
|
60
|
+
|
|
61
|
+
if (version === 0) {
|
|
62
|
+
this.db.transaction(() => {
|
|
63
|
+
this.db.exec(`
|
|
64
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
65
|
+
package_id TEXT NOT NULL,
|
|
66
|
+
key TEXT NOT NULL,
|
|
67
|
+
value_json TEXT,
|
|
68
|
+
updated_at TEXT NOT NULL,
|
|
69
|
+
PRIMARY KEY (package_id, key)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS state (
|
|
73
|
+
package_id TEXT NOT NULL,
|
|
74
|
+
namespace TEXT NOT NULL,
|
|
75
|
+
key TEXT NOT NULL,
|
|
76
|
+
value_json TEXT,
|
|
77
|
+
updated_at TEXT NOT NULL,
|
|
78
|
+
expires_at TEXT,
|
|
79
|
+
PRIMARY KEY (package_id, namespace, key)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
83
|
+
id TEXT PRIMARY KEY,
|
|
84
|
+
package_id TEXT NOT NULL,
|
|
85
|
+
action_id TEXT NOT NULL,
|
|
86
|
+
parent_run_id TEXT,
|
|
87
|
+
status TEXT NOT NULL,
|
|
88
|
+
input_json TEXT,
|
|
89
|
+
output_json TEXT,
|
|
90
|
+
error_json TEXT,
|
|
91
|
+
started_at TEXT NOT NULL,
|
|
92
|
+
finished_at TEXT
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_runs_action ON runs(package_id, action_id);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at DESC);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);
|
|
98
|
+
|
|
99
|
+
PRAGMA user_version = 2;
|
|
100
|
+
`);
|
|
101
|
+
})();
|
|
102
|
+
} else if (version < 2) {
|
|
103
|
+
this.db.transaction(() => {
|
|
104
|
+
try {
|
|
105
|
+
const columns = this.db
|
|
106
|
+
.query("PRAGMA table_info(state);")
|
|
107
|
+
.all() as Array<{ name: string }>;
|
|
108
|
+
const hasExpiresAt = columns.some((c) => c.name === "expires_at");
|
|
109
|
+
if (!hasExpiresAt) {
|
|
110
|
+
this.db.exec("ALTER TABLE state ADD COLUMN expires_at TEXT;");
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// If table doesn't exist yet or already altered
|
|
114
|
+
}
|
|
115
|
+
this.db.exec(
|
|
116
|
+
"CREATE INDEX IF NOT EXISTS idx_state_expires ON state(expires_at);"
|
|
117
|
+
);
|
|
118
|
+
this.db.exec("PRAGMA user_version = 2;");
|
|
119
|
+
})();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Config ---
|
|
124
|
+
|
|
125
|
+
getConfig<T = unknown>(key: string): T | undefined {
|
|
126
|
+
const stmt = this.db.prepare(
|
|
127
|
+
"SELECT value_json FROM config WHERE package_id = ? AND key = ?"
|
|
128
|
+
);
|
|
129
|
+
const row = stmt.get(this.packageId, key) as { value_json: string } | null;
|
|
130
|
+
if (!row || row.value_json === undefined || row.value_json === null) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(row.value_json) as T;
|
|
135
|
+
} catch {
|
|
136
|
+
return row.value_json as unknown as T;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
listConfig(): Record<string, unknown> {
|
|
141
|
+
const stmt = this.db.prepare(
|
|
142
|
+
"SELECT key, value_json FROM config WHERE package_id = ?"
|
|
143
|
+
);
|
|
144
|
+
const rows = stmt.all(this.packageId) as Array<{
|
|
145
|
+
key: string;
|
|
146
|
+
value_json: string;
|
|
147
|
+
}>;
|
|
148
|
+
const result: Record<string, unknown> = {};
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
try {
|
|
151
|
+
result[row.key] = JSON.parse(row.value_json);
|
|
152
|
+
} catch {
|
|
153
|
+
result[row.key] = row.value_json;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setConfig(key: string, value: unknown): void {
|
|
160
|
+
const stmt = this.db.prepare(`
|
|
161
|
+
INSERT INTO config (package_id, key, value_json, updated_at)
|
|
162
|
+
VALUES (?, ?, ?, ?)
|
|
163
|
+
ON CONFLICT(package_id, key) DO UPDATE SET
|
|
164
|
+
value_json = excluded.value_json,
|
|
165
|
+
updated_at = excluded.updated_at
|
|
166
|
+
`);
|
|
167
|
+
const valJson = JSON.stringify(value);
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
stmt.run(this.packageId, key, valJson, now);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
deleteConfig(key: string): boolean {
|
|
173
|
+
const stmt = this.db.prepare(
|
|
174
|
+
"DELETE FROM config WHERE package_id = ? AND key = ?"
|
|
175
|
+
);
|
|
176
|
+
const res = stmt.run(this.packageId, key);
|
|
177
|
+
return res.changes > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- State ---
|
|
181
|
+
|
|
182
|
+
async getState<T = unknown>(
|
|
183
|
+
namespace: string,
|
|
184
|
+
key: string
|
|
185
|
+
): Promise<T | undefined> {
|
|
186
|
+
const stmt = this.db.prepare(
|
|
187
|
+
"SELECT value_json, expires_at FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
|
|
188
|
+
);
|
|
189
|
+
const row = stmt.get(this.packageId, namespace, key) as {
|
|
190
|
+
value_json: string;
|
|
191
|
+
expires_at: string | null;
|
|
192
|
+
} | null;
|
|
193
|
+
if (!row || row.value_json === undefined || row.value_json === null) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
if (row.expires_at) {
|
|
197
|
+
const expiresTime = new Date(row.expires_at).getTime();
|
|
198
|
+
if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
|
|
199
|
+
await this.deleteState(namespace, key);
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(row.value_json) as T;
|
|
205
|
+
} catch {
|
|
206
|
+
return row.value_json as unknown as T;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async findState<T = unknown>(
|
|
211
|
+
targetKey: string,
|
|
212
|
+
namespace?: string
|
|
213
|
+
): Promise<StateEntry | undefined> {
|
|
214
|
+
let row: any = null;
|
|
215
|
+
|
|
216
|
+
if (namespace !== undefined) {
|
|
217
|
+
const stmt = this.db.prepare(
|
|
218
|
+
"SELECT * FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
|
|
219
|
+
);
|
|
220
|
+
row = stmt.get(this.packageId, namespace, targetKey);
|
|
221
|
+
} else {
|
|
222
|
+
// 1. 先在根命名空间精确查找
|
|
223
|
+
const rootStmt = this.db.prepare(
|
|
224
|
+
"SELECT * FROM state WHERE package_id = ? AND namespace = '' AND key = ?"
|
|
225
|
+
);
|
|
226
|
+
row = rootStmt.get(this.packageId, targetKey);
|
|
227
|
+
|
|
228
|
+
// 2. 若未命中且包含 ':',尝试通过 (namespace || ':' || key) 复合匹配
|
|
229
|
+
if (!row && targetKey.includes(":")) {
|
|
230
|
+
const compositeStmt = this.db.prepare(
|
|
231
|
+
"SELECT * FROM state WHERE package_id = ? AND (namespace || ':' || key) = ?"
|
|
232
|
+
);
|
|
233
|
+
row = compositeStmt.get(this.packageId, targetKey);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!row) return undefined;
|
|
238
|
+
|
|
239
|
+
// 校验过期
|
|
240
|
+
if (row.expires_at) {
|
|
241
|
+
const expiresTime = new Date(row.expires_at).getTime();
|
|
242
|
+
if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
|
|
243
|
+
await this.deleteState(row.namespace, row.key);
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let parsedVal: unknown;
|
|
249
|
+
try {
|
|
250
|
+
parsedVal =
|
|
251
|
+
row.value_json !== null && row.value_json !== undefined
|
|
252
|
+
? JSON.parse(row.value_json)
|
|
253
|
+
: row.value_json;
|
|
254
|
+
} catch {
|
|
255
|
+
parsedVal = row.value_json;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fullKey = row.namespace ? `${row.namespace}:${row.key}` : row.key;
|
|
259
|
+
return {
|
|
260
|
+
packageId: row.package_id,
|
|
261
|
+
namespace: row.namespace,
|
|
262
|
+
key: row.key,
|
|
263
|
+
fullKey,
|
|
264
|
+
value: parsedVal as T,
|
|
265
|
+
updatedAt: row.updated_at,
|
|
266
|
+
expiresAt: row.expires_at || undefined,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async setState<T = unknown>(
|
|
271
|
+
namespace: string,
|
|
272
|
+
key: string,
|
|
273
|
+
value: T,
|
|
274
|
+
ttl?: number
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const expiresAtStr =
|
|
277
|
+
typeof ttl === "number" && ttl > 0
|
|
278
|
+
? new Date(Date.now() + ttl * 1000).toISOString()
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
const stmt = this.db.prepare(`
|
|
282
|
+
INSERT INTO state (package_id, namespace, key, value_json, updated_at, expires_at)
|
|
283
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
284
|
+
ON CONFLICT(package_id, namespace, key) DO UPDATE SET
|
|
285
|
+
value_json = excluded.value_json,
|
|
286
|
+
updated_at = excluded.updated_at,
|
|
287
|
+
expires_at = excluded.expires_at
|
|
288
|
+
`);
|
|
289
|
+
const valJson = JSON.stringify(value);
|
|
290
|
+
const now = new Date().toISOString();
|
|
291
|
+
stmt.run(this.packageId, namespace, key, valJson, now, expiresAtStr);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async deleteState(namespace: string, key: string): Promise<boolean> {
|
|
295
|
+
const stmt = this.db.prepare(
|
|
296
|
+
"DELETE FROM state WHERE package_id = ? AND namespace = ? AND key = ?"
|
|
297
|
+
);
|
|
298
|
+
const res = stmt.run(this.packageId, namespace, key);
|
|
299
|
+
return res.changes > 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async deleteStateSmart(
|
|
303
|
+
targetKey: string,
|
|
304
|
+
namespace?: string
|
|
305
|
+
): Promise<boolean> {
|
|
306
|
+
if (namespace !== undefined) {
|
|
307
|
+
return this.deleteState(namespace, targetKey);
|
|
308
|
+
}
|
|
309
|
+
// 1. 先尝试在根命名空间删除
|
|
310
|
+
const rootDeleted = await this.deleteState("", targetKey);
|
|
311
|
+
if (rootDeleted) return true;
|
|
312
|
+
|
|
313
|
+
// 2. 若 targetKey 包含 ':',尝试按 (namespace || ':' || key) 复合键删除
|
|
314
|
+
if (targetKey.includes(":")) {
|
|
315
|
+
const stmt = this.db.prepare(
|
|
316
|
+
"DELETE FROM state WHERE package_id = ? AND (namespace || ':' || key) = ?"
|
|
317
|
+
);
|
|
318
|
+
const res = stmt.run(this.packageId, targetKey);
|
|
319
|
+
return res.changes > 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async clearState(
|
|
326
|
+
options: { namespace?: string; all?: boolean; prefix?: string } = {}
|
|
327
|
+
): Promise<number> {
|
|
328
|
+
if (options.all) {
|
|
329
|
+
const stmt = this.db.prepare("DELETE FROM state WHERE package_id = ?");
|
|
330
|
+
const res = stmt.run(this.packageId);
|
|
331
|
+
return res.changes;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (options.namespace !== undefined) {
|
|
335
|
+
if (options.prefix) {
|
|
336
|
+
const stmt = this.db.prepare(
|
|
337
|
+
"DELETE FROM state WHERE package_id = ? AND namespace = ? AND key LIKE ?"
|
|
338
|
+
);
|
|
339
|
+
const res = stmt.run(
|
|
340
|
+
this.packageId,
|
|
341
|
+
options.namespace,
|
|
342
|
+
`${options.prefix}%`
|
|
343
|
+
);
|
|
344
|
+
return res.changes;
|
|
345
|
+
} else {
|
|
346
|
+
const stmt = this.db.prepare(
|
|
347
|
+
"DELETE FROM state WHERE package_id = ? AND namespace = ?"
|
|
348
|
+
);
|
|
349
|
+
const res = stmt.run(this.packageId, options.namespace);
|
|
350
|
+
return res.changes;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (options.prefix) {
|
|
355
|
+
const pattern = `${options.prefix}%`;
|
|
356
|
+
const stmt = this.db.prepare(
|
|
357
|
+
"DELETE FROM state WHERE package_id = ? AND (CASE WHEN namespace = '' THEN key ELSE (namespace || ':' || key) END) LIKE ?"
|
|
358
|
+
);
|
|
359
|
+
const res = stmt.run(this.packageId, pattern);
|
|
360
|
+
return res.changes;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const stmt = this.db.prepare(
|
|
364
|
+
"DELETE FROM state WHERE package_id = ? AND namespace = ''"
|
|
365
|
+
);
|
|
366
|
+
const res = stmt.run(this.packageId);
|
|
367
|
+
return res.changes;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async listStateKeys(
|
|
371
|
+
namespace?: string | null,
|
|
372
|
+
prefix = ""
|
|
373
|
+
): Promise<string[]> {
|
|
374
|
+
const nowStr = new Date().toISOString();
|
|
375
|
+
try {
|
|
376
|
+
const cleanupStmt = this.db.prepare(
|
|
377
|
+
"DELETE FROM state WHERE package_id = ? AND expires_at IS NOT NULL AND expires_at <= ?"
|
|
378
|
+
);
|
|
379
|
+
cleanupStmt.run(this.packageId, nowStr);
|
|
380
|
+
} catch {
|
|
381
|
+
// Best-effort cleanup
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (namespace === null || namespace === undefined) {
|
|
385
|
+
const pattern = prefix ? `${prefix}%` : "%";
|
|
386
|
+
const stmt = this.db.prepare(
|
|
387
|
+
"SELECT namespace, key FROM state WHERE package_id = ? AND (expires_at IS NULL OR expires_at > ?) AND (CASE WHEN namespace = '' THEN key ELSE (namespace || ':' || key) END) LIKE ? ORDER BY namespace ASC, key ASC"
|
|
388
|
+
);
|
|
389
|
+
const rows = stmt.all(this.packageId, nowStr, pattern) as Array<{
|
|
390
|
+
namespace: string;
|
|
391
|
+
key: string;
|
|
392
|
+
}>;
|
|
393
|
+
return rows.map((r) => (r.namespace ? `${r.namespace}:${r.key}` : r.key));
|
|
394
|
+
} else {
|
|
395
|
+
const pattern = prefix ? `${prefix}%` : "%";
|
|
396
|
+
const stmt = this.db.prepare(
|
|
397
|
+
"SELECT key FROM state WHERE package_id = ? AND namespace = ? AND key LIKE ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key ASC"
|
|
398
|
+
);
|
|
399
|
+
const rows = stmt.all(
|
|
400
|
+
this.packageId,
|
|
401
|
+
namespace,
|
|
402
|
+
pattern,
|
|
403
|
+
nowStr
|
|
404
|
+
) as Array<{
|
|
405
|
+
key: string;
|
|
406
|
+
}>;
|
|
407
|
+
return rows.map((r) => r.key);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async listStateEntries(
|
|
412
|
+
options: { namespace?: string; prefix?: string } = {}
|
|
413
|
+
): Promise<StateEntry[]> {
|
|
414
|
+
const nowStr = new Date().toISOString();
|
|
415
|
+
try {
|
|
416
|
+
const cleanupStmt = this.db.prepare(
|
|
417
|
+
"DELETE FROM state WHERE package_id = ? AND expires_at IS NOT NULL AND expires_at <= ?"
|
|
418
|
+
);
|
|
419
|
+
cleanupStmt.run(this.packageId, nowStr);
|
|
420
|
+
} catch {}
|
|
421
|
+
|
|
422
|
+
const pattern = options.prefix ? `${options.prefix}%` : "%";
|
|
423
|
+
let rows: any[] = [];
|
|
424
|
+
|
|
425
|
+
if (options.namespace !== undefined) {
|
|
426
|
+
const stmt = this.db.prepare(
|
|
427
|
+
"SELECT * FROM state WHERE package_id = ? AND namespace = ? AND key LIKE ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key ASC"
|
|
428
|
+
);
|
|
429
|
+
rows = stmt.all(this.packageId, options.namespace, pattern, nowStr);
|
|
430
|
+
} else {
|
|
431
|
+
const stmt = this.db.prepare(
|
|
432
|
+
"SELECT * FROM state WHERE package_id = ? AND (expires_at IS NULL OR expires_at > ?) AND (CASE WHEN namespace = '' THEN key ELSE (namespace || ':' || key) END) LIKE ? ORDER BY namespace ASC, key ASC"
|
|
433
|
+
);
|
|
434
|
+
rows = stmt.all(this.packageId, nowStr, pattern);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return rows.map((row) => {
|
|
438
|
+
let parsedVal: unknown;
|
|
439
|
+
try {
|
|
440
|
+
parsedVal =
|
|
441
|
+
row.value_json !== null && row.value_json !== undefined
|
|
442
|
+
? JSON.parse(row.value_json)
|
|
443
|
+
: row.value_json;
|
|
444
|
+
} catch {
|
|
445
|
+
parsedVal = row.value_json;
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
packageId: row.package_id,
|
|
449
|
+
namespace: row.namespace,
|
|
450
|
+
key: row.key,
|
|
451
|
+
fullKey: row.namespace ? `${row.namespace}:${row.key}` : row.key,
|
|
452
|
+
value: parsedVal,
|
|
453
|
+
updatedAt: row.updated_at,
|
|
454
|
+
expiresAt: row.expires_at || undefined,
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// --- Runs ---
|
|
460
|
+
|
|
461
|
+
createRun(record: RunRecord): void {
|
|
462
|
+
const stmt = this.db.prepare(`
|
|
463
|
+
INSERT INTO runs (
|
|
464
|
+
id, package_id, action_id, parent_run_id, status,
|
|
465
|
+
input_json, output_json, error_json, started_at, finished_at
|
|
466
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
467
|
+
`);
|
|
468
|
+
stmt.run(
|
|
469
|
+
record.id,
|
|
470
|
+
this.packageId,
|
|
471
|
+
record.actionId,
|
|
472
|
+
record.parentRunId || null,
|
|
473
|
+
record.status,
|
|
474
|
+
record.input !== undefined ? JSON.stringify(record.input) : null,
|
|
475
|
+
record.output !== undefined ? JSON.stringify(record.output) : null,
|
|
476
|
+
record.error ? JSON.stringify(record.error) : null,
|
|
477
|
+
record.startedAt,
|
|
478
|
+
record.finishedAt || null
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
updateRun(
|
|
483
|
+
id: string,
|
|
484
|
+
status: TerminalRunStatus,
|
|
485
|
+
output?: unknown,
|
|
486
|
+
error?: RuntimeError,
|
|
487
|
+
finishedAt?: string
|
|
488
|
+
): void {
|
|
489
|
+
const stmt = this.db.prepare(`
|
|
490
|
+
UPDATE runs SET
|
|
491
|
+
status = ?,
|
|
492
|
+
output_json = ?,
|
|
493
|
+
error_json = ?,
|
|
494
|
+
finished_at = ?
|
|
495
|
+
WHERE id = ?
|
|
496
|
+
`);
|
|
497
|
+
stmt.run(
|
|
498
|
+
status,
|
|
499
|
+
output !== undefined ? JSON.stringify(output) : null,
|
|
500
|
+
error ? JSON.stringify(error) : null,
|
|
501
|
+
finishedAt || new Date().toISOString(),
|
|
502
|
+
id
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
getRun(id: string): RunRecord | null {
|
|
507
|
+
const stmt = this.db.prepare(
|
|
508
|
+
"SELECT * FROM runs WHERE id = ? AND package_id = ?"
|
|
509
|
+
);
|
|
510
|
+
const row = stmt.get(id, this.packageId) as any;
|
|
511
|
+
if (!row) return null;
|
|
512
|
+
return this.mapRunRecord(row);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
listRuns(options: { actionId?: string; limit?: number } = {}): RunRecord[] {
|
|
516
|
+
const limit = options.limit || 50;
|
|
517
|
+
let rows: any[];
|
|
518
|
+
if (options.actionId) {
|
|
519
|
+
const stmt = this.db.prepare(`
|
|
520
|
+
SELECT * FROM runs
|
|
521
|
+
WHERE package_id = ? AND action_id = ?
|
|
522
|
+
ORDER BY started_at DESC
|
|
523
|
+
LIMIT ?
|
|
524
|
+
`);
|
|
525
|
+
rows = stmt.all(this.packageId, options.actionId, limit);
|
|
526
|
+
} else {
|
|
527
|
+
const stmt = this.db.prepare(`
|
|
528
|
+
SELECT * FROM runs
|
|
529
|
+
WHERE package_id = ?
|
|
530
|
+
ORDER BY started_at DESC
|
|
531
|
+
LIMIT ?
|
|
532
|
+
`);
|
|
533
|
+
rows = stmt.all(this.packageId, limit);
|
|
534
|
+
}
|
|
535
|
+
return rows.map((r) => this.mapRunRecord(r));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private mapRunRecord(row: any): RunRecord {
|
|
539
|
+
let input: unknown;
|
|
540
|
+
let output: unknown;
|
|
541
|
+
let error: RuntimeError | undefined;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
input = row.input_json ? JSON.parse(row.input_json) : undefined;
|
|
545
|
+
} catch {
|
|
546
|
+
input = row.input_json;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
output = row.output_json ? JSON.parse(row.output_json) : undefined;
|
|
551
|
+
} catch {
|
|
552
|
+
output = row.output_json;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
error = row.error_json ? JSON.parse(row.error_json) : undefined;
|
|
557
|
+
} catch {
|
|
558
|
+
error = undefined;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
id: row.id,
|
|
563
|
+
packageId: row.package_id,
|
|
564
|
+
actionId: row.action_id,
|
|
565
|
+
parentRunId: row.parent_run_id || undefined,
|
|
566
|
+
status: row.status,
|
|
567
|
+
input,
|
|
568
|
+
output,
|
|
569
|
+
error,
|
|
570
|
+
startedAt: row.started_at,
|
|
571
|
+
finishedAt: row.finished_at || undefined,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
close(): void {
|
|
576
|
+
this.db.close();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { RuntimeError, RunRecord } from "@actiondock/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 数据库中存储的配置条目实体。
|
|
5
|
+
*/
|
|
6
|
+
export interface ConfigEntry {
|
|
7
|
+
/** 所属 Package ID */
|
|
8
|
+
packageId: string;
|
|
9
|
+
/** 配置键名 */
|
|
10
|
+
key: string;
|
|
11
|
+
/** 配置值(JSON 序列化存储) */
|
|
12
|
+
value: unknown;
|
|
13
|
+
/** 最近更新时间(ISO 8601 格式) */
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 数据库中存储的状态条目实体。
|
|
19
|
+
*/
|
|
20
|
+
export interface StateEntry {
|
|
21
|
+
/** 所属 Package ID */
|
|
22
|
+
packageId: string;
|
|
23
|
+
/** 状态隔离命名空间 */
|
|
24
|
+
namespace: string;
|
|
25
|
+
/** 状态键名 */
|
|
26
|
+
key: string;
|
|
27
|
+
/** 复合完整键名(例如 namespace 为空时为 "key",非空时为 "namespace:key") */
|
|
28
|
+
fullKey: string;
|
|
29
|
+
/** 状态值(JSON 序列化存储) */
|
|
30
|
+
value: unknown;
|
|
31
|
+
/** 最近更新时间(ISO 8601 格式) */
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
/** 自动过期时间戳(ISO 8601 格式,null/undefined 表示不过期) */
|
|
34
|
+
expiresAt?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 初始化存储引擎所需的选项。
|
|
39
|
+
*/
|
|
40
|
+
export interface StorageOptions {
|
|
41
|
+
/** SQLite 数据库文件绝对路径,或 ":memory:" 表示内存数据库 */
|
|
42
|
+
dbPath?: string;
|
|
43
|
+
/** 所绑定的 Package ID */
|
|
44
|
+
packageId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Action 执行终态枚举(已结束状态)。
|
|
49
|
+
*/
|
|
50
|
+
export type TerminalRunStatus = "success" | "failed" | "cancelled";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 统一运行时存储抽象接口(RuntimeStorage)。
|
|
54
|
+
* 集中管理单个 Package 下的 Config(配置)、State(状态与 TTL)、Runs(执行历史记录)。
|
|
55
|
+
*/
|
|
56
|
+
export interface RuntimeStorage {
|
|
57
|
+
// --- Config 配置管理 ---
|
|
58
|
+
/** 读取指定配置键的值 */
|
|
59
|
+
getConfig<T = unknown>(key: string): T | undefined;
|
|
60
|
+
/** 列出该 Package 存储的所有配置键值映射 */
|
|
61
|
+
listConfig(): Record<string, unknown>;
|
|
62
|
+
/** 设置或更新配置键值 */
|
|
63
|
+
setConfig(key: string, value: unknown): void;
|
|
64
|
+
/** 删除指定配置键,返回是否成功删除 */
|
|
65
|
+
deleteConfig(key: string): boolean;
|
|
66
|
+
|
|
67
|
+
// --- State 状态管理 ---
|
|
68
|
+
/** 读取指定命名空间下的状态值(已过期则自动清除并返回 undefined) */
|
|
69
|
+
getState<T = unknown>(namespace: string, key: string): Promise<T | undefined>;
|
|
70
|
+
/** 智能查找状态实体(支持 namespace 显式定位,或复合 key "namespace:key" 自动定位) */
|
|
71
|
+
findState<T = unknown>(
|
|
72
|
+
targetKey: string,
|
|
73
|
+
namespace?: string
|
|
74
|
+
): Promise<StateEntry | undefined>;
|
|
75
|
+
/** 写入状态值,可指定 TTL 存活秒数 */
|
|
76
|
+
setState<T = unknown>(
|
|
77
|
+
namespace: string,
|
|
78
|
+
key: string,
|
|
79
|
+
value: T,
|
|
80
|
+
ttl?: number
|
|
81
|
+
): Promise<void>;
|
|
82
|
+
/** 删除指定命名空间下的状态数据,返回是否实际删除了数据 */
|
|
83
|
+
deleteState(namespace: string, key: string): Promise<boolean>;
|
|
84
|
+
/** 智能删除状态(支持显式 namespace,或复合 key "namespace:key" 自动定位),返回是否实际删除 */
|
|
85
|
+
deleteStateSmart(targetKey: string, namespace?: string): Promise<boolean>;
|
|
86
|
+
/** 批量清理状态数据,返回实际清理的条数 */
|
|
87
|
+
clearState(options?: { namespace?: string; all?: boolean; prefix?: string }): Promise<number>;
|
|
88
|
+
/** 列出状态键名。当 namespace 为 null/undefined 时扫描全量 namespace 并返回 fullKey,否则只返回指定 namespace 下的 key */
|
|
89
|
+
listStateKeys(namespace?: string | null, prefix?: string): Promise<string[]>;
|
|
90
|
+
/** 列出状态条目富信息实体列表 */
|
|
91
|
+
listStateEntries(options?: { namespace?: string; prefix?: string }): Promise<StateEntry[]>;
|
|
92
|
+
|
|
93
|
+
// --- Runs 运行记录管理 ---
|
|
94
|
+
/** 创建并记录一条初始运行记录(status: running) */
|
|
95
|
+
createRun(record: RunRecord): void;
|
|
96
|
+
/** 更新运行记录至终态(success/failed/cancelled)并记录输出或错误 */
|
|
97
|
+
updateRun(
|
|
98
|
+
id: string,
|
|
99
|
+
status: TerminalRunStatus,
|
|
100
|
+
output?: unknown,
|
|
101
|
+
error?: RuntimeError,
|
|
102
|
+
finishedAt?: string
|
|
103
|
+
): void;
|
|
104
|
+
/** 根据 ID 查询单条运行记录 */
|
|
105
|
+
getRun(id: string): RunRecord | null;
|
|
106
|
+
/** 分页或按 Action 查询历史运行记录列表(按 startedAt 倒序排列) */
|
|
107
|
+
listRuns(options?: { actionId?: string; limit?: number }): RunRecord[];
|
|
108
|
+
|
|
109
|
+
/** 关闭底层 SQLite 数据库连接并释放句柄 */
|
|
110
|
+
close(): void;
|
|
111
|
+
}
|