@felixdotgo/aidlc-kit 0.2.0 → 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/DEVELOPMENT.md +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type UiOptions } from "./options.js";
|
|
2
|
+
/** Canonical id pattern shared by task, assignment, item, and worker ids (`loop-worker-log.mjs`). */
|
|
3
|
+
export declare const ID_PATTERN: RegExp;
|
|
4
|
+
export declare const DEFAULT_TAIL_BYTES: number;
|
|
5
|
+
export declare const MAX_TAIL_BYTES: number;
|
|
6
|
+
export declare const TRUNCATED_PREFIX = "\u2026 (earlier output truncated)\n";
|
|
7
|
+
/** How long one `docker ps` answer is shared per task (live polling runs every ~1.5 s). */
|
|
8
|
+
export declare const DOCKER_PS_MAX_AGE_MS = 1500;
|
|
9
|
+
/** One recorded container turn as written by the loop (`index.json`). */
|
|
10
|
+
export interface WorkerIndexEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
assignmentId: string;
|
|
13
|
+
itemId?: string;
|
|
14
|
+
workerId?: string;
|
|
15
|
+
agent?: string;
|
|
16
|
+
mode?: "local" | "fleet";
|
|
17
|
+
host?: string;
|
|
18
|
+
turn?: number;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
endedAt?: string;
|
|
21
|
+
/** Exit code; null when the container ended by signal or was finalized without one. */
|
|
22
|
+
exitCode?: number | null;
|
|
23
|
+
log?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ContainerEntry extends WorkerIndexEntry {
|
|
26
|
+
/** Still running: no `endedAt` and docker lists the name (or docker is unavailable). */
|
|
27
|
+
running: boolean;
|
|
28
|
+
/** Recorded on this host, so its log file can be served from this checkout. */
|
|
29
|
+
local: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ContainersPayload {
|
|
32
|
+
ok: true;
|
|
33
|
+
docker: "ok" | "unavailable";
|
|
34
|
+
host: string;
|
|
35
|
+
containers: ContainerEntry[];
|
|
36
|
+
}
|
|
37
|
+
export type ContainersResult = {
|
|
38
|
+
status: 200;
|
|
39
|
+
payload: ContainersPayload;
|
|
40
|
+
} | {
|
|
41
|
+
status: 400 | 404;
|
|
42
|
+
payload: {
|
|
43
|
+
ok: false;
|
|
44
|
+
error: string;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
export interface ContainerLogResult {
|
|
48
|
+
status: 200 | 400 | 404;
|
|
49
|
+
body: string;
|
|
50
|
+
contentType: "text/plain; charset=utf-8";
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
size: number;
|
|
53
|
+
}
|
|
54
|
+
/** `.agents/data/tasks/<taskId>/runtime/workers`, every component checked for symlinks. */
|
|
55
|
+
export declare const workerLogDirectory: (root: string, taskId: string) => string;
|
|
56
|
+
export declare const workerIndexPath: (root: string, taskId: string) => string;
|
|
57
|
+
export declare const workerLogPath: (root: string, taskId: string, assignmentId: string, generation?: number) => string;
|
|
58
|
+
/** The task's container index; missing, corrupt, or symlinked reads as empty. */
|
|
59
|
+
export declare const readWorkerIndex: (root: string, taskId: string) => WorkerIndexEntry[];
|
|
60
|
+
/**
|
|
61
|
+
* The task's recorded containers with `running` and `local`. 400 for a malformed id,
|
|
62
|
+
* 404 for an unknown task; a missing or corrupt index is an empty list, never an error.
|
|
63
|
+
*/
|
|
64
|
+
export declare const taskContainers: (options: UiOptions, taskId: string) => Promise<ContainersResult>;
|
|
65
|
+
/**
|
|
66
|
+
* The last `tailBytes` of one assignment's raw log (default 64 KiB, at most 1 MiB; a
|
|
67
|
+
* larger request is clamped). A tail that cut the file starts at the first complete
|
|
68
|
+
* line after the cut, prefixed with one `… (earlier output truncated)` line. When the
|
|
69
|
+
* current log is missing but a rotated `.log.1` exists, that generation is served.
|
|
70
|
+
*/
|
|
71
|
+
export declare const containerLog: (options: UiOptions, taskId: string, assignmentId: string, tailBytes?: number) => Promise<ContainerLogResult>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker containers of one task for `/api/tasks/:id/containers` and the Containers
|
|
3
|
+
* panel, and the raw container log tail for `/api/tasks/:id/containers/:assignment/log`.
|
|
4
|
+
*
|
|
5
|
+
* The source of truth is the task's own runtime record, written by the loop
|
|
6
|
+
* (`loop-worker-log.mjs`) identically in local and fleet mode:
|
|
7
|
+
*
|
|
8
|
+
* .agents/data/tasks/<taskId>/runtime/workers/index.json one entry per container turn
|
|
9
|
+
* .agents/data/tasks/<taskId>/runtime/workers/<assignmentId>.log raw output (rotated once to .log.1)
|
|
10
|
+
*
|
|
11
|
+
* Docker only enriches `running`: `docker ps --filter label=aidlc.task=<id>` runs via
|
|
12
|
+
* `execFile` (no shell, allow-listed environment, 4 s timeout) and its result is shared
|
|
13
|
+
* for a short time so live polling does not spawn docker on every tick. When docker is
|
|
14
|
+
* missing, slow, or failing the index still answers and `docker` reads `unavailable`.
|
|
15
|
+
* Every path component is validated against the canonical id pattern and refused when
|
|
16
|
+
* it crosses a symlink (the same rule as the assignment records in ./changes.ts).
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
20
|
+
import { hostname } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
import { readUiState, readUiTask } from "./data.js";
|
|
23
|
+
import { readRange } from "./files.js";
|
|
24
|
+
import { TASK_ID_PATTERN } from "./options.js";
|
|
25
|
+
/** Canonical id pattern shared by task, assignment, item, and worker ids (`loop-worker-log.mjs`). */
|
|
26
|
+
export const ID_PATTERN = TASK_ID_PATTERN;
|
|
27
|
+
export const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
28
|
+
export const MAX_TAIL_BYTES = 1024 * 1024;
|
|
29
|
+
export const TRUNCATED_PREFIX = "… (earlier output truncated)\n";
|
|
30
|
+
/** How long one `docker ps` answer is shared per task (live polling runs every ~1.5 s). */
|
|
31
|
+
export const DOCKER_PS_MAX_AGE_MS = 1500;
|
|
32
|
+
const DOCKER_TIMEOUT_MS = 4000;
|
|
33
|
+
/** The only variables the docker CLI receives: where the binary is and how to reach the daemon. */
|
|
34
|
+
const DOCKER_ENV = ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY", "USERPROFILE", "SystemRoot", "TEMP", "TMP"];
|
|
35
|
+
/* ---------------------------------------------------------------------------
|
|
36
|
+
* Paths (mirrors `workerLogDirectory` / `workerLogPath` in loop-worker-log.mjs)
|
|
37
|
+
* ------------------------------------------------------------------------- */
|
|
38
|
+
class SymlinkRefused extends Error {
|
|
39
|
+
}
|
|
40
|
+
/** `lstat` that reads a missing entry as undefined and refuses a symlink. */
|
|
41
|
+
const lstatNoFollow = (path) => {
|
|
42
|
+
try {
|
|
43
|
+
const stat = lstatSync(path);
|
|
44
|
+
if (stat.isSymbolicLink())
|
|
45
|
+
throw new SymlinkRefused(`Path crosses a symlink: ${path}`);
|
|
46
|
+
return stat;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === "ENOENT")
|
|
50
|
+
return undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** `.agents/data/tasks/<taskId>/runtime/workers`, every component checked for symlinks. */
|
|
55
|
+
export const workerLogDirectory = (root, taskId) => {
|
|
56
|
+
if (!ID_PATTERN.test(taskId))
|
|
57
|
+
throw new Error("Invalid task id");
|
|
58
|
+
let path = resolve(root);
|
|
59
|
+
for (const part of [".agents", "data", "tasks", taskId, "runtime", "workers"]) {
|
|
60
|
+
path = join(path, part);
|
|
61
|
+
lstatNoFollow(path);
|
|
62
|
+
}
|
|
63
|
+
return path;
|
|
64
|
+
};
|
|
65
|
+
export const workerIndexPath = (root, taskId) => join(workerLogDirectory(root, taskId), "index.json");
|
|
66
|
+
export const workerLogPath = (root, taskId, assignmentId, generation = 0) => {
|
|
67
|
+
if (!ID_PATTERN.test(assignmentId))
|
|
68
|
+
throw new Error("Invalid assignment id");
|
|
69
|
+
return join(workerLogDirectory(root, taskId), `${assignmentId}.log${generation ? `.${generation}` : ""}`);
|
|
70
|
+
};
|
|
71
|
+
const validEntry = (entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry)
|
|
72
|
+
&& typeof entry.name === "string" && ID_PATTERN.test(entry.name)
|
|
73
|
+
&& typeof entry.assignmentId === "string" && ID_PATTERN.test(entry.assignmentId)
|
|
74
|
+
&& typeof entry.startedAt === "string";
|
|
75
|
+
/** The task's container index; missing, corrupt, or symlinked reads as empty. */
|
|
76
|
+
export const readWorkerIndex = (root, taskId) => {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
const path = workerIndexPath(root, taskId);
|
|
80
|
+
if (!lstatNoFollow(path)?.isFile())
|
|
81
|
+
return [];
|
|
82
|
+
raw = readFileSync(path, "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const value = JSON.parse(raw);
|
|
89
|
+
return Array.isArray(value) ? value.filter(validEntry) : [];
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/* ---------------------------------------------------------------------------
|
|
96
|
+
* docker ps
|
|
97
|
+
* ------------------------------------------------------------------------- */
|
|
98
|
+
const dockerEnv = () => {
|
|
99
|
+
const env = {};
|
|
100
|
+
for (const name of DOCKER_ENV) {
|
|
101
|
+
const value = process.env[name];
|
|
102
|
+
if (value !== undefined)
|
|
103
|
+
env[name] = value;
|
|
104
|
+
}
|
|
105
|
+
return env;
|
|
106
|
+
};
|
|
107
|
+
/** Names of the task's running containers, or undefined when docker cannot answer. */
|
|
108
|
+
const dockerRunning = (taskId) => new Promise((done) => {
|
|
109
|
+
try {
|
|
110
|
+
execFile("docker", ["ps", "--filter", `label=aidlc.task=${taskId}`, "--format", "{{.Names}}"], { shell: false, env: dockerEnv(), timeout: DOCKER_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
|
|
111
|
+
if (error)
|
|
112
|
+
return done(undefined);
|
|
113
|
+
done(new Set(String(stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
done(undefined);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const dockerReads = new Map();
|
|
121
|
+
/** One `docker ps` per task per DOCKER_PS_MAX_AGE_MS, shared by concurrent requests. */
|
|
122
|
+
const sharedDockerRunning = (root, taskId) => {
|
|
123
|
+
const key = `${root}\0${taskId}`;
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const cached = dockerReads.get(key);
|
|
126
|
+
if (cached && now - cached.at < DOCKER_PS_MAX_AGE_MS)
|
|
127
|
+
return cached.value;
|
|
128
|
+
const value = dockerRunning(taskId);
|
|
129
|
+
dockerReads.set(key, { at: now, value });
|
|
130
|
+
if (dockerReads.size > 64) {
|
|
131
|
+
for (const [name, entry] of dockerReads)
|
|
132
|
+
if (now - entry.at >= DOCKER_PS_MAX_AGE_MS)
|
|
133
|
+
dockerReads.delete(name);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
};
|
|
137
|
+
/* ---------------------------------------------------------------------------
|
|
138
|
+
* Public entry points
|
|
139
|
+
* ------------------------------------------------------------------------- */
|
|
140
|
+
/**
|
|
141
|
+
* The task's recorded containers with `running` and `local`. 400 for a malformed id,
|
|
142
|
+
* 404 for an unknown task; a missing or corrupt index is an empty list, never an error.
|
|
143
|
+
*/
|
|
144
|
+
export const taskContainers = async (options, taskId) => {
|
|
145
|
+
if (!ID_PATTERN.test(taskId))
|
|
146
|
+
return { status: 400, payload: { ok: false, error: "Invalid task id" } };
|
|
147
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
148
|
+
if (!task)
|
|
149
|
+
return { status: 404, payload: { ok: false, error: `Task not found: ${taskId}` } };
|
|
150
|
+
const entries = readWorkerIndex(options.root, taskId);
|
|
151
|
+
const live = await sharedDockerRunning(options.root, taskId);
|
|
152
|
+
const host = hostname();
|
|
153
|
+
const containers = entries.map((entry) => ({
|
|
154
|
+
...entry,
|
|
155
|
+
running: !entry.endedAt && (live === undefined || live.has(entry.name)),
|
|
156
|
+
local: entry.host === host,
|
|
157
|
+
}));
|
|
158
|
+
return { status: 200, payload: { ok: true, docker: live === undefined ? "unavailable" : "ok", host, containers } };
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* The last `tailBytes` of one assignment's raw log (default 64 KiB, at most 1 MiB; a
|
|
162
|
+
* larger request is clamped). A tail that cut the file starts at the first complete
|
|
163
|
+
* line after the cut, prefixed with one `… (earlier output truncated)` line. When the
|
|
164
|
+
* current log is missing but a rotated `.log.1` exists, that generation is served.
|
|
165
|
+
*/
|
|
166
|
+
export const containerLog = async (options, taskId, assignmentId, tailBytes = DEFAULT_TAIL_BYTES) => {
|
|
167
|
+
const contentType = "text/plain; charset=utf-8";
|
|
168
|
+
const fail = (status, body) => ({ status, body, contentType, truncated: false, size: 0 });
|
|
169
|
+
if (!ID_PATTERN.test(taskId))
|
|
170
|
+
return fail(400, "Invalid task id");
|
|
171
|
+
if (!ID_PATTERN.test(assignmentId))
|
|
172
|
+
return fail(400, "Invalid assignment id");
|
|
173
|
+
if (!Number.isSafeInteger(tailBytes) || tailBytes < 1)
|
|
174
|
+
return fail(400, "Invalid tail: expected a positive number of bytes");
|
|
175
|
+
const tail = Math.min(tailBytes, MAX_TAIL_BYTES);
|
|
176
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
177
|
+
if (!task)
|
|
178
|
+
return fail(404, `Task not found: ${taskId}`);
|
|
179
|
+
let path;
|
|
180
|
+
let size = 0;
|
|
181
|
+
try {
|
|
182
|
+
for (const generation of [0, 1]) {
|
|
183
|
+
const candidate = workerLogPath(options.root, taskId, assignmentId, generation);
|
|
184
|
+
const stat = lstatNoFollow(candidate);
|
|
185
|
+
if (stat?.isFile()) {
|
|
186
|
+
path = candidate;
|
|
187
|
+
size = stat.size;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return fail(404, "Container log not found");
|
|
194
|
+
}
|
|
195
|
+
if (path === undefined)
|
|
196
|
+
return fail(404, "Container log not found");
|
|
197
|
+
const truncated = size > tail;
|
|
198
|
+
let buffer;
|
|
199
|
+
try {
|
|
200
|
+
buffer = readRange(path, truncated ? size - tail : 0, size, true);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return fail(404, "Container log not found");
|
|
204
|
+
}
|
|
205
|
+
let body = buffer.toString("utf8");
|
|
206
|
+
if (truncated) {
|
|
207
|
+
// Drop the partial first line; a tail with no newline at all is kept as is.
|
|
208
|
+
const newline = body.indexOf("\n");
|
|
209
|
+
if (newline >= 0)
|
|
210
|
+
body = body.slice(newline + 1);
|
|
211
|
+
body = TRUNCATED_PREFIX + body;
|
|
212
|
+
}
|
|
213
|
+
return { status: 200, body, contentType, truncated, size };
|
|
214
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Runs an installed workflow script with the current Node binary without
|
|
2
|
+
* blocking the event loop; rejects with the child's stderr attached. */
|
|
3
|
+
export declare const runNode: (root: string, args: string[], options: {
|
|
4
|
+
input?: string;
|
|
5
|
+
timeout: number;
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
}) => Promise<string>;
|
|
8
|
+
/** The child's structured error message (or stderr) for a failed `runNode`. */
|
|
9
|
+
export declare const childError: (error: unknown) => string;
|
|
10
|
+
export interface DaemonStatus {
|
|
11
|
+
running: boolean;
|
|
12
|
+
pid: number | null;
|
|
13
|
+
startedAt: string | null;
|
|
14
|
+
host?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const pidAlive: (pid: number) => boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Reads the singleton loop daemon lock written by `loop-daemon.mjs`. A lease whose
|
|
19
|
+
* `expiresAt` has passed is stale (the daemon renews it every 15 s), so a crashed
|
|
20
|
+
* daemon's reused pid is not mistaken for a live daemon. `host` names the machine
|
|
21
|
+
* that holds it (a shared filesystem can show another host's daemon).
|
|
22
|
+
*/
|
|
23
|
+
export declare const daemonStatus: (root: string) => DaemonStatus;
|
|
24
|
+
export interface DaemonError {
|
|
25
|
+
taskId?: string;
|
|
26
|
+
message: string;
|
|
27
|
+
at?: string;
|
|
28
|
+
failures?: number;
|
|
29
|
+
retryInMs?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Unresolved daemon errors from the tail of the event-log sidecar: a task's
|
|
33
|
+
* `daemon_turn_error` stays open until a later `daemon_turn` for that task, a
|
|
34
|
+
* poll that no longer lists it as actionable, or a daemon restart; a
|
|
35
|
+
* `daemon_poll_error` stays open until the next successful poll.
|
|
36
|
+
*/
|
|
37
|
+
export declare const daemonHealth: (root: string) => DaemonError[];
|
|
38
|
+
/** Starts the local daemon unless a live one already holds the singleton lock.
|
|
39
|
+
* The daemon's stderr goes to `daemon.log` so start-up failures are visible.
|
|
40
|
+
* `extraEnv` (e.g. `AIDLC_ENV_FILE`) is added to the daemon's environment; the
|
|
41
|
+
* daemon itself loads the AIDLC env file and derives provider settings. */
|
|
42
|
+
export declare const ensureDaemon: (root: string, extraEnv?: Record<string, string>) => {
|
|
43
|
+
pid: number | null;
|
|
44
|
+
started: boolean;
|
|
45
|
+
};
|
|
46
|
+
export interface StopResult {
|
|
47
|
+
wasRunning: boolean;
|
|
48
|
+
stopped: boolean;
|
|
49
|
+
pid: number | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Stops the local daemon through its staged shutdown: one SIGTERM finishes the current
|
|
53
|
+
* agent turn, a second (`now`) cancels it. The daemon counts signals and exits hard on the
|
|
54
|
+
* third, so the stage already requested is recorded in `daemon.stop` and repeated calls
|
|
55
|
+
* (`stop`, then `stop --now`, or `stop` twice) send only the missing signals and never a
|
|
56
|
+
* third. Waits up to `timeoutMs`; on Windows the signal ends the process immediately.
|
|
57
|
+
*/
|
|
58
|
+
export declare const stopDaemon: (root: string, { now, timeoutMs, pollMs }?: {
|
|
59
|
+
now?: boolean;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
pollMs?: number;
|
|
62
|
+
}) => Promise<StopResult>;
|
|
63
|
+
export declare const intakeTask: (root: string, requirement: string) => Promise<{
|
|
64
|
+
taskId: string;
|
|
65
|
+
title: string;
|
|
66
|
+
}>;
|
|
67
|
+
export declare const activateTask: (root: string, taskId: string) => Promise<unknown>;
|
|
68
|
+
export declare const loopStatusJson: (root: string, taskId: string) => Promise<unknown>;
|
|
69
|
+
export declare const approveGate: (root: string, taskId: string, gate: string, source: string) => Promise<unknown>;
|
|
70
|
+
/** Human feedback keeps the task at its current gate. The audited status cancel
|
|
71
|
+
* records `Cancelled gate wait at <gate>: <feedback>` evidence and sets the task
|
|
72
|
+
* `active`; the loop runtime turns that into a "revise the review artifact, then
|
|
73
|
+
* re-present the gate" prompt — the same mechanism as typing feedback in the
|
|
74
|
+
* trusted terminal (`loop-gate.mjs`). It never approves anything. */
|
|
75
|
+
export declare const feedbackGate: (root: string, taskId: string, feedback: string, source: string) => Promise<unknown>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop daemon status/health and the thin facades that spawn installed workflow scripts
|
|
3
|
+
* (`loop-entry.mjs`, `state.mjs`). This module never mutates lifecycle state itself.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { hostname } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { readRange } from "./files.js";
|
|
10
|
+
import { daemonLockPath, daemonLogPath, eventsLogPath, loopArgs, loopDataDir, scrubbedEnv, stateScriptPath } from "./options.js";
|
|
11
|
+
/** Runs an installed workflow script with the current Node binary without
|
|
12
|
+
* blocking the event loop; rejects with the child's stderr attached. */
|
|
13
|
+
export const runNode = (root, args, options) => new Promise((resolveRun, rejectRun) => {
|
|
14
|
+
const child = execFile(process.execPath, args, { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024, timeout: options.timeout, env: scrubbedEnv(options.env) }, (error, stdout, stderr) => {
|
|
15
|
+
if (error) {
|
|
16
|
+
rejectRun(Object.assign(error, { stderr }));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
resolveRun(stdout);
|
|
20
|
+
});
|
|
21
|
+
child.stdin?.end(options.input ?? "");
|
|
22
|
+
});
|
|
23
|
+
/** The child's structured error message (or stderr) for a failed `runNode`. */
|
|
24
|
+
export const childError = (error) => {
|
|
25
|
+
if (error && typeof error === "object" && "stderr" in error) {
|
|
26
|
+
const stderr = String(error.stderr ?? "").trim();
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(stderr);
|
|
29
|
+
if (parsed.error?.message)
|
|
30
|
+
return parsed.error.message;
|
|
31
|
+
}
|
|
32
|
+
catch { /* not JSON */ }
|
|
33
|
+
if (stderr)
|
|
34
|
+
return stderr;
|
|
35
|
+
}
|
|
36
|
+
return error instanceof Error ? error.message : String(error);
|
|
37
|
+
};
|
|
38
|
+
export const pidAlive = (pid) => {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return error.code !== "ESRCH";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Reads the singleton loop daemon lock written by `loop-daemon.mjs`. A lease whose
|
|
49
|
+
* `expiresAt` has passed is stale (the daemon renews it every 15 s), so a crashed
|
|
50
|
+
* daemon's reused pid is not mistaken for a live daemon. `host` names the machine
|
|
51
|
+
* that holds it (a shared filesystem can show another host's daemon).
|
|
52
|
+
*/
|
|
53
|
+
export const daemonStatus = (root) => {
|
|
54
|
+
try {
|
|
55
|
+
const lock = JSON.parse(readFileSync(daemonLockPath(root), "utf8"));
|
|
56
|
+
const pid = typeof lock.pid === "number" && Number.isSafeInteger(lock.pid) && lock.pid > 0 ? lock.pid : null;
|
|
57
|
+
const host = typeof lock.host === "string" && lock.host ? lock.host : undefined;
|
|
58
|
+
const expired = typeof lock.expiresAt === "string" && Date.parse(lock.expiresAt) < Date.now();
|
|
59
|
+
const local = !host || host === hostname();
|
|
60
|
+
const running = pid !== null && !expired && (local ? pidAlive(pid) : true);
|
|
61
|
+
return { running, pid, startedAt: typeof lock.startedAt === "string" ? lock.startedAt : null, ...(host ? { host } : {}) };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { running: false, pid: null, startedAt: null };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/** How much of the event-log tail the daemon health check scans. */
|
|
68
|
+
const HEALTH_TAIL_BYTES = 64 * 1024;
|
|
69
|
+
/**
|
|
70
|
+
* Unresolved daemon errors from the tail of the event-log sidecar: a task's
|
|
71
|
+
* `daemon_turn_error` stays open until a later `daemon_turn` for that task, a
|
|
72
|
+
* poll that no longer lists it as actionable, or a daemon restart; a
|
|
73
|
+
* `daemon_poll_error` stays open until the next successful poll.
|
|
74
|
+
*/
|
|
75
|
+
export const daemonHealth = (root) => {
|
|
76
|
+
const path = eventsLogPath(root);
|
|
77
|
+
let text = "";
|
|
78
|
+
try {
|
|
79
|
+
const size = statSync(path).size;
|
|
80
|
+
const start = Math.max(0, size - HEALTH_TAIL_BYTES);
|
|
81
|
+
text = readRange(path, start, size).toString("utf8");
|
|
82
|
+
if (start > 0)
|
|
83
|
+
text = text.slice(text.indexOf("\n") + 1);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const turnErrors = new Map();
|
|
89
|
+
let pollError = null;
|
|
90
|
+
for (const line of text.split("\n")) {
|
|
91
|
+
if (!line)
|
|
92
|
+
continue;
|
|
93
|
+
let event;
|
|
94
|
+
try {
|
|
95
|
+
event = JSON.parse(line);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!event || typeof event !== "object")
|
|
101
|
+
continue;
|
|
102
|
+
const taskId = typeof event.taskId === "string" ? event.taskId : undefined;
|
|
103
|
+
const record = () => ({
|
|
104
|
+
...(taskId ? { taskId } : {}),
|
|
105
|
+
message: typeof event.message === "string" ? event.message : "error",
|
|
106
|
+
...(typeof event.at === "string" ? { at: event.at } : {}),
|
|
107
|
+
...(typeof event.failures === "number" ? { failures: event.failures } : {}),
|
|
108
|
+
...(typeof event.retryInMs === "number" ? { retryInMs: event.retryInMs } : {}),
|
|
109
|
+
});
|
|
110
|
+
switch (event.type) {
|
|
111
|
+
case "daemon_start":
|
|
112
|
+
turnErrors.clear();
|
|
113
|
+
pollError = null;
|
|
114
|
+
break;
|
|
115
|
+
case "daemon_poll_error":
|
|
116
|
+
pollError = record();
|
|
117
|
+
break;
|
|
118
|
+
case "daemon_poll": {
|
|
119
|
+
pollError = null;
|
|
120
|
+
const actionable = Array.isArray(event.actionable) ? event.actionable : [];
|
|
121
|
+
for (const id of [...turnErrors.keys()])
|
|
122
|
+
if (!actionable.includes(id))
|
|
123
|
+
turnErrors.delete(id);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "daemon_turn":
|
|
127
|
+
if (taskId)
|
|
128
|
+
turnErrors.delete(taskId);
|
|
129
|
+
break;
|
|
130
|
+
case "daemon_turn_error":
|
|
131
|
+
if (taskId)
|
|
132
|
+
turnErrors.set(taskId, record());
|
|
133
|
+
break;
|
|
134
|
+
default: break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [...(pollError ? [pollError] : []), ...turnErrors.values()];
|
|
138
|
+
};
|
|
139
|
+
/** Starts the local daemon unless a live one already holds the singleton lock.
|
|
140
|
+
* The daemon's stderr goes to `daemon.log` so start-up failures are visible.
|
|
141
|
+
* `extraEnv` (e.g. `AIDLC_ENV_FILE`) is added to the daemon's environment; the
|
|
142
|
+
* daemon itself loads the AIDLC env file and derives provider settings. */
|
|
143
|
+
export const ensureDaemon = (root, extraEnv = {}) => {
|
|
144
|
+
const current = daemonStatus(root);
|
|
145
|
+
if (current.running)
|
|
146
|
+
return { pid: current.pid, started: false };
|
|
147
|
+
mkdirSync(loopDataDir(root), { recursive: true });
|
|
148
|
+
rmSync(stopMarkerPath(root), { force: true }); // a new run starts with no stop requested
|
|
149
|
+
const log = openSync(daemonLogPath(root), "a");
|
|
150
|
+
try {
|
|
151
|
+
const child = spawn(process.execPath, loopArgs(root, "start"), {
|
|
152
|
+
cwd: root,
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: ["ignore", log, log],
|
|
155
|
+
// The daemon (and every lead/worker it spawns) never sees UI or reviewer credentials.
|
|
156
|
+
env: scrubbedEnv({ AIDLC_LOOP_EVENT_LOG: eventsLogPath(root), ...extraEnv }),
|
|
157
|
+
});
|
|
158
|
+
// Without a listener a spawn failure would be an unhandled 'error' event and
|
|
159
|
+
// crash the UI server; the daemon's own start-up errors go to daemon.log.
|
|
160
|
+
child.on("error", () => { });
|
|
161
|
+
child.unref();
|
|
162
|
+
return { pid: child.pid ?? null, started: true };
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
closeSync(log);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/** Shutdown stage already requested of a daemon pid: 1 = finish the turn, 2 = cancel it. */
|
|
169
|
+
const stopMarkerPath = (root) => join(loopDataDir(root), "daemon.stop");
|
|
170
|
+
/** The marker belongs to one daemon run (pid + lease start), so a later daemon that reuses
|
|
171
|
+
* the pid starts from stage 0. Signals sent outside `stopDaemon` (a terminal Ctrl-C on a
|
|
172
|
+
* foreground `loop start`) are not counted. */
|
|
173
|
+
const requestedStage = (root, status) => {
|
|
174
|
+
try {
|
|
175
|
+
const marker = JSON.parse(readFileSync(stopMarkerPath(root), "utf8"));
|
|
176
|
+
return marker.pid === status.pid && marker.startedAt === status.startedAt && typeof marker.stage === "number" ? marker.stage : 0;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Stops the local daemon through its staged shutdown: one SIGTERM finishes the current
|
|
184
|
+
* agent turn, a second (`now`) cancels it. The daemon counts signals and exits hard on the
|
|
185
|
+
* third, so the stage already requested is recorded in `daemon.stop` and repeated calls
|
|
186
|
+
* (`stop`, then `stop --now`, or `stop` twice) send only the missing signals and never a
|
|
187
|
+
* third. Waits up to `timeoutMs`; on Windows the signal ends the process immediately.
|
|
188
|
+
*/
|
|
189
|
+
export const stopDaemon = async (root, { now = false, timeoutMs = 15000, pollMs = 200 } = {}) => {
|
|
190
|
+
const status = daemonStatus(root);
|
|
191
|
+
if (!status.running || status.pid === null) {
|
|
192
|
+
rmSync(stopMarkerPath(root), { force: true });
|
|
193
|
+
return { wasRunning: false, stopped: true, pid: status.pid };
|
|
194
|
+
}
|
|
195
|
+
if (status.host && status.host !== hostname())
|
|
196
|
+
throw new Error(`The loop daemon runs on host ${status.host} (pid ${status.pid}); stop it there`);
|
|
197
|
+
const pid = status.pid;
|
|
198
|
+
const target = now ? 2 : 1;
|
|
199
|
+
for (let stage = requestedStage(root, status); stage < target; stage += 1) {
|
|
200
|
+
try {
|
|
201
|
+
process.kill(pid, "SIGTERM");
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
break; /* already exited */
|
|
205
|
+
}
|
|
206
|
+
writeFileSync(stopMarkerPath(root), `${JSON.stringify({ pid, startedAt: status.startedAt, stage: stage + 1, at: new Date().toISOString() })}\n`);
|
|
207
|
+
if (stage + 1 < target)
|
|
208
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
209
|
+
}
|
|
210
|
+
for (let waited = 0; waited < timeoutMs && pidAlive(pid); waited += pollMs)
|
|
211
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, pollMs));
|
|
212
|
+
const stopped = !pidAlive(pid);
|
|
213
|
+
if (stopped)
|
|
214
|
+
rmSync(stopMarkerPath(root), { force: true });
|
|
215
|
+
return { wasRunning: true, stopped, pid };
|
|
216
|
+
};
|
|
217
|
+
export const intakeTask = async (root, requirement) => {
|
|
218
|
+
const output = await runNode(root, loopArgs(root, "intake"), { input: requirement, timeout: 30000 });
|
|
219
|
+
const parsed = JSON.parse(output);
|
|
220
|
+
if (typeof parsed.taskId !== "string" || typeof parsed.title !== "string")
|
|
221
|
+
throw new Error("loop intake returned no task id");
|
|
222
|
+
return { taskId: parsed.taskId, title: parsed.title };
|
|
223
|
+
};
|
|
224
|
+
export const activateTask = async (root, taskId) => JSON.parse(await runNode(root, [stateScriptPath(root), "task", "status", taskId, "--status", "active", "--root", root], { timeout: 30000 }));
|
|
225
|
+
export const loopStatusJson = async (root, taskId) => JSON.parse(await runNode(root, loopArgs(root, "status", taskId), { timeout: 5000 }));
|
|
226
|
+
export const approveGate = async (root, taskId, gate, source) => JSON.parse(await runNode(root, [stateScriptPath(root), "gate", "approve", taskId, "--gate", gate, "--source", source, "--root", root], { timeout: 30000 }));
|
|
227
|
+
/** Human feedback keeps the task at its current gate. The audited status cancel
|
|
228
|
+
* records `Cancelled gate wait at <gate>: <feedback>` evidence and sets the task
|
|
229
|
+
* `active`; the loop runtime turns that into a "revise the review artifact, then
|
|
230
|
+
* re-present the gate" prompt — the same mechanism as typing feedback in the
|
|
231
|
+
* trusted terminal (`loop-gate.mjs`). It never approves anything. */
|
|
232
|
+
export const feedbackGate = async (root, taskId, feedback, source) => JSON.parse(await runNode(root, [stateScriptPath(root), "task", "status", taskId, "--status", "active", "--mode", "audited", "--reason", feedback, "--source", source, "--root", root], { timeout: 30000 }));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TaskState, WorkflowState } from "../model.js";
|
|
2
|
+
import { type UiOptions } from "./options.js";
|
|
3
|
+
export declare const ARTIFACT_NAMES: readonly ["intent", "design", "workplan"];
|
|
4
|
+
/** Feedback entries not yet followed by a passing approval of that gate
|
|
5
|
+
* (mirrors `pendingFeedback` in the installed `loop-runtime.mjs`). */
|
|
6
|
+
export declare const pendingFeedback: (task: Pick<TaskState, "evidence">) => Array<{
|
|
7
|
+
gate: string;
|
|
8
|
+
text: string;
|
|
9
|
+
recordedAt: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** How stale a fleet read on the live-update path may be. Mutations always read fresh. */
|
|
12
|
+
export declare const LIVE_FLEET_MAX_AGE_MS = 1000;
|
|
13
|
+
export declare const readUiState: (options: UiOptions, maxAgeMs?: number) => Promise<WorkflowState>;
|
|
14
|
+
export declare const readUiTask: (options: UiOptions, id: string, state: WorkflowState) => TaskState | undefined;
|
|
15
|
+
export interface FleetGate {
|
|
16
|
+
ok?: boolean;
|
|
17
|
+
gate?: string;
|
|
18
|
+
status?: string;
|
|
19
|
+
checkpoint?: {
|
|
20
|
+
snapshotId?: string;
|
|
21
|
+
status?: string;
|
|
22
|
+
} | null;
|
|
23
|
+
snapshot?: {
|
|
24
|
+
id?: string;
|
|
25
|
+
reviewText?: string;
|
|
26
|
+
integrationCommit?: string | null;
|
|
27
|
+
} | null;
|
|
28
|
+
}
|
|
29
|
+
export declare const fleetGate: (options: UiOptions, id: string, maxAgeMs?: number) => Promise<FleetGate>;
|
|
30
|
+
export declare const taskGatePayload: (options: UiOptions, id: string, known?: WorkflowState, maxAgeMs?: number) => Promise<{
|
|
31
|
+
ok: boolean;
|
|
32
|
+
error?: string;
|
|
33
|
+
gate?: string;
|
|
34
|
+
view?: unknown;
|
|
35
|
+
snapshot?: FleetGate["snapshot"];
|
|
36
|
+
}>;
|
|
37
|
+
export declare const artifactPayload: (root: string, id: string, name: string, task?: TaskState | undefined) => {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
content?: string;
|
|
42
|
+
};
|