@mingchuno/agent-workflows 0.1.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/LICENCE +21 -0
- package/README.md +74 -0
- package/dist/drizzle/0000_initial.sql +45 -0
- package/dist/drizzle/meta/0000_snapshot.json +264 -0
- package/dist/drizzle/meta/_journal.json +13 -0
- package/dist/src/adapters/agent-worker.d.ts +1 -0
- package/dist/src/adapters/agent-worker.js +16 -0
- package/dist/src/adapters/agents.d.ts +24 -0
- package/dist/src/adapters/agents.js +142 -0
- package/dist/src/adapters/hosting.d.ts +33 -0
- package/dist/src/adapters/hosting.js +275 -0
- package/dist/src/adapters/sdk-protocol.d.ts +43 -0
- package/dist/src/adapters/sdk-protocol.js +64 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +175 -0
- package/dist/src/config.d.ts +224 -0
- package/dist/src/config.js +82 -0
- package/dist/src/db/locks.d.ts +4 -0
- package/dist/src/db/locks.js +14 -0
- package/dist/src/db/migrate.d.ts +1 -0
- package/dist/src/db/migrate.js +12 -0
- package/dist/src/db/migrations.d.ts +2 -0
- package/dist/src/db/migrations.js +22 -0
- package/dist/src/db/schema.d.ts +486 -0
- package/dist/src/db/schema.js +46 -0
- package/dist/src/domain.d.ts +133 -0
- package/dist/src/domain.js +24 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +8 -0
- package/dist/src/operations.d.ts +35 -0
- package/dist/src/operations.js +378 -0
- package/dist/src/run-record.d.ts +7 -0
- package/dist/src/run-record.js +19 -0
- package/dist/src/runner.d.ts +47 -0
- package/dist/src/runner.js +370 -0
- package/dist/src/runtime/ownership.d.ts +8 -0
- package/dist/src/runtime/ownership.js +84 -0
- package/dist/src/runtime/process.d.ts +18 -0
- package/dist/src/runtime/process.js +98 -0
- package/dist/src/runtime/redaction.d.ts +8 -0
- package/dist/src/runtime/redaction.js +33 -0
- package/dist/src/store.d.ts +87 -0
- package/dist/src/store.js +355 -0
- package/dist/src/tui-data.d.ts +25 -0
- package/dist/src/tui-data.js +89 -0
- package/dist/src/tui.d.ts +5 -0
- package/dist/src/tui.js +69 -0
- package/dist/src/workspace.d.ts +16 -0
- package/dist/src/workspace.js +186 -0
- package/docs/acceptance.md +35 -0
- package/docs/api.md +64 -0
- package/docs/architecture.md +24 -0
- package/docs/configuration.md +41 -0
- package/docs/database.md +28 -0
- package/docs/operations.md +46 -0
- package/docs/providers.md +49 -0
- package/docs/releases.md +89 -0
- package/examples/config.ts +57 -0
- package/examples/custom-workflow.ts +32 -0
- package/examples/observe.ts +18 -0
- package/examples/run.ts +31 -0
- package/package.json +78 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
import type { RunRecord } from "./domain.js";
|
|
3
|
+
export interface EventRecord {
|
|
4
|
+
sequence: number;
|
|
5
|
+
runId: string | null;
|
|
6
|
+
kind: string;
|
|
7
|
+
payload: unknown;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ProjectState {
|
|
11
|
+
id: string;
|
|
12
|
+
paused: boolean;
|
|
13
|
+
blocked: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface InvocationRecord {
|
|
16
|
+
id: string;
|
|
17
|
+
runId: string;
|
|
18
|
+
projectId: string;
|
|
19
|
+
step: string;
|
|
20
|
+
stepId: number;
|
|
21
|
+
attempt: number;
|
|
22
|
+
provider: string;
|
|
23
|
+
sessionId: string | null;
|
|
24
|
+
sessionState: "pending" | "available" | "unavailable";
|
|
25
|
+
requested: unknown;
|
|
26
|
+
effective: unknown;
|
|
27
|
+
prompt: string;
|
|
28
|
+
skills: unknown;
|
|
29
|
+
outcome: string;
|
|
30
|
+
startedAt: string;
|
|
31
|
+
finishedAt?: string;
|
|
32
|
+
log: string;
|
|
33
|
+
}
|
|
34
|
+
interface RetryAdmission {
|
|
35
|
+
commandId?: string;
|
|
36
|
+
checkSafety: (previous: RunRecord) => Promise<{
|
|
37
|
+
checkout: string;
|
|
38
|
+
branchTemplate: string;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
/** Public persisted query surface. All queries work without a running executor. */
|
|
42
|
+
export declare class Store {
|
|
43
|
+
readonly scope: string;
|
|
44
|
+
private readonly redact;
|
|
45
|
+
readonly pool: Pool;
|
|
46
|
+
private readonly db;
|
|
47
|
+
private ownership?;
|
|
48
|
+
constructor(databaseUrl: string, scope: string, redact?: (text: string) => string);
|
|
49
|
+
initialize(): Promise<void>;
|
|
50
|
+
acquire(keys: string[], onLost: () => void): Promise<void>;
|
|
51
|
+
release(): Promise<void>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
registerProject(id: string): Promise<void>;
|
|
54
|
+
projects(): Promise<ProjectState[]>;
|
|
55
|
+
project(id: string): Promise<ProjectState>;
|
|
56
|
+
setProject(id: string, change: {
|
|
57
|
+
paused?: boolean;
|
|
58
|
+
blocked?: string | null;
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
insertRun(run: RunRecord): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Admit a retry and its events atomically. Safety checks run under the project
|
|
63
|
+
* lock only for new admissions; they must not write through this Store.
|
|
64
|
+
*/
|
|
65
|
+
admitRetry(runId: string, admission: RetryAdmission): Promise<string>;
|
|
66
|
+
run(id: string): Promise<RunRecord>;
|
|
67
|
+
runs(): Promise<RunRecord[]>;
|
|
68
|
+
patchRun(id: string, patch: Partial<RunRecord>): Promise<RunRecord>;
|
|
69
|
+
saveInvocation(record: InvocationRecord): Promise<void>;
|
|
70
|
+
invocations(runId: string): Promise<InvocationRecord[]>;
|
|
71
|
+
emit(runId: string | null, kind: string, payload: unknown): Promise<void>;
|
|
72
|
+
events(after?: number, runId?: string): Promise<EventRecord[]>;
|
|
73
|
+
subscribe(listener: (event: EventRecord) => void, options?: {
|
|
74
|
+
after?: number;
|
|
75
|
+
intervalMs?: number;
|
|
76
|
+
}): () => void;
|
|
77
|
+
request(kind: "pause" | "resume" | "stop" | "retry", target: string): Promise<string>;
|
|
78
|
+
commands(): Promise<Array<{
|
|
79
|
+
id: string;
|
|
80
|
+
kind: string;
|
|
81
|
+
target: string;
|
|
82
|
+
status: string;
|
|
83
|
+
error: string | null;
|
|
84
|
+
}>>;
|
|
85
|
+
finishCommand(id: string, error?: string): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { and, eq, gt, Param, SQL } from "drizzle-orm";
|
|
3
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
4
|
+
import { Pool } from "pg";
|
|
5
|
+
import { tryLock, unlockAll } from "./db/locks.js";
|
|
6
|
+
import { migrateDatabase } from "./db/migrations.js";
|
|
7
|
+
import * as tables from "./db/schema.js";
|
|
8
|
+
import { createQueuedRun } from "./run-record.js";
|
|
9
|
+
import { redactValue } from "./runtime/redaction.js";
|
|
10
|
+
/** Public persisted query surface. All queries work without a running executor. */
|
|
11
|
+
export class Store {
|
|
12
|
+
scope;
|
|
13
|
+
redact;
|
|
14
|
+
pool;
|
|
15
|
+
db;
|
|
16
|
+
ownership;
|
|
17
|
+
constructor(databaseUrl, scope, redact = (text) => text) {
|
|
18
|
+
this.scope = scope;
|
|
19
|
+
this.redact = redact;
|
|
20
|
+
this.pool = new Pool({ connectionString: databaseUrl });
|
|
21
|
+
this.db = drizzle(this.pool);
|
|
22
|
+
}
|
|
23
|
+
async initialize() {
|
|
24
|
+
await migrateDatabase(this.pool);
|
|
25
|
+
}
|
|
26
|
+
async acquire(keys, onLost) {
|
|
27
|
+
this.ownership = await this.pool.connect();
|
|
28
|
+
this.ownership.on("error", onLost);
|
|
29
|
+
try {
|
|
30
|
+
for (const key of [
|
|
31
|
+
`runner:${this.scope}`,
|
|
32
|
+
...keys.map((key) => `checkout:${key}`),
|
|
33
|
+
]) {
|
|
34
|
+
if (!(await tryLock(this.ownership, key))) {
|
|
35
|
+
throw new Error(`Already owned by another runner: ${key}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
await this.release();
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async release() {
|
|
45
|
+
const client = this.ownership;
|
|
46
|
+
if (!client)
|
|
47
|
+
return;
|
|
48
|
+
this.ownership = undefined;
|
|
49
|
+
try {
|
|
50
|
+
await unlockAll(client);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
client.removeAllListeners("error");
|
|
54
|
+
client.release(true);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async close() {
|
|
58
|
+
try {
|
|
59
|
+
await this.release();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await this.pool.end();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async registerProject(id) {
|
|
66
|
+
await this.db
|
|
67
|
+
.insert(tables.projects)
|
|
68
|
+
.values({ scope: this.scope, id })
|
|
69
|
+
.onConflictDoNothing();
|
|
70
|
+
}
|
|
71
|
+
async projects() {
|
|
72
|
+
const { projects } = tables;
|
|
73
|
+
return this.db
|
|
74
|
+
.select({
|
|
75
|
+
id: projects.id,
|
|
76
|
+
paused: projects.paused,
|
|
77
|
+
blocked: projects.blocked,
|
|
78
|
+
})
|
|
79
|
+
.from(projects)
|
|
80
|
+
.where(eq(projects.scope, this.scope))
|
|
81
|
+
.orderBy(projects.id);
|
|
82
|
+
}
|
|
83
|
+
async project(id) {
|
|
84
|
+
const state = (await this.projects()).find((project) => project.id === id);
|
|
85
|
+
if (!state)
|
|
86
|
+
throw new Error(`Unknown project: ${id}`);
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
async setProject(id, change) {
|
|
90
|
+
const { projects } = tables;
|
|
91
|
+
if (change.paused !== undefined || change.blocked !== undefined) {
|
|
92
|
+
await this.db
|
|
93
|
+
.update(projects)
|
|
94
|
+
.set(change)
|
|
95
|
+
.where(and(eq(projects.scope, this.scope), eq(projects.id, id)));
|
|
96
|
+
}
|
|
97
|
+
await this.emit(null, "project", { id, ...change });
|
|
98
|
+
}
|
|
99
|
+
async insertRun(run) {
|
|
100
|
+
const { runs } = tables;
|
|
101
|
+
const inserted = await this.db
|
|
102
|
+
.insert(runs)
|
|
103
|
+
.values({
|
|
104
|
+
scope: this.scope,
|
|
105
|
+
id: run.id,
|
|
106
|
+
taskKey: run.taskKey,
|
|
107
|
+
attempt: run.attempt,
|
|
108
|
+
record: redactValue(run, this.redact),
|
|
109
|
+
})
|
|
110
|
+
.onConflictDoNothing()
|
|
111
|
+
.returning({ id: runs.id });
|
|
112
|
+
if (inserted.length)
|
|
113
|
+
await this.emit(run.id, "run", run);
|
|
114
|
+
return inserted.length > 0;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Admit a retry and its events atomically. Safety checks run under the project
|
|
118
|
+
* lock only for new admissions; they must not write through this Store.
|
|
119
|
+
*/
|
|
120
|
+
async admitRetry(runId, admission) {
|
|
121
|
+
const original = await this.run(runId);
|
|
122
|
+
const { projects, runs, events } = tables;
|
|
123
|
+
const projectPredicate = and(eq(projects.scope, this.scope), eq(projects.id, original.projectId));
|
|
124
|
+
return this.db.transaction(async (tx) => {
|
|
125
|
+
// Lock the project, not just the original run: retries of different
|
|
126
|
+
// attempts of the same task must compete for the same admission.
|
|
127
|
+
const [project] = await tx
|
|
128
|
+
.select({ id: projects.id })
|
|
129
|
+
.from(projects)
|
|
130
|
+
.where(projectPredicate)
|
|
131
|
+
.for("update");
|
|
132
|
+
if (!project)
|
|
133
|
+
throw new Error(`Unknown project: ${original.projectId}`);
|
|
134
|
+
if (admission.commandId) {
|
|
135
|
+
const [existing] = await tx
|
|
136
|
+
.select({ record: runs.record })
|
|
137
|
+
.from(runs)
|
|
138
|
+
.where(and(eq(runs.scope, this.scope), eq(runs.id, admission.commandId)));
|
|
139
|
+
if (existing) {
|
|
140
|
+
if (existing.record.retryOf !== runId)
|
|
141
|
+
throw new Error("Retry command identity belongs to a different run");
|
|
142
|
+
return existing.record.id;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const history = await tx
|
|
146
|
+
.select({ record: runs.record, attempt: runs.attempt })
|
|
147
|
+
.from(runs)
|
|
148
|
+
.where(and(eq(runs.scope, this.scope), eq(runs.taskKey, original.taskKey)))
|
|
149
|
+
.orderBy(runs.id)
|
|
150
|
+
.for("update");
|
|
151
|
+
const previous = history.find((row) => row.record.id === runId)?.record;
|
|
152
|
+
if (!previous)
|
|
153
|
+
throw new Error(`Unknown run: ${runId}`);
|
|
154
|
+
if (!["failed", "blocked", "cancelled"].includes(previous.outcome))
|
|
155
|
+
throw new Error("Only failed, blocked or cancelled runs can be retried");
|
|
156
|
+
if (history.some(({ record }) => ["queued", "running"].includes(record.outcome)))
|
|
157
|
+
throw new Error("This task already has a queued or active retry");
|
|
158
|
+
const target = await admission.checkSafety(previous);
|
|
159
|
+
const attempt = Math.max(...history.map((row) => row.attempt)) + 1;
|
|
160
|
+
const id = admission.commandId ?? randomUUID();
|
|
161
|
+
const now = new Date().toISOString();
|
|
162
|
+
const retry = createQueuedRun({
|
|
163
|
+
id,
|
|
164
|
+
projectId: previous.projectId,
|
|
165
|
+
checkout: target.checkout,
|
|
166
|
+
taskKey: previous.taskKey,
|
|
167
|
+
attempt,
|
|
168
|
+
retryOf: previous.id,
|
|
169
|
+
issue: previous.issue,
|
|
170
|
+
now,
|
|
171
|
+
branchTemplate: target.branchTemplate,
|
|
172
|
+
});
|
|
173
|
+
const persisted = redactValue(retry, this.redact);
|
|
174
|
+
// A conflict must fail admission, never return an unpersisted run ID.
|
|
175
|
+
await tx.insert(runs).values({
|
|
176
|
+
scope: this.scope,
|
|
177
|
+
id,
|
|
178
|
+
taskKey: retry.taskKey,
|
|
179
|
+
attempt,
|
|
180
|
+
record: persisted,
|
|
181
|
+
});
|
|
182
|
+
await tx.update(projects).set({ blocked: null }).where(projectPredicate);
|
|
183
|
+
await tx.insert(events).values([
|
|
184
|
+
{
|
|
185
|
+
scope: this.scope,
|
|
186
|
+
runId: null,
|
|
187
|
+
kind: "project",
|
|
188
|
+
payload: redactValue({ id: previous.projectId, blocked: null }, this.redact),
|
|
189
|
+
},
|
|
190
|
+
{ scope: this.scope, runId: id, kind: "run", payload: persisted },
|
|
191
|
+
]);
|
|
192
|
+
return id;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async run(id) {
|
|
196
|
+
const { runs } = tables;
|
|
197
|
+
const [row] = await this.db
|
|
198
|
+
.select({ record: runs.record })
|
|
199
|
+
.from(runs)
|
|
200
|
+
.where(and(eq(runs.scope, this.scope), eq(runs.id, id)));
|
|
201
|
+
if (!row)
|
|
202
|
+
throw new Error(`Unknown run: ${id}`);
|
|
203
|
+
return row.record;
|
|
204
|
+
}
|
|
205
|
+
async runs() {
|
|
206
|
+
const { runs } = tables;
|
|
207
|
+
const rows = await this.db
|
|
208
|
+
.select({ record: runs.record, attempt: runs.attempt, id: runs.id })
|
|
209
|
+
.from(runs)
|
|
210
|
+
.where(eq(runs.scope, this.scope));
|
|
211
|
+
// This API returns the full scope; sort JSON fields here without raw SQL expressions.
|
|
212
|
+
return rows
|
|
213
|
+
.sort((a, b) => a.record.createdAt.localeCompare(b.record.createdAt) ||
|
|
214
|
+
a.record.issue.number - b.record.issue.number ||
|
|
215
|
+
a.attempt - b.attempt ||
|
|
216
|
+
a.id.localeCompare(b.id))
|
|
217
|
+
.map((row) => row.record);
|
|
218
|
+
}
|
|
219
|
+
async patchRun(id, patch) {
|
|
220
|
+
const { runs } = tables;
|
|
221
|
+
const change = { ...patch, updatedAt: new Date().toISOString() };
|
|
222
|
+
const predicate = and(eq(runs.scope, this.scope), eq(runs.id, id));
|
|
223
|
+
const record = await this.db.transaction(async (tx) => {
|
|
224
|
+
// Serialize read/merge/write so concurrent patches cannot lose fields.
|
|
225
|
+
const [row] = await tx
|
|
226
|
+
.select({ record: runs.record })
|
|
227
|
+
.from(runs)
|
|
228
|
+
.where(predicate)
|
|
229
|
+
.for("update");
|
|
230
|
+
if (!row)
|
|
231
|
+
throw new Error(`Unknown run: ${id}`);
|
|
232
|
+
// Match JSON serialization: undefined patch fields leave stored fields intact.
|
|
233
|
+
const persistedChange = JSON.parse(JSON.stringify(redactValue(change, this.redact)));
|
|
234
|
+
const merged = { ...row.record, ...persistedChange };
|
|
235
|
+
await tx.update(runs).set({ record: merged }).where(predicate);
|
|
236
|
+
return merged;
|
|
237
|
+
});
|
|
238
|
+
await this.emit(id, "run", change);
|
|
239
|
+
return record;
|
|
240
|
+
}
|
|
241
|
+
async saveInvocation(record) {
|
|
242
|
+
const { invocations } = tables;
|
|
243
|
+
const persisted = redactValue(record, this.redact);
|
|
244
|
+
await this.db
|
|
245
|
+
.insert(invocations)
|
|
246
|
+
.values({
|
|
247
|
+
scope: this.scope,
|
|
248
|
+
id: record.id,
|
|
249
|
+
runId: record.runId,
|
|
250
|
+
record: persisted,
|
|
251
|
+
})
|
|
252
|
+
.onConflictDoUpdate({
|
|
253
|
+
target: invocations.id,
|
|
254
|
+
set: { record: persisted },
|
|
255
|
+
});
|
|
256
|
+
await this.emit(record.runId, "invocation", record);
|
|
257
|
+
}
|
|
258
|
+
async invocations(runId) {
|
|
259
|
+
const { invocations } = tables;
|
|
260
|
+
const rows = await this.db
|
|
261
|
+
.select({ record: invocations.record })
|
|
262
|
+
.from(invocations)
|
|
263
|
+
.where(and(eq(invocations.scope, this.scope), eq(invocations.runId, runId)));
|
|
264
|
+
return rows
|
|
265
|
+
.map((row) => row.record)
|
|
266
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
267
|
+
}
|
|
268
|
+
async emit(runId, kind, payload) {
|
|
269
|
+
await this.db.insert(tables.events).values({
|
|
270
|
+
scope: this.scope,
|
|
271
|
+
runId,
|
|
272
|
+
kind,
|
|
273
|
+
// Bind serialized JSON so JSON null is not converted to SQL NULL.
|
|
274
|
+
payload: new SQL([
|
|
275
|
+
new Param(JSON.stringify(redactValue(payload, this.redact))),
|
|
276
|
+
]),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async events(after = 0, runId) {
|
|
280
|
+
const { events } = tables;
|
|
281
|
+
const rows = await this.db
|
|
282
|
+
.select({
|
|
283
|
+
sequence: events.sequence,
|
|
284
|
+
runId: events.runId,
|
|
285
|
+
kind: events.kind,
|
|
286
|
+
payload: events.payload,
|
|
287
|
+
createdAt: events.createdAt,
|
|
288
|
+
})
|
|
289
|
+
.from(events)
|
|
290
|
+
.where(and(eq(events.scope, this.scope), gt(events.sequence, after), runId === undefined ? undefined : eq(events.runId, runId)))
|
|
291
|
+
.orderBy(events.sequence)
|
|
292
|
+
.limit(1000);
|
|
293
|
+
return rows.map((row) => ({
|
|
294
|
+
...row,
|
|
295
|
+
createdAt: row.createdAt.toISOString(),
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
subscribe(listener, options = {}) {
|
|
299
|
+
let cursor = options.after ?? 0, closed = false, busy = false;
|
|
300
|
+
const poll = async () => {
|
|
301
|
+
if (closed || busy)
|
|
302
|
+
return;
|
|
303
|
+
busy = true;
|
|
304
|
+
try {
|
|
305
|
+
for (const event of await this.events(cursor)) {
|
|
306
|
+
if (closed)
|
|
307
|
+
break;
|
|
308
|
+
listener(event);
|
|
309
|
+
cursor = event.sequence;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
busy = false;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const timer = setInterval(() => {
|
|
317
|
+
void poll().catch(() => {
|
|
318
|
+
/* Next poll retries; callers can query directly for errors. */
|
|
319
|
+
});
|
|
320
|
+
}, options.intervalMs ?? 500);
|
|
321
|
+
void poll().catch(() => { });
|
|
322
|
+
return () => {
|
|
323
|
+
closed = true;
|
|
324
|
+
clearInterval(timer);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async request(kind, target) {
|
|
328
|
+
const id = randomUUID();
|
|
329
|
+
await this.db
|
|
330
|
+
.insert(tables.commands)
|
|
331
|
+
.values({ id, scope: this.scope, kind, target });
|
|
332
|
+
return id;
|
|
333
|
+
}
|
|
334
|
+
async commands() {
|
|
335
|
+
const { commands } = tables;
|
|
336
|
+
return this.db
|
|
337
|
+
.select({
|
|
338
|
+
id: commands.id,
|
|
339
|
+
kind: commands.kind,
|
|
340
|
+
target: commands.target,
|
|
341
|
+
status: commands.status,
|
|
342
|
+
error: commands.error,
|
|
343
|
+
})
|
|
344
|
+
.from(commands)
|
|
345
|
+
.where(eq(commands.scope, this.scope))
|
|
346
|
+
.orderBy(commands.createdAt);
|
|
347
|
+
}
|
|
348
|
+
async finishCommand(id, error) {
|
|
349
|
+
const { commands } = tables;
|
|
350
|
+
await this.db
|
|
351
|
+
.update(commands)
|
|
352
|
+
.set({ status: error ? "failed" : "success", error: error ?? null })
|
|
353
|
+
.where(and(eq(commands.scope, this.scope), eq(commands.id, id)));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunRecord } from "./domain.js";
|
|
2
|
+
import type { EventRecord, InvocationRecord, ProjectState, Store } from "./store.js";
|
|
3
|
+
export interface MonitorSource {
|
|
4
|
+
projects: Store["projects"];
|
|
5
|
+
runs: Store["runs"];
|
|
6
|
+
invocations: Store["invocations"];
|
|
7
|
+
events: Store["events"];
|
|
8
|
+
request: Store["request"];
|
|
9
|
+
commands: Store["commands"];
|
|
10
|
+
}
|
|
11
|
+
export declare function useMonitorData(source: MonitorSource, selection: {
|
|
12
|
+
projectIndex: number;
|
|
13
|
+
runIndex: number;
|
|
14
|
+
}): {
|
|
15
|
+
projects: ProjectState[];
|
|
16
|
+
project: ProjectState | undefined;
|
|
17
|
+
projectRuns: RunRecord[];
|
|
18
|
+
run: RunRecord | undefined;
|
|
19
|
+
sessions: InvocationRecord[];
|
|
20
|
+
events: EventRecord[];
|
|
21
|
+
message: string;
|
|
22
|
+
setMessage: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
23
|
+
pending: string | undefined;
|
|
24
|
+
action: (kind: "pause" | "resume" | "stop" | "retry", target: string) => Promise<void>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
export function useMonitorData(source, selection) {
|
|
3
|
+
const { projectIndex, runIndex } = selection;
|
|
4
|
+
const [projects, setProjects] = useState([]);
|
|
5
|
+
const [runs, setRuns] = useState([]);
|
|
6
|
+
const [sessions, setSessions] = useState([]), [events, setEvents] = useState([]);
|
|
7
|
+
const [message, setMessage] = useState("Connecting…"), [pending, setPending] = useState();
|
|
8
|
+
const project = projects[projectIndex];
|
|
9
|
+
const projectRuns = runs.filter((run) => run.projectId === project?.id);
|
|
10
|
+
const run = projectRuns[runIndex];
|
|
11
|
+
const selectedRunId = run?.id;
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let closed = false, busy = false;
|
|
14
|
+
const update = async () => {
|
|
15
|
+
if (busy)
|
|
16
|
+
return;
|
|
17
|
+
busy = true;
|
|
18
|
+
try {
|
|
19
|
+
const [nextProjects, nextRuns] = await Promise.all([
|
|
20
|
+
source.projects(),
|
|
21
|
+
source.runs(),
|
|
22
|
+
]);
|
|
23
|
+
if (closed)
|
|
24
|
+
return;
|
|
25
|
+
setProjects(nextProjects);
|
|
26
|
+
setRuns(nextRuns);
|
|
27
|
+
if (selectedRunId) {
|
|
28
|
+
const [nextSessions, nextEvents] = await Promise.all([
|
|
29
|
+
source.invocations(selectedRunId),
|
|
30
|
+
source.events(0, selectedRunId),
|
|
31
|
+
]);
|
|
32
|
+
if (closed)
|
|
33
|
+
return;
|
|
34
|
+
setSessions(nextSessions);
|
|
35
|
+
setEvents(nextEvents.filter((event) => event.runId === selectedRunId));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
setSessions([]);
|
|
39
|
+
setEvents([]);
|
|
40
|
+
}
|
|
41
|
+
if (pending) {
|
|
42
|
+
const command = (await source.commands()).find((command) => command.id === pending);
|
|
43
|
+
if (command && command.status !== "pending") {
|
|
44
|
+
setMessage(`${command.kind}: ${command.status}${command.error ? ` — ${command.error}` : ""}`);
|
|
45
|
+
setPending(undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
setMessage((current) => current === "Connecting…" ? "Connected" : current);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!closed)
|
|
53
|
+
setMessage(`Connection error: ${String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
busy = false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
void update();
|
|
60
|
+
const timer = setInterval(() => void update(), 400);
|
|
61
|
+
return () => {
|
|
62
|
+
closed = true;
|
|
63
|
+
clearInterval(timer);
|
|
64
|
+
};
|
|
65
|
+
}, [source, selectedRunId, pending]);
|
|
66
|
+
const action = async (kind, target) => {
|
|
67
|
+
setMessage(`${kind}: pending`);
|
|
68
|
+
setPending("submitting");
|
|
69
|
+
try {
|
|
70
|
+
setPending(await source.request(kind, target));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
setPending(undefined);
|
|
74
|
+
setMessage(`${kind}: failed — ${String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
projects,
|
|
79
|
+
project,
|
|
80
|
+
projectRuns,
|
|
81
|
+
run,
|
|
82
|
+
sessions,
|
|
83
|
+
events,
|
|
84
|
+
message,
|
|
85
|
+
setMessage,
|
|
86
|
+
pending,
|
|
87
|
+
action,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/src/tui.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { useMonitorData } from "./tui-data.js";
|
|
6
|
+
export function Monitor({ source }) {
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const [projectIndex, setProjectIndex] = useState(0), [runIndex, setRunIndex] = useState(0), [sessionIndex, setSessionIndex] = useState(0), [stepIndex, setStepIndex] = useState(0), [validationIndex, setValidationIndex] = useState(0);
|
|
9
|
+
const { projects, project, projectRuns, run, sessions, events, message, setMessage, pending, action, } = useMonitorData(source, { projectIndex, runIndex });
|
|
10
|
+
const [log, setLog] = useState("");
|
|
11
|
+
const session = sessions[sessionIndex];
|
|
12
|
+
const steps = events.filter((event) => event.kind === "step");
|
|
13
|
+
const selectedStep = steps[stepIndex];
|
|
14
|
+
const showLog = (path) => {
|
|
15
|
+
void readFile(path, "utf8").then((text) => setLog(text.split("\n").slice(-8).join("\n")), (error) => setMessage(`Log unavailable: ${String(error)}`));
|
|
16
|
+
};
|
|
17
|
+
useInput((input, key) => {
|
|
18
|
+
if (input === "q") {
|
|
19
|
+
exit();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (key.leftArrow || key.rightArrow) {
|
|
23
|
+
setProjectIndex((index) => Math.max(0, Math.min(projects.length - 1, index + (key.rightArrow ? 1 : -1))));
|
|
24
|
+
setRunIndex(0);
|
|
25
|
+
setSessionIndex(0);
|
|
26
|
+
setStepIndex(0);
|
|
27
|
+
setValidationIndex(0);
|
|
28
|
+
setLog("");
|
|
29
|
+
}
|
|
30
|
+
if (key.upArrow || key.downArrow) {
|
|
31
|
+
setRunIndex((index) => Math.max(0, Math.min(projectRuns.length - 1, index + (key.downArrow ? 1 : -1))));
|
|
32
|
+
setSessionIndex(0);
|
|
33
|
+
setStepIndex(0);
|
|
34
|
+
setValidationIndex(0);
|
|
35
|
+
setLog("");
|
|
36
|
+
}
|
|
37
|
+
if (key.tab) {
|
|
38
|
+
setSessionIndex((index) => (index + 1) % Math.max(sessions.length, 1));
|
|
39
|
+
setLog("");
|
|
40
|
+
}
|
|
41
|
+
if (input === "l" && session)
|
|
42
|
+
showLog(session.log);
|
|
43
|
+
if (input === "[" || input === "]")
|
|
44
|
+
setStepIndex((index) => Math.max(0, Math.min(steps.length - 1, index + (input === "]" ? 1 : -1))));
|
|
45
|
+
if (input === "v" && run?.validation?.length) {
|
|
46
|
+
const check = run.validation[validationIndex % run.validation.length];
|
|
47
|
+
setMessage(`Validation: ${check.command} · exit ${check.exitCode}`);
|
|
48
|
+
showLog(check.log);
|
|
49
|
+
setValidationIndex((index) => index + 1);
|
|
50
|
+
}
|
|
51
|
+
if (pending)
|
|
52
|
+
return;
|
|
53
|
+
if (input === "p" && project)
|
|
54
|
+
void action(project.paused ? "resume" : "pause", project.id);
|
|
55
|
+
if (input === "s" && run)
|
|
56
|
+
void action("stop", run.id);
|
|
57
|
+
if (input === "r" && run)
|
|
58
|
+
void action("retry", run.id);
|
|
59
|
+
});
|
|
60
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Agent Workflows \u00B7 Monitor" }), _jsx(Text, { children: "\u2190 \u2192 project \u00B7 \u2191 \u2193 run \u00B7 [ ] step/attempt \u00B7 Tab session \u00B7 l agent log \u00B7 v validation log \u00B7 p pause/resume \u00B7 s stop \u00B7 r retry \u00B7 q close" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: project
|
|
61
|
+
? `${project.id} · ${project.paused ? "intake paused" : "intake enabled"} · ${projectRuns.filter((run) => run.outcome === "queued").length} queued`
|
|
62
|
+
: "No projects registered. Start the runner to populate this view." }), project?.blocked && (_jsxs(Text, { color: "yellow", children: ["Blocked: ", project.blocked] })), projectRuns
|
|
63
|
+
.slice(Math.max(0, runIndex - 1), runIndex + 2)
|
|
64
|
+
.map((item) => (_jsxs(Text, { inverse: item.id === run?.id, children: [item.id === run?.id ? ">" : " ", " #", item.issue.number, " \u00B7 attempt", " ", item.attempt, " \u00B7 ", item.outcome, " \u00B7 ", item.phase] }, item.id))), project && projectRuns.length === 0 && (_jsx(Text, { children: "No runs yet. Eligible issues appear after polling." }))] }), run && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Run ", run.id] }), _jsx(Text, { children: run.issue.title }), run.error && _jsx(Text, { color: "red", children: run.error }), _jsxs(Text, { children: ["Validation:", " ", run.validation
|
|
65
|
+
?.map((check) => `${check.command}: exit ${check.exitCode}`)
|
|
66
|
+
.join(" · ") || "No checks recorded"] }), _jsxs(Text, { children: ["Step/attempt event ", steps.length ? stepIndex + 1 : 0, "/", steps.length, ":", " ", selectedStep
|
|
67
|
+
? JSON.stringify(selectedStep.payload)
|
|
68
|
+
: "No steps recorded"] }), _jsxs(Text, { bold: true, children: ["Agent sessions \u00B7 ", sessions.length] }), session ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [session.step, " \u00B7 invocation ", session.attempt, " \u00B7", " ", session.outcome] }), _jsxs(Text, { children: ["Session: ", session.sessionId ?? session.sessionState] }), _jsxs(Text, { children: ["Requested: ", JSON.stringify(session.requested)] }), _jsxs(Text, { children: ["Effective: ", JSON.stringify(session.effective)] }), _jsxs(Text, { children: ["Log: ", session.log] })] })) : (_jsx(Text, { children: "No agent sessions recorded" })), log && _jsx(Text, { children: log })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: message }) }), _jsx(Text, { dimColor: true, children: "Closing this monitor leaves the runner and its tasks running." })] }));
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Project } from "./config.js";
|
|
2
|
+
import { type Publication, type Snapshot, type Workspace } from "./domain.js";
|
|
3
|
+
export declare class ExistingCheckout implements Workspace {
|
|
4
|
+
private readonly processDirectories;
|
|
5
|
+
private processDirectory;
|
|
6
|
+
private runGit;
|
|
7
|
+
private git;
|
|
8
|
+
check(project: Project): Promise<void>;
|
|
9
|
+
private assertNoOperation;
|
|
10
|
+
prepare(project: Project, branch: string, signal?: AbortSignal): Promise<Snapshot>;
|
|
11
|
+
inspect(project: Project): Promise<Snapshot>;
|
|
12
|
+
verify(project: Project, expected: Snapshot): Promise<void>;
|
|
13
|
+
commit(project: Project, expected: Snapshot, publication: Publication, runId: string, signal?: AbortSignal): Promise<string>;
|
|
14
|
+
push(project: Project, branch: string, head: string, signal?: AbortSignal): Promise<void>;
|
|
15
|
+
release(project: Project): Promise<void>;
|
|
16
|
+
}
|