@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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/aad.js +2 -0
  4. package/package.json +78 -0
  5. package/src/__tests__/e2e/pipeline-e2e.test.ts +279 -0
  6. package/src/__tests__/e2e/resume-e2e.test.ts +200 -0
  7. package/src/__tests__/integration/cli-smoke.test.ts +175 -0
  8. package/src/__tests__/integration/pipeline.test.ts +346 -0
  9. package/src/bun-imports.d.ts +14 -0
  10. package/src/main.ts +52 -0
  11. package/src/modules/claude-provider/__tests__/claude-cli.adapter.test.ts +277 -0
  12. package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +127 -0
  13. package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +347 -0
  14. package/src/modules/claude-provider/__tests__/effort-strategy.test.ts +212 -0
  15. package/src/modules/claude-provider/__tests__/provider-registry.test.ts +251 -0
  16. package/src/modules/claude-provider/__tests__/retry.test.ts +201 -0
  17. package/src/modules/claude-provider/claude-cli.adapter.ts +156 -0
  18. package/src/modules/claude-provider/claude-provider.port.ts +35 -0
  19. package/src/modules/claude-provider/claude-sdk.adapter.ts +217 -0
  20. package/src/modules/claude-provider/effort-strategy.ts +94 -0
  21. package/src/modules/claude-provider/index.ts +32 -0
  22. package/src/modules/claude-provider/provider-registry.ts +92 -0
  23. package/src/modules/claude-provider/retry.ts +81 -0
  24. package/src/modules/cli/__tests__/app.test.ts +160 -0
  25. package/src/modules/cli/__tests__/cleanup.test.ts +111 -0
  26. package/src/modules/cli/__tests__/commands.test.ts +186 -0
  27. package/src/modules/cli/__tests__/output.test.ts +329 -0
  28. package/src/modules/cli/__tests__/resume.test.ts +324 -0
  29. package/src/modules/cli/__tests__/run.test.ts +168 -0
  30. package/src/modules/cli/__tests__/shutdown.test.ts +168 -0
  31. package/src/modules/cli/__tests__/status.test.ts +144 -0
  32. package/src/modules/cli/app.ts +241 -0
  33. package/src/modules/cli/commands/cleanup.ts +120 -0
  34. package/src/modules/cli/commands/resume.ts +156 -0
  35. package/src/modules/cli/commands/run.ts +322 -0
  36. package/src/modules/cli/commands/status.ts +101 -0
  37. package/src/modules/cli/index.ts +29 -0
  38. package/src/modules/cli/output.ts +256 -0
  39. package/src/modules/cli/shutdown.ts +122 -0
  40. package/src/modules/dashboard/__tests__/api-routes.test.ts +204 -0
  41. package/src/modules/dashboard/__tests__/file-watcher.test.ts +34 -0
  42. package/src/modules/dashboard/__tests__/server.test.ts +120 -0
  43. package/src/modules/dashboard/__tests__/sse-broadcaster.test.ts +163 -0
  44. package/src/modules/dashboard/__tests__/sse-routes.test.ts +58 -0
  45. package/src/modules/dashboard/__tests__/state-aggregator.test.ts +330 -0
  46. package/src/modules/dashboard/index.ts +8 -0
  47. package/src/modules/dashboard/routes/api.ts +84 -0
  48. package/src/modules/dashboard/routes/sse.ts +37 -0
  49. package/src/modules/dashboard/server.ts +111 -0
  50. package/src/modules/dashboard/services/file-watcher.ts +36 -0
  51. package/src/modules/dashboard/services/sse-broadcaster.ts +81 -0
  52. package/src/modules/dashboard/services/state-aggregator.ts +132 -0
  53. package/src/modules/dashboard/ui/dashboard.html +405 -0
  54. package/src/modules/git-workspace/__tests__/branch-manager.test.ts +335 -0
  55. package/src/modules/git-workspace/__tests__/git-exec.test.ts +91 -0
  56. package/src/modules/git-workspace/__tests__/memory-sync.test.ts +273 -0
  57. package/src/modules/git-workspace/__tests__/merge-service.test.ts +286 -0
  58. package/src/modules/git-workspace/__tests__/settings-merge.test.ts +163 -0
  59. package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +247 -0
  60. package/src/modules/git-workspace/branch-manager.ts +191 -0
  61. package/src/modules/git-workspace/git-exec.ts +124 -0
  62. package/src/modules/git-workspace/index.ts +17 -0
  63. package/src/modules/git-workspace/memory-sync.ts +89 -0
  64. package/src/modules/git-workspace/merge-service.ts +156 -0
  65. package/src/modules/git-workspace/settings-merge.ts +95 -0
  66. package/src/modules/git-workspace/worktree-manager.ts +199 -0
  67. package/src/modules/logging/__tests__/log-store.test.ts +242 -0
  68. package/src/modules/logging/__tests__/logger.test.ts +81 -0
  69. package/src/modules/logging/__tests__/sse-transport.test.ts +93 -0
  70. package/src/modules/logging/index.ts +7 -0
  71. package/src/modules/logging/log-store.ts +80 -0
  72. package/src/modules/logging/logger.ts +55 -0
  73. package/src/modules/logging/transports/sse-transport.ts +28 -0
  74. package/src/modules/multi-repo/__tests__/multi-repo-planner.test.ts +93 -0
  75. package/src/modules/multi-repo/__tests__/repo-context.test.ts +79 -0
  76. package/src/modules/multi-repo/index.ts +12 -0
  77. package/src/modules/multi-repo/multi-repo-planner.ts +112 -0
  78. package/src/modules/multi-repo/repo-context.ts +71 -0
  79. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +10 -0
  80. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +10 -0
  81. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +13 -0
  82. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +10 -0
  83. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +10 -0
  84. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +5 -0
  85. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +5 -0
  86. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +10 -0
  87. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +10 -0
  88. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +13 -0
  89. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +10 -0
  90. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +10 -0
  91. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +5 -0
  92. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +5 -0
  93. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +10 -0
  94. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +10 -0
  95. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +13 -0
  96. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +10 -0
  97. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +10 -0
  98. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +5 -0
  99. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +5 -0
  100. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +10 -0
  101. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +10 -0
  102. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +13 -0
  103. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +10 -0
  104. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +10 -0
  105. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +5 -0
  106. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +5 -0
  107. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +10 -0
  108. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +10 -0
  109. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +13 -0
  110. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +10 -0
  111. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +10 -0
  112. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +5 -0
  113. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +5 -0
  114. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +10 -0
  115. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +10 -0
  116. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +13 -0
  117. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +10 -0
  118. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +10 -0
  119. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +5 -0
  120. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +5 -0
  121. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +10 -0
  122. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +10 -0
  123. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +13 -0
  124. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +10 -0
  125. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +10 -0
  126. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +5 -0
  127. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +5 -0
  128. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +10 -0
  129. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +10 -0
  130. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +13 -0
  131. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +10 -0
  132. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +10 -0
  133. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +5 -0
  134. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +5 -0
  135. package/src/modules/persistence/__tests__/file-lock.test.ts +141 -0
  136. package/src/modules/persistence/__tests__/index.test.ts +38 -0
  137. package/src/modules/persistence/__tests__/stores.test.ts +594 -0
  138. package/src/modules/persistence/file-lock.ts +158 -0
  139. package/src/modules/persistence/fs-run-store.ts +73 -0
  140. package/src/modules/persistence/fs-task-store.ts +152 -0
  141. package/src/modules/persistence/fs-worker-store.ts +116 -0
  142. package/src/modules/persistence/in-memory-stores.ts +98 -0
  143. package/src/modules/persistence/index.ts +60 -0
  144. package/src/modules/persistence/stores.port.ts +60 -0
  145. package/src/modules/planning/__tests__/file-conflict-validator.test.ts +256 -0
  146. package/src/modules/planning/__tests__/planning-service.test.ts +366 -0
  147. package/src/modules/planning/__tests__/project-detection.test.ts +707 -0
  148. package/src/modules/planning/file-conflict-validator.ts +135 -0
  149. package/src/modules/planning/index.ts +40 -0
  150. package/src/modules/planning/planning.service.ts +262 -0
  151. package/src/modules/planning/project-detection.ts +525 -0
  152. package/src/modules/plugin/__tests__/plugin-loader.test.ts +83 -0
  153. package/src/modules/plugin/__tests__/plugin-manager.test.ts +187 -0
  154. package/src/modules/plugin/index.ts +3 -0
  155. package/src/modules/plugin/plugin-loader.ts +46 -0
  156. package/src/modules/plugin/plugin-manager.ts +90 -0
  157. package/src/modules/plugin/plugin.types.ts +37 -0
  158. package/src/modules/process-manager/__tests__/process-manager.test.ts +210 -0
  159. package/src/modules/process-manager/__tests__/worker.test.ts +89 -0
  160. package/src/modules/process-manager/index.ts +5 -0
  161. package/src/modules/process-manager/process-manager.ts +193 -0
  162. package/src/modules/process-manager/worker.ts +106 -0
  163. package/src/modules/task-execution/__tests__/default-spawner.test.ts +154 -0
  164. package/src/modules/task-execution/__tests__/executor.test.ts +760 -0
  165. package/src/modules/task-execution/__tests__/implementer-green.test.ts +286 -0
  166. package/src/modules/task-execution/__tests__/merge-phase.test.ts +368 -0
  167. package/src/modules/task-execution/__tests__/reviewer.test.ts +302 -0
  168. package/src/modules/task-execution/__tests__/tester-red.test.ts +281 -0
  169. package/src/modules/task-execution/__tests__/tester-verify.test.ts +313 -0
  170. package/src/modules/task-execution/executor.ts +303 -0
  171. package/src/modules/task-execution/index.ts +45 -0
  172. package/src/modules/task-execution/phases/default-spawner.ts +49 -0
  173. package/src/modules/task-execution/phases/implementer-green.ts +100 -0
  174. package/src/modules/task-execution/phases/merge.ts +122 -0
  175. package/src/modules/task-execution/phases/reviewer.ts +160 -0
  176. package/src/modules/task-execution/phases/tester-red.ts +100 -0
  177. package/src/modules/task-execution/phases/tester-verify.ts +120 -0
  178. package/src/modules/task-queue/__tests__/dependency-resolver.test.ts +456 -0
  179. package/src/modules/task-queue/__tests__/dispatcher.test.ts +824 -0
  180. package/src/modules/task-queue/__tests__/task-plan.test.ts +122 -0
  181. package/src/modules/task-queue/__tests__/task.test.ts +130 -0
  182. package/src/modules/task-queue/dependency-resolver.ts +171 -0
  183. package/src/modules/task-queue/dispatcher.ts +372 -0
  184. package/src/modules/task-queue/index.ts +16 -0
  185. package/src/modules/task-queue/task-plan.ts +40 -0
  186. package/src/modules/task-queue/task.ts +67 -0
  187. package/src/shared/__tests__/config.test.ts +204 -0
  188. package/src/shared/__tests__/errors.test.ts +285 -0
  189. package/src/shared/__tests__/events.test.ts +496 -0
  190. package/src/shared/__tests__/types.test.ts +360 -0
  191. package/src/shared/config.ts +133 -0
  192. package/src/shared/errors.ts +128 -0
  193. package/src/shared/events.ts +171 -0
  194. package/src/shared/types.ts +143 -0
  195. 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
+ }