@tea-agent/loop-agent 0.16.26 → 0.17.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/CHANGELOG.md +38 -1
- package/README.md +14 -3
- package/dist/cli/command-definitions.js +43 -0
- package/dist/cli/program.js +26 -0
- package/dist/commands/dag-approve.js +4 -0
- package/dist/commands/dag-resume.js +1 -0
- package/dist/commands/dag-validate.js +6 -0
- package/dist/commands/operator.js +44 -0
- package/dist/commands/task-contract.js +271 -0
- package/dist/executors/dag-pi-executor.js +55 -7
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +55 -8
- package/dist/executors/shell-presets.js +16 -3
- package/dist/shared/operator/capabilities.js +255 -0
- package/dist/shared/operator/envelope.js +59 -0
- package/dist/shared/operator/index.js +4 -0
- package/dist/shared/operator/registry.js +38 -0
- package/dist/shared/operator/types.js +5 -0
- package/dist/task/contract/adopt.js +166 -0
- package/dist/task/contract/apply.js +326 -0
- package/dist/task/contract/canonicalize.js +60 -0
- package/dist/task/contract/constants.js +29 -0
- package/dist/task/contract/diff.js +177 -0
- package/dist/task/contract/hash.js +42 -0
- package/dist/task/contract/import-revision.js +96 -0
- package/dist/task/contract/index.js +17 -0
- package/dist/task/contract/journal.js +155 -0
- package/dist/task/contract/lock.js +153 -0
- package/dist/task/contract/observe.js +296 -0
- package/dist/task/contract/paths.js +19 -0
- package/dist/task/contract/project.js +170 -0
- package/dist/task/contract/recover.js +312 -0
- package/dist/task/contract/request-ledger.js +37 -0
- package/dist/task/contract/schema.js +151 -0
- package/dist/task/contract/transaction.js +160 -0
- package/dist/task/contract/types.js +1 -0
- package/dist/task/contract/validate-draft.js +106 -0
- package/dist/task/index.js +3 -0
- package/dist/task/operator/capabilities.js +6 -0
- package/dist/task/operator/envelope.js +2 -0
- package/dist/task/operator/index.js +5 -0
- package/dist/task/operator/registry.js +2 -0
- package/dist/task/operator/types.js +1 -0
- package/dist/task/runtime.js +5 -1
- package/dist/task/source-references.js +7 -0
- package/dist/worker/cli.js +150 -32
- package/dist/worker/console/app-data.js +185 -0
- package/dist/worker/console/dag-confirmation.js +313 -0
- package/dist/worker/console/doctor.js +169 -0
- package/dist/worker/console/draft-store.js +80 -0
- package/dist/worker/console/index.js +15 -0
- package/dist/worker/console/interview/assessment.js +67 -0
- package/dist/worker/console/interview/session.js +100 -0
- package/dist/worker/console/interview/tools.js +109 -0
- package/dist/worker/console/loopback.js +16 -0
- package/dist/worker/console/observe-health-match.js +174 -0
- package/dist/worker/console/observe-link.js +33 -0
- package/dist/worker/console/operation-runner.js +166 -0
- package/dist/worker/console/operation-sse.js +158 -0
- package/dist/worker/console/operation-store.js +147 -0
- package/dist/worker/console/operator-actions.js +769 -0
- package/dist/worker/console/pi-readiness.js +94 -0
- package/dist/worker/console/recovery-cta.js +133 -0
- package/dist/worker/console/repo-fingerprint.js +29 -0
- package/dist/worker/console/resource-loader.js +95 -0
- package/dist/worker/console/routes.js +368 -0
- package/dist/worker/console/security.js +126 -0
- package/dist/worker/console/server.js +149 -0
- package/dist/worker/console/sibling-controller.js +28 -0
- package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
- package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
- package/dist/worker/console/static/index.html +13 -0
- package/dist/worker/console/vite.config.js +27 -0
- package/dist/worker/observe/health.js +57 -0
- package/dist/worker/observe/routes.js +16 -9
- package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
- package/dist/workflows/dag/frontend-repair.js +1 -10
- package/dist/workflows/dag/init-hybrid.js +269 -116
- package/dist/workflows/dag/node-execution.js +25 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +74 -10
- package/dist/workflows/dag/validate.js +25 -7
- package/docs/README.md +2 -0
- package/docs/architecture/evolution.md +2 -0
- package/docs/architecture/system-overview.md +6 -0
- package/docs/architecture/worker-and-feature.md +7 -0
- package/docs/templates/agent-dag.schema.json +49 -2
- package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
- package/docs/templates/backend-test-dag.json +26 -154
- package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
- package/package.json +8 -2
- package/skills/agent-worker/SKILL.md +1 -0
- package/skills/agent-worker/references/agent-worker-operator.md +3 -2
- package/skills/frontend-design-review/SKILL.md +25 -16
- package/skills/frontend-implementation/references/node-contracts.md +5 -5
- package/skills/loop-agent/references/command-reference.md +48 -1
- package/skills/loop-agent/references/hybrid-dag.md +4 -4
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureSecureDir, writeSecureJsonSync } from "./app-data.js";
|
|
4
|
+
const DEFAULT_MAX_EVENTS = 1000;
|
|
5
|
+
function safeOperationId(operationId) {
|
|
6
|
+
return operationId.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 180);
|
|
7
|
+
}
|
|
8
|
+
export function createOperationEventStore(options) {
|
|
9
|
+
const maxEvents = options?.maxEventsPerOperation ?? DEFAULT_MAX_EVENTS;
|
|
10
|
+
const persistenceDir = options?.persistenceDir
|
|
11
|
+
? path.resolve(options.persistenceDir)
|
|
12
|
+
: undefined;
|
|
13
|
+
if (persistenceDir)
|
|
14
|
+
ensureSecureDir(persistenceDir);
|
|
15
|
+
const rings = new Map();
|
|
16
|
+
function persistedPath(operationId) {
|
|
17
|
+
return persistenceDir
|
|
18
|
+
? path.join(persistenceDir, `${safeOperationId(operationId)}.json`)
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
function loadRing(operationId) {
|
|
22
|
+
const file = persistedPath(operationId);
|
|
23
|
+
if (!file || !existsSync(file))
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
27
|
+
if (parsed.schemaVersion !== 1 ||
|
|
28
|
+
parsed.operationId !== operationId ||
|
|
29
|
+
!Array.isArray(parsed.events)) {
|
|
30
|
+
throw new Error("invalid persisted operation event ring");
|
|
31
|
+
}
|
|
32
|
+
const events = parsed.events.slice(-maxEvents);
|
|
33
|
+
return {
|
|
34
|
+
events,
|
|
35
|
+
nextSeq: Math.max(parsed.nextSeq, (events[events.length - 1]?.seq ?? 0) + 1),
|
|
36
|
+
minSeq: events[0]?.seq ?? parsed.minSeq ?? parsed.nextSeq,
|
|
37
|
+
maxEvents,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new Error(`invalid persisted operation events ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function persist(operationId, ring) {
|
|
45
|
+
const file = persistedPath(operationId);
|
|
46
|
+
if (!file)
|
|
47
|
+
return;
|
|
48
|
+
writeSecureJsonSync(file, {
|
|
49
|
+
schemaVersion: 1,
|
|
50
|
+
operationId,
|
|
51
|
+
events: ring.events,
|
|
52
|
+
nextSeq: ring.nextSeq,
|
|
53
|
+
minSeq: ring.minSeq,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function ringFor(operationId) {
|
|
57
|
+
let ring = rings.get(operationId);
|
|
58
|
+
if (!ring) {
|
|
59
|
+
ring = loadRing(operationId) ?? {
|
|
60
|
+
events: [],
|
|
61
|
+
nextSeq: 1,
|
|
62
|
+
minSeq: 1,
|
|
63
|
+
maxEvents,
|
|
64
|
+
};
|
|
65
|
+
rings.set(operationId, ring);
|
|
66
|
+
}
|
|
67
|
+
return ring;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
append(operationId, partial) {
|
|
71
|
+
const ring = ringFor(operationId);
|
|
72
|
+
const seq = ring.nextSeq++;
|
|
73
|
+
const event = {
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
eventId: `${operationId}:${seq}`,
|
|
76
|
+
operationId,
|
|
77
|
+
seq,
|
|
78
|
+
at: partial.at ?? new Date().toISOString(),
|
|
79
|
+
kind: partial.kind,
|
|
80
|
+
message: partial.message,
|
|
81
|
+
state: partial.state,
|
|
82
|
+
data: partial.data,
|
|
83
|
+
};
|
|
84
|
+
ring.events.push(event);
|
|
85
|
+
while (ring.events.length > ring.maxEvents) {
|
|
86
|
+
const dropped = ring.events.shift();
|
|
87
|
+
if (dropped)
|
|
88
|
+
ring.minSeq = dropped.seq + 1;
|
|
89
|
+
}
|
|
90
|
+
if (ring.events.length > 0) {
|
|
91
|
+
ring.minSeq = ring.events[0].seq;
|
|
92
|
+
}
|
|
93
|
+
persist(operationId, ring);
|
|
94
|
+
return event;
|
|
95
|
+
},
|
|
96
|
+
listFrom(operationId, afterSeq) {
|
|
97
|
+
const ring = rings.get(operationId) ?? loadRing(operationId);
|
|
98
|
+
if (!ring) {
|
|
99
|
+
return { events: [] };
|
|
100
|
+
}
|
|
101
|
+
rings.set(operationId, ring);
|
|
102
|
+
// Gap between requested cursor and retained window → must re-snapshot.
|
|
103
|
+
if (ring.events.length > 0 && afterSeq + 1 < ring.minSeq) {
|
|
104
|
+
return { error: "EVENT_CURSOR_EXPIRED" };
|
|
105
|
+
}
|
|
106
|
+
const events = ring.events.filter((e) => e.seq > afterSeq);
|
|
107
|
+
return { events };
|
|
108
|
+
},
|
|
109
|
+
snapshot(operationId) {
|
|
110
|
+
const ring = rings.get(operationId) ?? loadRing(operationId);
|
|
111
|
+
if (ring)
|
|
112
|
+
rings.set(operationId, ring);
|
|
113
|
+
return ring?.events.slice() ?? [];
|
|
114
|
+
},
|
|
115
|
+
clear(operationId) {
|
|
116
|
+
rings.delete(operationId);
|
|
117
|
+
const file = persistedPath(operationId);
|
|
118
|
+
if (file)
|
|
119
|
+
rmSync(file, { force: true });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function parseLastEventId(header) {
|
|
124
|
+
if (!header)
|
|
125
|
+
return 0;
|
|
126
|
+
const raw = header.trim();
|
|
127
|
+
if (!raw)
|
|
128
|
+
return 0;
|
|
129
|
+
// Accept either bare seq or "operationId:seq"
|
|
130
|
+
const colon = raw.lastIndexOf(":");
|
|
131
|
+
const seqPart = colon >= 0 ? raw.slice(colon + 1) : raw;
|
|
132
|
+
const n = Number.parseInt(seqPart, 10);
|
|
133
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
134
|
+
}
|
|
135
|
+
export function writeSseEvent(res, event) {
|
|
136
|
+
res.write(`id: ${event.eventId}\n`);
|
|
137
|
+
res.write(`event: operator\n`);
|
|
138
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
139
|
+
}
|
|
140
|
+
export function openSseResponse(res) {
|
|
141
|
+
res.writeHead(200, {
|
|
142
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
143
|
+
"Cache-Control": "no-cache, no-transform",
|
|
144
|
+
Connection: "keep-alive",
|
|
145
|
+
"X-Accel-Buffering": "no",
|
|
146
|
+
});
|
|
147
|
+
// Help some proxies flush immediately
|
|
148
|
+
res.write(": connected\n\n");
|
|
149
|
+
}
|
|
150
|
+
export function stateEvent(store, operation, state, message) {
|
|
151
|
+
return store.append(operation.operationId, {
|
|
152
|
+
at: new Date().toISOString(),
|
|
153
|
+
kind: "state",
|
|
154
|
+
state,
|
|
155
|
+
message,
|
|
156
|
+
data: { action: operation.action },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listJsonFiles, readJsonIfExists, sha256Json, writeSecureJson, } from "./app-data.js";
|
|
4
|
+
export const TERMINAL_OPERATION_STATES = new Set(["succeeded", "failed", "timed-out"]);
|
|
5
|
+
function operationFile(appData, operationId) {
|
|
6
|
+
return path.join(appData.operations, `${operationId}.json`);
|
|
7
|
+
}
|
|
8
|
+
function requestIndexFile(appData, clientRequestId) {
|
|
9
|
+
const safe = clientRequestId.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 180);
|
|
10
|
+
return path.join(appData.operations, `req-${safe}.json`);
|
|
11
|
+
}
|
|
12
|
+
export function computeActionPayloadHash(action, actionParams) {
|
|
13
|
+
return sha256Json({ action, actionParams });
|
|
14
|
+
}
|
|
15
|
+
export function newOperationId() {
|
|
16
|
+
return `op_${randomBytes(12).toString("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
export class OperationStore {
|
|
19
|
+
appData;
|
|
20
|
+
constructor(appData) {
|
|
21
|
+
this.appData = appData;
|
|
22
|
+
}
|
|
23
|
+
async create(input) {
|
|
24
|
+
const clientRequestId = input.clientRequestId.trim();
|
|
25
|
+
if (!clientRequestId) {
|
|
26
|
+
throw new Error("clientRequestId is required for mutation operations");
|
|
27
|
+
}
|
|
28
|
+
const payloadHash = computeActionPayloadHash(input.action, input.actionParams);
|
|
29
|
+
const indexPath = requestIndexFile(this.appData, clientRequestId);
|
|
30
|
+
const existingIndex = await readJsonIfExists(indexPath);
|
|
31
|
+
if (existingIndex) {
|
|
32
|
+
const existing = await this.get(existingIndex.operationId);
|
|
33
|
+
if (existing) {
|
|
34
|
+
if (existing.payloadHash === payloadHash) {
|
|
35
|
+
return { kind: "idempotent", operation: existing };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
kind: "conflict",
|
|
39
|
+
code: "REQUEST_ID_REUSE_CONFLICT",
|
|
40
|
+
message: `clientRequestId ${clientRequestId} was reused with a different payload`,
|
|
41
|
+
existing,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const operationId = newOperationId();
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
const operation = {
|
|
48
|
+
schemaVersion: 1,
|
|
49
|
+
operationId,
|
|
50
|
+
clientRequestId,
|
|
51
|
+
action: input.action,
|
|
52
|
+
actionParams: input.actionParams,
|
|
53
|
+
payloadHash,
|
|
54
|
+
state: "queued",
|
|
55
|
+
createdAt: now,
|
|
56
|
+
taskId: input.taskId,
|
|
57
|
+
cliArgs: input.cliArgs,
|
|
58
|
+
};
|
|
59
|
+
await writeSecureJson(operationFile(this.appData, operationId), operation);
|
|
60
|
+
await writeSecureJson(indexPath, { operationId, payloadHash });
|
|
61
|
+
return { kind: "created", operation };
|
|
62
|
+
}
|
|
63
|
+
async get(operationId) {
|
|
64
|
+
return readJsonIfExists(operationFile(this.appData, operationId));
|
|
65
|
+
}
|
|
66
|
+
async save(operation) {
|
|
67
|
+
await writeSecureJson(operationFile(this.appData, operation.operationId), operation);
|
|
68
|
+
return operation;
|
|
69
|
+
}
|
|
70
|
+
async update(operationId, patch) {
|
|
71
|
+
const current = await this.get(operationId);
|
|
72
|
+
if (!current) {
|
|
73
|
+
throw new Error(`operation not found: ${operationId}`);
|
|
74
|
+
}
|
|
75
|
+
const next = {
|
|
76
|
+
...current,
|
|
77
|
+
...patch,
|
|
78
|
+
operationId: current.operationId,
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
clientRequestId: current.clientRequestId,
|
|
81
|
+
payloadHash: current.payloadHash,
|
|
82
|
+
action: current.action,
|
|
83
|
+
createdAt: current.createdAt,
|
|
84
|
+
};
|
|
85
|
+
return this.save(next);
|
|
86
|
+
}
|
|
87
|
+
async list() {
|
|
88
|
+
const files = await listJsonFiles(this.appData.operations);
|
|
89
|
+
const ops = [];
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const base = path.basename(file);
|
|
92
|
+
if (base.startsWith("req-"))
|
|
93
|
+
continue;
|
|
94
|
+
const rec = await readJsonIfExists(file);
|
|
95
|
+
if (rec?.operationId)
|
|
96
|
+
ops.push(rec);
|
|
97
|
+
}
|
|
98
|
+
ops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
99
|
+
return ops;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* On Console boot: any non-terminal operation that cannot be proven still
|
|
103
|
+
* running becomes needs-reconcile (never invent succeeded/failed).
|
|
104
|
+
*/
|
|
105
|
+
async reconcileOnBoot(options) {
|
|
106
|
+
const isAlive = options?.isProcessAlive ??
|
|
107
|
+
((pid) => {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
const changed = [];
|
|
117
|
+
for (const op of await this.list()) {
|
|
118
|
+
if (TERMINAL_OPERATION_STATES.has(op.state))
|
|
119
|
+
continue;
|
|
120
|
+
if (op.state === "needs-reconcile")
|
|
121
|
+
continue;
|
|
122
|
+
let next;
|
|
123
|
+
if (op.state === "running" &&
|
|
124
|
+
typeof op.pid === "number" &&
|
|
125
|
+
isAlive(op.pid, op.processStartIdentity)) {
|
|
126
|
+
// still running — leave as-is
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// queued/starting/running without proven process → needs-reconcile
|
|
130
|
+
next = await this.update(op.operationId, {
|
|
131
|
+
state: "needs-reconcile",
|
|
132
|
+
errorCode: "OPERATION_NEEDS_RECONCILE",
|
|
133
|
+
errorMessage: "Console restart or lost process evidence; operation needs reconcile (not cancelled)",
|
|
134
|
+
});
|
|
135
|
+
changed.push(next);
|
|
136
|
+
}
|
|
137
|
+
return changed;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function isTerminalOperationState(state) {
|
|
141
|
+
return TERMINAL_OPERATION_STATES.has(state);
|
|
142
|
+
}
|
|
143
|
+
export function clipPreview(text, max = 4096) {
|
|
144
|
+
if (text.length <= max)
|
|
145
|
+
return text;
|
|
146
|
+
return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]`;
|
|
147
|
+
}
|