@actiondock/core 2.0.1 → 2.0.3
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 +59 -27
- package/package.json +2 -2
- package/src/build/builder.ts +20 -7
- package/src/catalog/action-index.ts +92 -0
- package/src/catalog/action-resolver.ts +120 -0
- package/src/catalog/index.ts +5 -0
- package/src/catalog/location-registry.ts +134 -0
- package/src/catalog/package-catalog.ts +87 -0
- package/src/catalog/types.ts +83 -0
- package/src/doctor/doctor.ts +59 -33
- package/src/execution/index.ts +2 -0
- package/src/execution/service.ts +282 -0
- package/src/execution/types.ts +73 -0
- package/src/export/skill.ts +7 -6
- package/src/export/templates.ts +1 -1
- package/src/index.ts +2 -0
- package/src/profile/client.ts +252 -2
- package/src/project/index.ts +1 -0
- package/src/project/init.ts +80 -33
- package/src/project/loader.ts +11 -12
- package/src/project/manifest.ts +96 -0
- package/src/project/types.ts +30 -0
- package/src/runtime/clock.ts +41 -0
- package/src/runtime/context.ts +24 -2
- package/src/runtime/events.ts +142 -0
- package/src/runtime/index.ts +4 -0
- package/src/runtime/process.ts +244 -0
- package/src/runtime/runner.ts +37 -3
- package/src/server/runtime-registry.ts +35 -0
- package/src/server/server.ts +994 -74
- package/src/server/types.ts +15 -0
- package/src/storage/driver.ts +126 -0
- package/src/storage/index.ts +4 -3
- package/src/storage/sqlite.ts +365 -258
- package/src/storage/types.ts +42 -22
package/README.md
CHANGED
|
@@ -1,50 +1,82 @@
|
|
|
1
1
|
# @actiondock/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ActionDock 2.0 核心领域模型与调度引擎。
|
|
4
4
|
|
|
5
|
-
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
8
8
|
|
|
9
|
-
`@actiondock/core`
|
|
9
|
+
`@actiondock/core` 承载 ActionDock 的领域对象、状态机、持久化接口抽象与执行服务,是与具体宿主环境解耦的通用内核。
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 核心领域模型
|
|
14
|
+
|
|
15
|
+
- `ProjectConfig`:定义在 `actiondock.json` 中的项目规范,包含包标识、名称、版本号、目录配置以及配置项元数据。
|
|
16
|
+
- `PlaybookDefinition`:智能体操作规程定义,由 Markdown 文本与其头部 YAML 元数据构成,静态记录任务步骤与调用的 Action 依赖列表。
|
|
17
|
+
- `ActionDockManifest`:`actiondock.manifest.json` 声明式元数据清单模型,记录每个 Action 的入口路径、功能描述、输入输出模式、静态依赖与协议注解,作为无副作用模块发现与构建规划的唯一事实源。
|
|
18
|
+
- `ConfigItemDefinition`:单项配置规范,涵盖默认值、类型约束、敏感脱敏标记及绑定的外部环境变量。
|
|
17
19
|
|
|
18
20
|
---
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## 关键抽象接口
|
|
23
|
+
|
|
24
|
+
### SqliteDriver 驱动接口
|
|
25
|
+
|
|
26
|
+
解耦底层数据库实现,提供一致的同步参数化执行与事务契约:
|
|
27
|
+
|
|
28
|
+
- `exec(sql: string): void`:执行无返回值的 SQL 语句。
|
|
29
|
+
- `prepare(sql: string): SqliteStatement`:编译 SQL 模板,生成支持 `run`、`get`、`all` 方法的预编译语句对象。
|
|
30
|
+
- `transaction<T>(fn: () => T): T`:同步事务执行器,在出现异常时自动回滚,并在驱动层严格拦截异步 Promise 以避免事务泄漏。
|
|
31
|
+
- `close(): void`:释放数据库连接与文件句柄。
|
|
32
|
+
|
|
33
|
+
### ProcessExecutor 进程执行器接口
|
|
21
34
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
抽象跨平台的子进程操作,统一下列能力:
|
|
36
|
+
|
|
37
|
+
- `exec(command, args, options): Promise<ProcessResult>`:执行外部命令并捕获标准输出与标准错误流,支持标准输入流透传、执行超时控制、取消信号响应以及缓冲区防爆保护。
|
|
38
|
+
- `spawnDetached(options): Promise<DetachedProcessResult>`:独立拉起后台长周期守护进程,通过轮询就绪探针确认服务启动状态。
|
|
25
39
|
|
|
26
40
|
---
|
|
27
41
|
|
|
28
|
-
##
|
|
42
|
+
## 执行核心与状态机
|
|
43
|
+
|
|
44
|
+
### ActionRunner 执行状态机
|
|
45
|
+
|
|
46
|
+
`ActionRunner` 是单个 Action 执行的核心引擎,负责完整的生命周期状态流转与契约保障:
|
|
47
|
+
|
|
48
|
+
- **调用链环路检测**:基于调用栈跟踪,当检测到依赖循环(例如 A 动作调用 B 动作,B 动作反向调用 A 动作)时立即拦截并返回错误信封。
|
|
49
|
+
- **模式严格校验**:在 Action 执行前使用 Ajv 校验输入数据是否满足 `inputSchema` 契约,校验失败时直接阻断并生成结构化诊断信息。
|
|
50
|
+
- **运行记录持久化**:初始化运行时在 SQLite 中写入 `running` 状态记录,并在结束时流转至对应终态。
|
|
51
|
+
- **生命周期状态转换**:
|
|
52
|
+
- 启动阶段:状态置为 `running`,绑定超时定时器与取消信号。
|
|
53
|
+
- 正常完成:捕获返回值,更新状态为 `success` 并持久化输出快照。
|
|
54
|
+
- 业务抛错:捕获异常,更新状态为 `failed` 并提取结构化错误码与调用栈。
|
|
55
|
+
- 超时中止:超时定时器触发,发送取消信号并更新状态为 `timed_out`。
|
|
56
|
+
- 主动取消:外部请求取消,更新状态为 `cancelled`。
|
|
57
|
+
- **上下文环境合成**:动态构建 `ActionContext`,集成配置优先级解析器、状态存储器与标准错误流日志记录器。
|
|
29
58
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
59
|
+
### DefaultExecutionService 统一执行服务
|
|
60
|
+
|
|
61
|
+
负责系统层面的并发控制、任务追踪与生命周期协同:
|
|
62
|
+
|
|
63
|
+
- **并发度控制**:维护活跃任务表,支持配置系统最大并发上限,超限时合理排队或拒接。
|
|
64
|
+
- **全链路追踪**:为每次执行分配全局唯一的根运行标识与父子调用关联。
|
|
65
|
+
- **协同取消传播**:支持根据运行标识获取执行句柄,向下游所有派生子任务广播取消信号。
|
|
66
|
+
- **事件汇聚分发**:将执行过程中的状态变更事件统一推送到可插拔的事件接收器中。
|
|
36
67
|
|
|
37
68
|
---
|
|
38
69
|
|
|
39
|
-
##
|
|
70
|
+
## 运行时可插拔设计
|
|
71
|
+
|
|
72
|
+
`@actiondock/core` 保持平台中立,不绑定任何特定运行环境:
|
|
40
73
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- [HTTP Server & Remote Dispatch](../../docs/consumer/http-service.md)
|
|
74
|
+
- 在日常使用与 Node.js 运行时中,通过 `@actiondock/runtime-node` 注入基于 `node:sqlite` 与 `execa` 的驱动。
|
|
75
|
+
- 在独立二进制编译产物中,通过 `@actiondock/runtime-bun` 注入基于 `bun:sqlite` 与 `Bun.spawn` 的驱动。
|
|
76
|
+
- 在自动化测试中,通过 `@actiondock/testing` 注入纯内存存储驱动与模拟进程执行器。
|
|
45
77
|
|
|
46
78
|
---
|
|
47
79
|
|
|
48
|
-
##
|
|
80
|
+
## 开源协议
|
|
49
81
|
|
|
50
|
-
|
|
82
|
+
本项目采用 Apache-2.0 开源协议。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actiondock/core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "ActionDock Core Engine - Project loader, runtime execution, SQLite storage, standalone builder, and skill exporter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"README.md"
|
|
18
18
|
],
|
|
19
19
|
"engines": {
|
|
20
|
-
"
|
|
20
|
+
"node": ">=22.12.0"
|
|
21
21
|
},
|
|
22
22
|
"publishConfig": {
|
|
23
23
|
"access": "public",
|
package/src/build/builder.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -142,15 +143,18 @@ export async function buildProject(options: BuildOptions): Promise<BuildResult>
|
|
|
142
143
|
buildArgs.push(`--target=${formattedTarget}`);
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
const proc =
|
|
146
|
+
const proc = spawnSync(buildArgs[0], buildArgs.slice(1), {
|
|
146
147
|
cwd: root,
|
|
147
|
-
|
|
148
|
-
stderr: "pipe",
|
|
148
|
+
stdio: "pipe",
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
if (proc.
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
if (proc.error) {
|
|
152
|
+
throw new Error(`Bun compile failed to spawn: ${proc.error.message}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (proc.status !== 0) {
|
|
156
|
+
const errText = proc.stderr?.toString() || proc.stdout?.toString() || "Unknown error";
|
|
157
|
+
throw new Error(`Bun compile failed (exit code ${proc.status}):\n${errText}`);
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
// Compile artifact resolution (on Windows bun compile automatically appends .exe)
|
|
@@ -177,6 +181,15 @@ export async function buildProject(options: BuildOptions): Promise<BuildResult>
|
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
183
|
|
|
184
|
+
const detectedBunVersion = (typeof (globalThis as any).Bun !== "undefined" && (globalThis as any).Bun.version) || (() => {
|
|
185
|
+
try {
|
|
186
|
+
const vProc = spawnSync("bun", ["--version"], { stdio: "pipe" });
|
|
187
|
+
return vProc.stdout ? vProc.stdout.toString().trim() : "unknown";
|
|
188
|
+
} catch {
|
|
189
|
+
return "unknown";
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
|
|
180
193
|
// Generate artifact.json metadata
|
|
181
194
|
const metadata = {
|
|
182
195
|
packageId: config.id,
|
|
@@ -185,7 +198,7 @@ export async function buildProject(options: BuildOptions): Promise<BuildResult>
|
|
|
185
198
|
description: config.description,
|
|
186
199
|
target: options.target || "host",
|
|
187
200
|
actions: actionImports.map((a) => a.id),
|
|
188
|
-
bunVersion:
|
|
201
|
+
bunVersion: detectedBunVersion,
|
|
189
202
|
lockHash,
|
|
190
203
|
buildHash,
|
|
191
204
|
createdAt: new Date().toISOString(),
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadManifest } from "../project/manifest";
|
|
4
|
+
import type { CatalogSnapshot, IndexedAction } from "./types";
|
|
5
|
+
|
|
6
|
+
export class ActionIndex {
|
|
7
|
+
private actions = new Map<string, IndexedAction[]>(); // actionId -> IndexedAction[]
|
|
8
|
+
|
|
9
|
+
constructor(snapshot: CatalogSnapshot) {
|
|
10
|
+
this.indexSnapshot(snapshot);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private indexSnapshot(snapshot: CatalogSnapshot): void {
|
|
14
|
+
for (const [packageId, pkg] of snapshot.packages) {
|
|
15
|
+
const manifest = loadManifest(pkg.projectRoot);
|
|
16
|
+
if (manifest && manifest.actions) {
|
|
17
|
+
for (const [actionId, item] of Object.entries(manifest.actions)) {
|
|
18
|
+
const indexed: IndexedAction = {
|
|
19
|
+
packageId,
|
|
20
|
+
actionId,
|
|
21
|
+
contract: {
|
|
22
|
+
id: actionId,
|
|
23
|
+
description: item.description,
|
|
24
|
+
inputSchema: item.inputSchema,
|
|
25
|
+
outputSchema: item.outputSchema,
|
|
26
|
+
uses: item.uses,
|
|
27
|
+
tags: item.tags,
|
|
28
|
+
annotations: item.annotations as any,
|
|
29
|
+
},
|
|
30
|
+
entry: item.entry,
|
|
31
|
+
projectRoot: pkg.projectRoot,
|
|
32
|
+
};
|
|
33
|
+
this.add(indexed);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
// 向后兼容回退:若未提供清单,扫描 actions 目录生成基础索引
|
|
37
|
+
const actionsDir = join(pkg.projectRoot, pkg.config.actionsDir || "actions");
|
|
38
|
+
if (existsSync(actionsDir)) {
|
|
39
|
+
try {
|
|
40
|
+
const files = readdirSync(actionsDir);
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
if (file.endsWith(".ts") || file.endsWith(".js")) {
|
|
43
|
+
const actionId = file.replace(/\.(ts|js)$/, "");
|
|
44
|
+
const indexed: IndexedAction = {
|
|
45
|
+
packageId,
|
|
46
|
+
actionId,
|
|
47
|
+
contract: {
|
|
48
|
+
id: actionId,
|
|
49
|
+
},
|
|
50
|
+
entry: join(pkg.config.actionsDir || "actions", file),
|
|
51
|
+
projectRoot: pkg.projectRoot,
|
|
52
|
+
};
|
|
53
|
+
this.add(indexed);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// 忽略读取目录异常
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private add(action: IndexedAction): void {
|
|
65
|
+
let list = this.actions.get(action.actionId);
|
|
66
|
+
if (!list) {
|
|
67
|
+
list = [];
|
|
68
|
+
this.actions.set(action.actionId, list);
|
|
69
|
+
}
|
|
70
|
+
list.push(action);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public list(packageId?: string): IndexedAction[] {
|
|
74
|
+
const all: IndexedAction[] = [];
|
|
75
|
+
for (const [_, list] of this.actions) {
|
|
76
|
+
for (const action of list) {
|
|
77
|
+
if (!packageId || action.packageId === packageId) {
|
|
78
|
+
all.push(action);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return all;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public find(actionId: string, packageId?: string): IndexedAction[] {
|
|
86
|
+
const matches = this.actions.get(actionId) || [];
|
|
87
|
+
if (packageId) {
|
|
88
|
+
return matches.filter((a) => a.packageId === packageId);
|
|
89
|
+
}
|
|
90
|
+
return matches;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ActionRef, ResolvedActionRef } from "@actiondock/sdk";
|
|
2
|
+
import { ActionIndex } from "./action-index";
|
|
3
|
+
import type { CatalogSnapshot, IndexedAction } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface ResolveOptions {
|
|
6
|
+
currentPackageId?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ActionResolver {
|
|
10
|
+
private snapshot: CatalogSnapshot;
|
|
11
|
+
private index: ActionIndex;
|
|
12
|
+
|
|
13
|
+
constructor(snapshot: CatalogSnapshot, index?: ActionIndex) {
|
|
14
|
+
this.snapshot = snapshot;
|
|
15
|
+
this.index = index || new ActionIndex(snapshot);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 解析字符串或 ActionRef 为规范化的 ActionRef。
|
|
20
|
+
* 支持 greet、my-tools/greet、@team/github/issues.list。
|
|
21
|
+
*/
|
|
22
|
+
public static parseRef(refStringOrObj: string | ActionRef): ActionRef {
|
|
23
|
+
if (typeof refStringOrObj === "object") {
|
|
24
|
+
return refStringOrObj;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const str = refStringOrObj.trim();
|
|
28
|
+
if (str.includes("/")) {
|
|
29
|
+
const lastSlashIndex = str.lastIndexOf("/");
|
|
30
|
+
const packageId = str.slice(0, lastSlashIndex);
|
|
31
|
+
const actionId = str.slice(lastSlashIndex + 1);
|
|
32
|
+
|
|
33
|
+
if (actionId.includes(":") || actionId.includes("/") || actionId.includes("..")) {
|
|
34
|
+
throw new Error(`Invalid action identifier: '${actionId}'`);
|
|
35
|
+
}
|
|
36
|
+
return { packageId, actionId };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 处理旧语法 package:action 的兼容提醒
|
|
40
|
+
if (str.includes(":")) {
|
|
41
|
+
const parts = str.split(":");
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Legacy syntax '${str}' is deprecated. Please use '${parts.join("/")}' instead.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { actionId: str };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public resolve(
|
|
51
|
+
refStringOrObj: string | ActionRef,
|
|
52
|
+
options: ResolveOptions = {}
|
|
53
|
+
): { resolved: ResolvedActionRef; action: IndexedAction } {
|
|
54
|
+
const ref = ActionResolver.parseRef(refStringOrObj);
|
|
55
|
+
|
|
56
|
+
// 1. 若指定了 packageId,直接精确匹配
|
|
57
|
+
if (ref.packageId) {
|
|
58
|
+
const matches = this.index.find(ref.actionId, ref.packageId);
|
|
59
|
+
if (matches.length === 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`ACTION_NOT_FOUND: Action '${ref.actionId}' not found in package '${ref.packageId}'`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const matched = matches[0];
|
|
65
|
+
const pkg = this.snapshot.packages.get(matched.packageId);
|
|
66
|
+
return {
|
|
67
|
+
resolved: {
|
|
68
|
+
packageId: matched.packageId,
|
|
69
|
+
packageInstanceId: pkg?.packageInstanceId || matched.packageId,
|
|
70
|
+
actionId: matched.actionId,
|
|
71
|
+
generationId: this.snapshot.generationId,
|
|
72
|
+
},
|
|
73
|
+
action: matched,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. 未指定 packageId:优先查找当前调用者包
|
|
78
|
+
if (options.currentPackageId) {
|
|
79
|
+
const inCurrent = this.index.find(ref.actionId, options.currentPackageId);
|
|
80
|
+
if (inCurrent.length > 0) {
|
|
81
|
+
const matched = inCurrent[0];
|
|
82
|
+
const pkg = this.snapshot.packages.get(matched.packageId);
|
|
83
|
+
return {
|
|
84
|
+
resolved: {
|
|
85
|
+
packageId: matched.packageId,
|
|
86
|
+
packageInstanceId: pkg?.packageInstanceId || matched.packageId,
|
|
87
|
+
actionId: matched.actionId,
|
|
88
|
+
generationId: this.snapshot.generationId,
|
|
89
|
+
},
|
|
90
|
+
action: matched,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. 全局唯一匹配检查
|
|
96
|
+
const allMatches = this.index.find(ref.actionId);
|
|
97
|
+
if (allMatches.length === 0) {
|
|
98
|
+
throw new Error(`ACTION_NOT_FOUND: Action '${ref.actionId}' not found in any linked package`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (allMatches.length > 1) {
|
|
102
|
+
const candidates = allMatches.map((m) => `${m.packageId}/${m.actionId}`).join(", ");
|
|
103
|
+
throw new Error(
|
|
104
|
+
`AMBIGUOUS_ACTION_REF: Action '${ref.actionId}' is provided by multiple packages: ${candidates}. Please specify the package name.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const matched = allMatches[0];
|
|
109
|
+
const pkg = this.snapshot.packages.get(matched.packageId);
|
|
110
|
+
return {
|
|
111
|
+
resolved: {
|
|
112
|
+
packageId: matched.packageId,
|
|
113
|
+
packageInstanceId: pkg?.packageInstanceId || matched.packageId,
|
|
114
|
+
actionId: matched.actionId,
|
|
115
|
+
generationId: this.snapshot.generationId,
|
|
116
|
+
},
|
|
117
|
+
action: matched,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { getActionDockHome } from "../utils";
|
|
4
|
+
import type { LocationLink, LocationRegistryData } from "./types";
|
|
5
|
+
|
|
6
|
+
export class LocationRegistry {
|
|
7
|
+
private filePath: string;
|
|
8
|
+
|
|
9
|
+
constructor(customHome?: string) {
|
|
10
|
+
const baseDir = getActionDockHome(customHome);
|
|
11
|
+
this.filePath = join(baseDir, ".actiondock", "registry.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public getFilePath(): string {
|
|
15
|
+
return this.filePath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public load(): LocationRegistryData {
|
|
19
|
+
if (!existsSync(this.filePath)) {
|
|
20
|
+
return { schemaVersion: 1, links: [] };
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (!parsed || typeof parsed !== "object") {
|
|
26
|
+
return { schemaVersion: 1, links: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 处理 2.0 旧格式向 1 迁移
|
|
30
|
+
if (parsed.schemaVersion === 1 && Array.isArray(parsed.links)) {
|
|
31
|
+
return parsed as LocationRegistryData;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const links: LocationLink[] = [];
|
|
35
|
+
if (parsed.workspaces && typeof parsed.workspaces === "object") {
|
|
36
|
+
for (const [wsPath, ws] of Object.entries(parsed.workspaces as Record<string, any>)) {
|
|
37
|
+
links.push({
|
|
38
|
+
type: "workspace",
|
|
39
|
+
path: resolve(wsPath),
|
|
40
|
+
linkedAt: ws.linkedAt || new Date().toISOString(),
|
|
41
|
+
depth: 3,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (parsed.packages && typeof parsed.packages === "object") {
|
|
47
|
+
for (const [_, pkg] of Object.entries(parsed.packages as Record<string, any>)) {
|
|
48
|
+
if (!pkg.workspaceRoot && pkg.path) {
|
|
49
|
+
links.push({
|
|
50
|
+
type: "package",
|
|
51
|
+
path: resolve(pkg.path),
|
|
52
|
+
linkedAt: pkg.linkedAt || new Date().toISOString(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { schemaVersion: 1, links };
|
|
59
|
+
} catch {
|
|
60
|
+
return { schemaVersion: 1, links: [] };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public save(data: LocationRegistryData): void {
|
|
65
|
+
const dir = dirname(this.filePath);
|
|
66
|
+
if (!existsSync(dir)) {
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
70
|
+
writeFileSync(tempPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
71
|
+
renameSync(tempPath, this.filePath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public addLink(path: string, options: { type?: "package" | "workspace"; depth?: number } = {}): LocationLink {
|
|
75
|
+
const absPath = resolve(path);
|
|
76
|
+
const registry = this.load();
|
|
77
|
+
const type = options.type || "package";
|
|
78
|
+
const depth = options.depth ?? (type === "workspace" ? 3 : undefined);
|
|
79
|
+
|
|
80
|
+
const existingIndex = registry.links.findIndex((l) => l.path === absPath);
|
|
81
|
+
const link: LocationLink = {
|
|
82
|
+
type,
|
|
83
|
+
path: absPath,
|
|
84
|
+
linkedAt: new Date().toISOString(),
|
|
85
|
+
depth,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (existingIndex >= 0) {
|
|
89
|
+
registry.links[existingIndex] = link;
|
|
90
|
+
} else {
|
|
91
|
+
registry.links.push(link);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.save(registry);
|
|
95
|
+
return link;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public removeLink(targetPathOrId: string): LocationLink | null {
|
|
99
|
+
const registry = this.load();
|
|
100
|
+
const absPath = resolve(targetPathOrId);
|
|
101
|
+
|
|
102
|
+
const idx = registry.links.findIndex(
|
|
103
|
+
(l) => l.path === absPath || l.path.endsWith(`/${targetPathOrId}`)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (idx >= 0) {
|
|
107
|
+
const [removed] = registry.links.splice(idx, 1);
|
|
108
|
+
this.save(registry);
|
|
109
|
+
return removed;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public prune(): LocationLink[] {
|
|
115
|
+
const registry = this.load();
|
|
116
|
+
const valid: LocationLink[] = [];
|
|
117
|
+
const removed: LocationLink[] = [];
|
|
118
|
+
|
|
119
|
+
for (const link of registry.links) {
|
|
120
|
+
if (existsSync(link.path)) {
|
|
121
|
+
valid.push(link);
|
|
122
|
+
} else {
|
|
123
|
+
removed.push(link);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (removed.length > 0) {
|
|
128
|
+
registry.links = valid;
|
|
129
|
+
this.save(registry);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return removed;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { findProjectRoot, loadProjectConfig } from "../project/loader";
|
|
5
|
+
import { discoverProjects } from "../registry/registry";
|
|
6
|
+
import { LocationRegistry } from "./location-registry";
|
|
7
|
+
import type { CatalogPackageEntry, CatalogSnapshot } from "./types";
|
|
8
|
+
|
|
9
|
+
export class PackageCatalog {
|
|
10
|
+
private registry: LocationRegistry;
|
|
11
|
+
|
|
12
|
+
constructor(registry?: LocationRegistry) {
|
|
13
|
+
this.registry = registry || new LocationRegistry();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public buildSnapshot(cwd: string = process.cwd()): CatalogSnapshot {
|
|
17
|
+
const packages = new Map<string, CatalogPackageEntry>();
|
|
18
|
+
const seenRealPaths = new Map<string, string>(); // realPath -> packageId
|
|
19
|
+
const seenPackageIds = new Map<string, string>(); // packageId -> realPath
|
|
20
|
+
|
|
21
|
+
const registerPackage = (root: string, isChild: boolean = false) => {
|
|
22
|
+
const abs = resolve(root);
|
|
23
|
+
if (!existsSync(abs)) return;
|
|
24
|
+
const real = realpathSync(abs);
|
|
25
|
+
|
|
26
|
+
if (seenRealPaths.has(real)) {
|
|
27
|
+
return; // 符号链接归一化去重
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const config = loadProjectConfig(abs);
|
|
32
|
+
if (seenPackageIds.has(config.id)) {
|
|
33
|
+
const existingPath = seenPackageIds.get(config.id);
|
|
34
|
+
if (existingPath !== real) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`PACKAGE_ID_CONFLICT: Package ID '${config.id}' is declared by multiple directories: '${existingPath}' and '${real}'`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entry: CatalogPackageEntry = {
|
|
42
|
+
id: config.id,
|
|
43
|
+
packageInstanceId: `${config.id}:${real}`,
|
|
44
|
+
projectRoot: abs,
|
|
45
|
+
config,
|
|
46
|
+
isWorkspaceChild: isChild,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
packages.set(config.id, entry);
|
|
50
|
+
seenRealPaths.set(real, config.id);
|
|
51
|
+
seenPackageIds.set(config.id, real);
|
|
52
|
+
} catch (e: any) {
|
|
53
|
+
if (e.message?.startsWith("PACKAGE_ID_CONFLICT")) {
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
// 忽略无效项目
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// 优先包含当前工作目录所在项目
|
|
61
|
+
const currentRoot = findProjectRoot(cwd);
|
|
62
|
+
if (currentRoot) {
|
|
63
|
+
registerPackage(currentRoot);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 扫描注册表中的位置
|
|
67
|
+
const regData = this.registry.load();
|
|
68
|
+
for (const link of regData.links) {
|
|
69
|
+
if (!existsSync(link.path)) continue;
|
|
70
|
+
|
|
71
|
+
if (link.type === "package") {
|
|
72
|
+
registerPackage(link.path);
|
|
73
|
+
} else if (link.type === "workspace") {
|
|
74
|
+
const subprojects = discoverProjects(link.path, link.depth ?? 3);
|
|
75
|
+
for (const sub of subprojects) {
|
|
76
|
+
registerPackage(sub, true);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
generationId: randomUUID(),
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
packages,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|