@ronkovic/aad 0.3.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/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/aad.js +2 -0
- package/package.json +78 -0
- package/src/__tests__/e2e/pipeline-e2e.test.ts +279 -0
- package/src/__tests__/e2e/resume-e2e.test.ts +200 -0
- package/src/__tests__/integration/cli-smoke.test.ts +175 -0
- package/src/__tests__/integration/pipeline.test.ts +346 -0
- package/src/bun-imports.d.ts +14 -0
- package/src/main.ts +52 -0
- package/src/modules/claude-provider/__tests__/claude-cli.adapter.test.ts +277 -0
- package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +127 -0
- package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +347 -0
- package/src/modules/claude-provider/__tests__/effort-strategy.test.ts +212 -0
- package/src/modules/claude-provider/__tests__/provider-registry.test.ts +251 -0
- package/src/modules/claude-provider/__tests__/retry.test.ts +201 -0
- package/src/modules/claude-provider/claude-cli.adapter.ts +156 -0
- package/src/modules/claude-provider/claude-provider.port.ts +35 -0
- package/src/modules/claude-provider/claude-sdk.adapter.ts +217 -0
- package/src/modules/claude-provider/effort-strategy.ts +94 -0
- package/src/modules/claude-provider/index.ts +32 -0
- package/src/modules/claude-provider/provider-registry.ts +92 -0
- package/src/modules/claude-provider/retry.ts +81 -0
- package/src/modules/cli/__tests__/app.test.ts +160 -0
- package/src/modules/cli/__tests__/cleanup.test.ts +111 -0
- package/src/modules/cli/__tests__/commands.test.ts +186 -0
- package/src/modules/cli/__tests__/output.test.ts +329 -0
- package/src/modules/cli/__tests__/resume.test.ts +324 -0
- package/src/modules/cli/__tests__/run.test.ts +168 -0
- package/src/modules/cli/__tests__/shutdown.test.ts +168 -0
- package/src/modules/cli/__tests__/status.test.ts +144 -0
- package/src/modules/cli/app.ts +241 -0
- package/src/modules/cli/commands/cleanup.ts +120 -0
- package/src/modules/cli/commands/resume.ts +156 -0
- package/src/modules/cli/commands/run.ts +322 -0
- package/src/modules/cli/commands/status.ts +101 -0
- package/src/modules/cli/index.ts +29 -0
- package/src/modules/cli/output.ts +256 -0
- package/src/modules/cli/shutdown.ts +122 -0
- package/src/modules/dashboard/__tests__/api-routes.test.ts +204 -0
- package/src/modules/dashboard/__tests__/file-watcher.test.ts +34 -0
- package/src/modules/dashboard/__tests__/server.test.ts +120 -0
- package/src/modules/dashboard/__tests__/sse-broadcaster.test.ts +163 -0
- package/src/modules/dashboard/__tests__/sse-routes.test.ts +58 -0
- package/src/modules/dashboard/__tests__/state-aggregator.test.ts +330 -0
- package/src/modules/dashboard/index.ts +8 -0
- package/src/modules/dashboard/routes/api.ts +84 -0
- package/src/modules/dashboard/routes/sse.ts +37 -0
- package/src/modules/dashboard/server.ts +111 -0
- package/src/modules/dashboard/services/file-watcher.ts +36 -0
- package/src/modules/dashboard/services/sse-broadcaster.ts +81 -0
- package/src/modules/dashboard/services/state-aggregator.ts +132 -0
- package/src/modules/dashboard/ui/dashboard.html +405 -0
- package/src/modules/git-workspace/__tests__/branch-manager.test.ts +335 -0
- package/src/modules/git-workspace/__tests__/git-exec.test.ts +91 -0
- package/src/modules/git-workspace/__tests__/memory-sync.test.ts +273 -0
- package/src/modules/git-workspace/__tests__/merge-service.test.ts +286 -0
- package/src/modules/git-workspace/__tests__/settings-merge.test.ts +163 -0
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +247 -0
- package/src/modules/git-workspace/branch-manager.ts +191 -0
- package/src/modules/git-workspace/git-exec.ts +124 -0
- package/src/modules/git-workspace/index.ts +17 -0
- package/src/modules/git-workspace/memory-sync.ts +89 -0
- package/src/modules/git-workspace/merge-service.ts +156 -0
- package/src/modules/git-workspace/settings-merge.ts +95 -0
- package/src/modules/git-workspace/worktree-manager.ts +199 -0
- package/src/modules/logging/__tests__/log-store.test.ts +242 -0
- package/src/modules/logging/__tests__/logger.test.ts +81 -0
- package/src/modules/logging/__tests__/sse-transport.test.ts +93 -0
- package/src/modules/logging/index.ts +7 -0
- package/src/modules/logging/log-store.ts +80 -0
- package/src/modules/logging/logger.ts +55 -0
- package/src/modules/logging/transports/sse-transport.ts +28 -0
- package/src/modules/multi-repo/__tests__/multi-repo-planner.test.ts +93 -0
- package/src/modules/multi-repo/__tests__/repo-context.test.ts +79 -0
- package/src/modules/multi-repo/index.ts +12 -0
- package/src/modules/multi-repo/multi-repo-planner.ts +112 -0
- package/src/modules/multi-repo/repo-context.ts +71 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/file-lock.test.ts +141 -0
- package/src/modules/persistence/__tests__/index.test.ts +38 -0
- package/src/modules/persistence/__tests__/stores.test.ts +594 -0
- package/src/modules/persistence/file-lock.ts +158 -0
- package/src/modules/persistence/fs-run-store.ts +73 -0
- package/src/modules/persistence/fs-task-store.ts +152 -0
- package/src/modules/persistence/fs-worker-store.ts +116 -0
- package/src/modules/persistence/in-memory-stores.ts +98 -0
- package/src/modules/persistence/index.ts +60 -0
- package/src/modules/persistence/stores.port.ts +60 -0
- package/src/modules/planning/__tests__/file-conflict-validator.test.ts +256 -0
- package/src/modules/planning/__tests__/planning-service.test.ts +366 -0
- package/src/modules/planning/__tests__/project-detection.test.ts +707 -0
- package/src/modules/planning/file-conflict-validator.ts +135 -0
- package/src/modules/planning/index.ts +40 -0
- package/src/modules/planning/planning.service.ts +262 -0
- package/src/modules/planning/project-detection.ts +525 -0
- package/src/modules/plugin/__tests__/plugin-loader.test.ts +83 -0
- package/src/modules/plugin/__tests__/plugin-manager.test.ts +187 -0
- package/src/modules/plugin/index.ts +3 -0
- package/src/modules/plugin/plugin-loader.ts +46 -0
- package/src/modules/plugin/plugin-manager.ts +90 -0
- package/src/modules/plugin/plugin.types.ts +37 -0
- package/src/modules/process-manager/__tests__/process-manager.test.ts +210 -0
- package/src/modules/process-manager/__tests__/worker.test.ts +89 -0
- package/src/modules/process-manager/index.ts +5 -0
- package/src/modules/process-manager/process-manager.ts +193 -0
- package/src/modules/process-manager/worker.ts +106 -0
- package/src/modules/task-execution/__tests__/default-spawner.test.ts +154 -0
- package/src/modules/task-execution/__tests__/executor.test.ts +760 -0
- package/src/modules/task-execution/__tests__/implementer-green.test.ts +286 -0
- package/src/modules/task-execution/__tests__/merge-phase.test.ts +368 -0
- package/src/modules/task-execution/__tests__/reviewer.test.ts +302 -0
- package/src/modules/task-execution/__tests__/tester-red.test.ts +281 -0
- package/src/modules/task-execution/__tests__/tester-verify.test.ts +313 -0
- package/src/modules/task-execution/executor.ts +303 -0
- package/src/modules/task-execution/index.ts +45 -0
- package/src/modules/task-execution/phases/default-spawner.ts +49 -0
- package/src/modules/task-execution/phases/implementer-green.ts +100 -0
- package/src/modules/task-execution/phases/merge.ts +122 -0
- package/src/modules/task-execution/phases/reviewer.ts +160 -0
- package/src/modules/task-execution/phases/tester-red.ts +100 -0
- package/src/modules/task-execution/phases/tester-verify.ts +120 -0
- package/src/modules/task-queue/__tests__/dependency-resolver.test.ts +456 -0
- package/src/modules/task-queue/__tests__/dispatcher.test.ts +824 -0
- package/src/modules/task-queue/__tests__/task-plan.test.ts +122 -0
- package/src/modules/task-queue/__tests__/task.test.ts +130 -0
- package/src/modules/task-queue/dependency-resolver.ts +171 -0
- package/src/modules/task-queue/dispatcher.ts +372 -0
- package/src/modules/task-queue/index.ts +16 -0
- package/src/modules/task-queue/task-plan.ts +40 -0
- package/src/modules/task-queue/task.ts +67 -0
- package/src/shared/__tests__/config.test.ts +204 -0
- package/src/shared/__tests__/errors.test.ts +285 -0
- package/src/shared/__tests__/events.test.ts +496 -0
- package/src/shared/__tests__/types.test.ts +360 -0
- package/src/shared/config.ts +133 -0
- package/src/shared/errors.ts +128 -0
- package/src/shared/events.ts +171 -0
- package/src/shared/types.ts +143 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Logger } from "pino";
|
|
4
|
+
import { FileLockError } from "@aad/shared/errors";
|
|
5
|
+
|
|
6
|
+
export interface FileLockOptions {
|
|
7
|
+
lockDir: string;
|
|
8
|
+
timeout?: number;
|
|
9
|
+
retryInterval?: number;
|
|
10
|
+
logger?: Logger;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* mkdir-based atomic file lock (Bash互換)
|
|
15
|
+
* PID file for stale lock detection
|
|
16
|
+
*/
|
|
17
|
+
export class FileLock {
|
|
18
|
+
private lockDir: string;
|
|
19
|
+
private pidFile: string;
|
|
20
|
+
private timeout: number;
|
|
21
|
+
private retryInterval: number;
|
|
22
|
+
private logger?: Logger;
|
|
23
|
+
private acquired: boolean = false;
|
|
24
|
+
|
|
25
|
+
constructor(options: FileLockOptions) {
|
|
26
|
+
this.lockDir = options.lockDir;
|
|
27
|
+
this.pidFile = join(options.lockDir, "pid");
|
|
28
|
+
this.timeout = options.timeout ?? 30000; // 30s default
|
|
29
|
+
this.retryInterval = options.retryInterval ?? 100; // 100ms default
|
|
30
|
+
this.logger = options.logger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async acquire(): Promise<void> {
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
|
|
36
|
+
while (true) {
|
|
37
|
+
try {
|
|
38
|
+
// Try mkdir (atomic operation)
|
|
39
|
+
await mkdir(this.lockDir, { recursive: false });
|
|
40
|
+
|
|
41
|
+
// Write PID file
|
|
42
|
+
await Bun.write(this.pidFile, `${process.pid}`);
|
|
43
|
+
|
|
44
|
+
this.acquired = true;
|
|
45
|
+
this.logger?.debug({ lockDir: this.lockDir, pid: process.pid }, "Lock acquired");
|
|
46
|
+
return;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const err = error as NodeJS.ErrnoException;
|
|
49
|
+
|
|
50
|
+
if (err.code !== "EEXIST") {
|
|
51
|
+
throw new FileLockError("Failed to create lock directory", {
|
|
52
|
+
lockDir: this.lockDir,
|
|
53
|
+
error: err.message,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Lock exists, check if stale
|
|
58
|
+
const isStale = await this.isLockStale();
|
|
59
|
+
if (isStale) {
|
|
60
|
+
this.logger?.warn({ lockDir: this.lockDir }, "Removing stale lock");
|
|
61
|
+
await this.forceRelease();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check timeout
|
|
66
|
+
const elapsed = Date.now() - startTime;
|
|
67
|
+
if (elapsed >= this.timeout) {
|
|
68
|
+
const holder = await this.getLockHolder();
|
|
69
|
+
throw new FileLockError("Lock acquisition timeout", {
|
|
70
|
+
lockDir: this.lockDir,
|
|
71
|
+
timeout: this.timeout,
|
|
72
|
+
holder,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Wait and retry
|
|
77
|
+
await Bun.sleep(this.retryInterval);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async release(): Promise<void> {
|
|
83
|
+
if (!this.acquired) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await rm(this.lockDir, { recursive: true, force: true });
|
|
89
|
+
this.acquired = false;
|
|
90
|
+
this.logger?.debug({ lockDir: this.lockDir }, "Lock released");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const err = error as NodeJS.ErrnoException;
|
|
93
|
+
throw new FileLockError("Failed to release lock", {
|
|
94
|
+
lockDir: this.lockDir,
|
|
95
|
+
error: err.message,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async forceRelease(): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
await rm(this.lockDir, { recursive: true, force: true });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.logger?.debug({ error, lockDir: this.lockDir }, "Failed to force release lock (ignored)");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async isLockStale(): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
const pidContent = await Bun.file(this.pidFile).text();
|
|
111
|
+
const pid = Number.parseInt(pidContent.trim(), 10);
|
|
112
|
+
|
|
113
|
+
if (Number.isNaN(pid)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check if process exists (signal 0 = test existence)
|
|
118
|
+
try {
|
|
119
|
+
process.kill(pid);
|
|
120
|
+
return false; // Process exists
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.logger?.debug({ error, pid }, "Process does not exist (stale lock)");
|
|
123
|
+
return true; // Process doesn't exist
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.logger?.debug({ error, pidFile: this.pidFile }, "PID file not readable (stale lock)");
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async getLockHolder(): Promise<number | null> {
|
|
132
|
+
try {
|
|
133
|
+
const pidContent = await Bun.file(this.pidFile).text();
|
|
134
|
+
const pid = Number.parseInt(pidContent.trim(), 10);
|
|
135
|
+
return Number.isNaN(pid) ? null : pid;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.logger?.debug({ error, pidFile: this.pidFile }, "Failed to read lock holder PID");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Helper for using FileLock with automatic cleanup
|
|
145
|
+
*/
|
|
146
|
+
export async function withFileLock<T>(
|
|
147
|
+
options: FileLockOptions,
|
|
148
|
+
fn: () => Promise<T>
|
|
149
|
+
): Promise<T> {
|
|
150
|
+
const lock = new FileLock(options);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await lock.acquire();
|
|
154
|
+
return await fn();
|
|
155
|
+
} finally {
|
|
156
|
+
await lock.release();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Logger } from "pino";
|
|
4
|
+
import type { RunState, RunId } from "@aad/shared/types";
|
|
5
|
+
import { PersistenceError } from "@aad/shared/errors";
|
|
6
|
+
import { RunStateSchema, type RunStore } from "./stores.port";
|
|
7
|
+
|
|
8
|
+
export interface FSRunStoreOptions {
|
|
9
|
+
basePath: string;
|
|
10
|
+
logger?: Logger;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* File system adapter for RunStore
|
|
15
|
+
* Structure: {basePath}/progress.json
|
|
16
|
+
*/
|
|
17
|
+
export class FSRunStore implements RunStore {
|
|
18
|
+
private basePath: string;
|
|
19
|
+
private progressFile: string;
|
|
20
|
+
private logger?: Logger;
|
|
21
|
+
|
|
22
|
+
constructor(options: FSRunStoreOptions) {
|
|
23
|
+
this.basePath = options.basePath;
|
|
24
|
+
this.progressFile = join(this.basePath, "progress.json");
|
|
25
|
+
this.logger = options.logger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async get(runId: RunId): Promise<RunState | null> {
|
|
29
|
+
try {
|
|
30
|
+
const content = await Bun.file(this.progressFile).text();
|
|
31
|
+
const data = JSON.parse(content);
|
|
32
|
+
const state = RunStateSchema.parse(data) as RunState;
|
|
33
|
+
|
|
34
|
+
// Return only if runId matches
|
|
35
|
+
if (state.runId === runId) {
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getLatest(): Promise<RunState | null> {
|
|
45
|
+
try {
|
|
46
|
+
const content = await Bun.file(this.progressFile).text();
|
|
47
|
+
const data = JSON.parse(content);
|
|
48
|
+
const state = RunStateSchema.parse(data) as RunState;
|
|
49
|
+
return state;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async save(state: RunState): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await mkdir(this.basePath, { recursive: true });
|
|
58
|
+
|
|
59
|
+
// Atomic write: write to temp file, then rename
|
|
60
|
+
const tempPath = `${this.progressFile}.tmp`;
|
|
61
|
+
await Bun.write(tempPath, JSON.stringify(state, null, 2));
|
|
62
|
+
await rename(tempPath, this.progressFile);
|
|
63
|
+
|
|
64
|
+
this.logger?.debug({ runId: state.runId }, "Run state saved");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const err = error as Error;
|
|
67
|
+
throw new PersistenceError("Failed to save run state", {
|
|
68
|
+
runId: state.runId,
|
|
69
|
+
error: err.message,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Glob } from "bun";
|
|
4
|
+
import type { Logger } from "pino";
|
|
5
|
+
import type { Task, TaskId, TaskStatus } from "@aad/shared/types";
|
|
6
|
+
import { PersistenceError } from "@aad/shared/errors";
|
|
7
|
+
import { TaskSchema, type TaskStore } from "./stores.port";
|
|
8
|
+
|
|
9
|
+
export interface FSTaskStoreOptions {
|
|
10
|
+
basePath: string;
|
|
11
|
+
logger?: Logger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* File system adapter for TaskStore
|
|
16
|
+
* Structure: {basePath}/queue/{status}/{taskId}.json
|
|
17
|
+
*/
|
|
18
|
+
export class FSTaskStore implements TaskStore {
|
|
19
|
+
private basePath: string;
|
|
20
|
+
private logger?: Logger;
|
|
21
|
+
|
|
22
|
+
constructor(options: FSTaskStoreOptions) {
|
|
23
|
+
this.basePath = options.basePath;
|
|
24
|
+
this.logger = options.logger;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async get(taskId: TaskId): Promise<Task | null> {
|
|
28
|
+
// Search in all status directories
|
|
29
|
+
for (const status of ["pending", "running", "completed", "failed"]) {
|
|
30
|
+
const filePath = this.getTaskPath(status as TaskStatus, taskId);
|
|
31
|
+
try {
|
|
32
|
+
const content = await Bun.file(filePath).text();
|
|
33
|
+
const data = JSON.parse(content);
|
|
34
|
+
return TaskSchema.parse(data) as Task;
|
|
35
|
+
} catch {
|
|
36
|
+
// File not found or parse error, continue
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getAll(): Promise<Task[]> {
|
|
43
|
+
const tasks: Task[] = [];
|
|
44
|
+
|
|
45
|
+
for (const status of ["pending", "running", "completed", "failed"]) {
|
|
46
|
+
const statusTasks = await this.getByStatus(status as TaskStatus);
|
|
47
|
+
tasks.push(...statusTasks);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return tasks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getByStatus(status: TaskStatus): Promise<Task[]> {
|
|
54
|
+
const statusDir = this.getStatusDir(status);
|
|
55
|
+
const tasks: Task[] = [];
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await mkdir(statusDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
const glob = new Glob("*.json");
|
|
61
|
+
const files = glob.scanSync(statusDir);
|
|
62
|
+
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
// Null byte防御: 空、null byteを含むエントリ、または.jsonで終わらないエントリをスキップ
|
|
65
|
+
if (!file || file.includes('\0') || !file.endsWith('.json')) {
|
|
66
|
+
this.logger?.warn({ file }, "Skipping invalid file entry");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const filePath = join(statusDir, file);
|
|
72
|
+
const content = await Bun.file(filePath).text();
|
|
73
|
+
const data = JSON.parse(content);
|
|
74
|
+
const task = TaskSchema.parse(data) as Task;
|
|
75
|
+
tasks.push(task);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
this.logger?.warn({ file, error }, "Failed to parse task file");
|
|
78
|
+
// Continue to next file instead of throwing
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const err = error as Error;
|
|
83
|
+
// Log error but return empty array instead of throwing
|
|
84
|
+
this.logger?.error({ status, statusDir, error: err.message }, "Failed to read status directory");
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return tasks;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async save(task: Task): Promise<void> {
|
|
92
|
+
const filePath = this.getTaskPath(task.status, task.taskId);
|
|
93
|
+
const statusDir = this.getStatusDir(task.status);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(statusDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
// Atomic write: write to temp file, then rename
|
|
99
|
+
const tempPath = `${filePath}.tmp`;
|
|
100
|
+
await Bun.write(tempPath, JSON.stringify(task, null, 2));
|
|
101
|
+
await rename(tempPath, filePath);
|
|
102
|
+
|
|
103
|
+
// Clean up old status files (if status changed)
|
|
104
|
+
await this.cleanupOldStatusFiles(task.taskId, task.status);
|
|
105
|
+
|
|
106
|
+
this.logger?.debug({ taskId: task.taskId, status: task.status }, "Task saved");
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const err = error as Error;
|
|
109
|
+
throw new PersistenceError("Failed to save task", {
|
|
110
|
+
taskId: task.taskId,
|
|
111
|
+
status: task.status,
|
|
112
|
+
error: err.message,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async delete(taskId: TaskId): Promise<void> {
|
|
118
|
+
// Remove from all status directories
|
|
119
|
+
for (const status of ["pending", "running", "completed", "failed"]) {
|
|
120
|
+
const filePath = this.getTaskPath(status as TaskStatus, taskId);
|
|
121
|
+
try {
|
|
122
|
+
await rm(filePath, { force: true });
|
|
123
|
+
} catch {
|
|
124
|
+
// Ignore if file doesn't exist
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.logger?.debug({ taskId }, "Task deleted");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private getStatusDir(status: TaskStatus): string {
|
|
132
|
+
return join(this.basePath, "queue", status);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private getTaskPath(status: TaskStatus, taskId: TaskId): string {
|
|
136
|
+
return join(this.getStatusDir(status), `${taskId as string}.json`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async cleanupOldStatusFiles(taskId: TaskId, currentStatus: TaskStatus): Promise<void> {
|
|
140
|
+
const statuses: TaskStatus[] = ["pending", "running", "completed", "failed"];
|
|
141
|
+
for (const status of statuses) {
|
|
142
|
+
if (status !== currentStatus) {
|
|
143
|
+
const filePath = this.getTaskPath(status, taskId);
|
|
144
|
+
try {
|
|
145
|
+
await rm(filePath, { force: true });
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore errors
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Glob } from "bun";
|
|
4
|
+
import type { Logger } from "pino";
|
|
5
|
+
import type { Worker, WorkerId } from "@aad/shared/types";
|
|
6
|
+
import { PersistenceError } from "@aad/shared/errors";
|
|
7
|
+
import { WorkerSchema, type WorkerStore } from "./stores.port";
|
|
8
|
+
|
|
9
|
+
export interface FSWorkerStoreOptions {
|
|
10
|
+
basePath: string;
|
|
11
|
+
logger?: Logger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* File system adapter for WorkerStore
|
|
16
|
+
* Structure: {basePath}/workers/worker-{id}.json
|
|
17
|
+
*/
|
|
18
|
+
export class FSWorkerStore implements WorkerStore {
|
|
19
|
+
private basePath: string;
|
|
20
|
+
private workersDir: string;
|
|
21
|
+
private logger?: Logger;
|
|
22
|
+
|
|
23
|
+
constructor(options: FSWorkerStoreOptions) {
|
|
24
|
+
this.basePath = options.basePath;
|
|
25
|
+
this.workersDir = join(this.basePath, "workers");
|
|
26
|
+
this.logger = options.logger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get(workerId: WorkerId): Promise<Worker | null> {
|
|
30
|
+
const filePath = this.getWorkerPath(workerId);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const content = await Bun.file(filePath).text();
|
|
34
|
+
const data = JSON.parse(content);
|
|
35
|
+
return WorkerSchema.parse(data) as Worker;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getAll(): Promise<Worker[]> {
|
|
42
|
+
const workers: Worker[] = [];
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await mkdir(this.workersDir, { recursive: true });
|
|
46
|
+
|
|
47
|
+
const glob = new Glob("worker-*.json");
|
|
48
|
+
const files = glob.scanSync(this.workersDir);
|
|
49
|
+
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
try {
|
|
52
|
+
const filePath = join(this.workersDir, file);
|
|
53
|
+
const content = await Bun.file(filePath).text();
|
|
54
|
+
const data = JSON.parse(content);
|
|
55
|
+
const worker = WorkerSchema.parse(data) as Worker;
|
|
56
|
+
workers.push(worker);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.logger?.warn({ file, error }, "Failed to parse worker file");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const err = error as Error;
|
|
63
|
+
throw new PersistenceError("Failed to read workers", {
|
|
64
|
+
workersDir: this.workersDir,
|
|
65
|
+
error: err.message,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return workers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getIdle(): Promise<Worker[]> {
|
|
73
|
+
const allWorkers = await this.getAll();
|
|
74
|
+
return allWorkers.filter((worker) => worker.status === "idle");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async save(worker: Worker): Promise<void> {
|
|
78
|
+
const filePath = this.getWorkerPath(worker.workerId);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await mkdir(this.workersDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
// Atomic write: write to temp file, then rename
|
|
84
|
+
const tempPath = `${filePath}.tmp`;
|
|
85
|
+
await Bun.write(tempPath, JSON.stringify(worker, null, 2));
|
|
86
|
+
await rename(tempPath, filePath);
|
|
87
|
+
|
|
88
|
+
this.logger?.debug({ workerId: worker.workerId, status: worker.status }, "Worker saved");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const err = error as Error;
|
|
91
|
+
throw new PersistenceError("Failed to save worker", {
|
|
92
|
+
workerId: worker.workerId,
|
|
93
|
+
error: err.message,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async delete(workerId: WorkerId): Promise<void> {
|
|
99
|
+
const filePath = this.getWorkerPath(workerId);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await rm(filePath, { force: true });
|
|
103
|
+
this.logger?.debug({ workerId }, "Worker deleted");
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const err = error as Error;
|
|
106
|
+
throw new PersistenceError("Failed to delete worker", {
|
|
107
|
+
workerId,
|
|
108
|
+
error: err.message,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getWorkerPath(workerId: WorkerId): string {
|
|
114
|
+
return join(this.workersDir, `${workerId as string}.json`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Task, TaskId, TaskStatus, Worker, WorkerId, RunState, RunId } from "@aad/shared/types";
|
|
2
|
+
import type { TaskStore, WorkerStore, RunStore } from "./stores.port";
|
|
3
|
+
|
|
4
|
+
export class InMemoryTaskStore implements TaskStore {
|
|
5
|
+
private tasks: Map<string, Task> = new Map();
|
|
6
|
+
|
|
7
|
+
get(taskId: TaskId): Promise<Task | null> {
|
|
8
|
+
return Promise.resolve(this.tasks.get(taskId as string) ?? null);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
getAll(): Promise<Task[]> {
|
|
12
|
+
return Promise.resolve(Array.from(this.tasks.values()));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getByStatus(status: TaskStatus): Promise<Task[]> {
|
|
16
|
+
return Promise.resolve(Array.from(this.tasks.values()).filter((task) => task.status === status));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
save(task: Task): Promise<void> {
|
|
20
|
+
this.tasks.set(task.taskId as string, { ...task });
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
delete(taskId: TaskId): Promise<void> {
|
|
25
|
+
this.tasks.delete(taskId as string);
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Test helper
|
|
30
|
+
clear(): void {
|
|
31
|
+
this.tasks.clear();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class InMemoryWorkerStore implements WorkerStore {
|
|
36
|
+
private workers: Map<string, Worker> = new Map();
|
|
37
|
+
|
|
38
|
+
get(workerId: WorkerId): Promise<Worker | null> {
|
|
39
|
+
return Promise.resolve(this.workers.get(workerId as string) ?? null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getAll(): Promise<Worker[]> {
|
|
43
|
+
return Promise.resolve(Array.from(this.workers.values()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getIdle(): Promise<Worker[]> {
|
|
47
|
+
return Promise.resolve(Array.from(this.workers.values()).filter((worker) => worker.status === "idle"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
save(worker: Worker): Promise<void> {
|
|
51
|
+
this.workers.set(worker.workerId as string, { ...worker });
|
|
52
|
+
return Promise.resolve();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
delete(workerId: WorkerId): Promise<void> {
|
|
56
|
+
this.workers.delete(workerId as string);
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Test helper
|
|
61
|
+
clear(): void {
|
|
62
|
+
this.workers.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class InMemoryRunStore implements RunStore {
|
|
67
|
+
private states: Map<string, RunState> = new Map();
|
|
68
|
+
|
|
69
|
+
get(runId: RunId): Promise<RunState | null> {
|
|
70
|
+
return Promise.resolve(this.states.get(runId as string) ?? null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getLatest(): Promise<RunState | null> {
|
|
74
|
+
const allStates = Array.from(this.states.values());
|
|
75
|
+
if (allStates.length === 0) {
|
|
76
|
+
return Promise.resolve(null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Sort by startTime descending (newest first)
|
|
80
|
+
allStates.sort((a, b) => {
|
|
81
|
+
const timeA = new Date(a.startTime).getTime();
|
|
82
|
+
const timeB = new Date(b.startTime).getTime();
|
|
83
|
+
return timeB - timeA;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return Promise.resolve(allStates[0] ?? null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
save(state: RunState): Promise<void> {
|
|
90
|
+
this.states.set(state.runId as string, { ...state });
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Test helper
|
|
95
|
+
clear(): void {
|
|
96
|
+
this.states.clear();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import type { TaskStore, WorkerStore, RunStore } from "./stores.port";
|
|
3
|
+
import { InMemoryTaskStore, InMemoryWorkerStore, InMemoryRunStore } from "./in-memory-stores";
|
|
4
|
+
import { FSTaskStore } from "./fs-task-store";
|
|
5
|
+
import { FSWorkerStore } from "./fs-worker-store";
|
|
6
|
+
import { FSRunStore } from "./fs-run-store";
|
|
7
|
+
|
|
8
|
+
export type { TaskStore, WorkerStore, RunStore } from "./stores.port";
|
|
9
|
+
export { TaskSchema, WorkerSchema, RunStateSchema } from "./stores.port";
|
|
10
|
+
export { FileLock, withFileLock } from "./file-lock";
|
|
11
|
+
export type { FileLockOptions } from "./file-lock";
|
|
12
|
+
|
|
13
|
+
export interface StoresOptions {
|
|
14
|
+
basePath?: string;
|
|
15
|
+
logger?: Logger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Stores {
|
|
19
|
+
taskStore: TaskStore;
|
|
20
|
+
workerStore: WorkerStore;
|
|
21
|
+
runStore: RunStore;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Factory function to create persistence stores
|
|
26
|
+
* @param mode - "memory" for in-memory stores, "fs" for file system stores
|
|
27
|
+
* @param options - Configuration options
|
|
28
|
+
*/
|
|
29
|
+
export function createStores(mode: "memory" | "fs", options: StoresOptions = {}): Stores {
|
|
30
|
+
if (mode === "memory") {
|
|
31
|
+
return {
|
|
32
|
+
taskStore: new InMemoryTaskStore(),
|
|
33
|
+
workerStore: new InMemoryWorkerStore(),
|
|
34
|
+
runStore: new InMemoryRunStore(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (mode === "fs") {
|
|
39
|
+
if (!options.basePath) {
|
|
40
|
+
throw new Error("basePath is required for fs mode");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
taskStore: new FSTaskStore({
|
|
45
|
+
basePath: options.basePath,
|
|
46
|
+
logger: options.logger?.child({ adapter: "FSTaskStore" }),
|
|
47
|
+
}),
|
|
48
|
+
workerStore: new FSWorkerStore({
|
|
49
|
+
basePath: options.basePath,
|
|
50
|
+
logger: options.logger?.child({ adapter: "FSWorkerStore" }),
|
|
51
|
+
}),
|
|
52
|
+
runStore: new FSRunStore({
|
|
53
|
+
basePath: options.basePath,
|
|
54
|
+
logger: options.logger?.child({ adapter: "FSRunStore" }),
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`Unknown store mode: ${mode}`);
|
|
60
|
+
}
|