@mingchuno/agent-workflows 0.1.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/README.md +37 -6
- package/dist/src/adapters/agents.js +6 -3
- package/dist/src/adapters/hosting.js +16 -10
- package/dist/src/adapters/sdk-protocol.d.ts +3 -3
- package/dist/src/adapters/sdk-protocol.js +9 -7
- package/dist/src/attribution.d.ts +9 -0
- package/dist/src/attribution.js +57 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +80 -25
- package/dist/src/config.d.ts +24 -30
- package/dist/src/config.js +32 -27
- package/dist/src/defaults.d.ts +2 -0
- package/dist/src/defaults.js +2 -0
- package/dist/src/domain.d.ts +31 -4
- package/dist/src/domain.js +10 -3
- package/dist/src/evidence.d.ts +54 -0
- package/dist/src/evidence.js +214 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/invocation.d.ts +25 -0
- package/dist/src/invocation.js +166 -0
- package/dist/src/operations.d.ts +8 -2
- package/dist/src/operations.js +100 -136
- package/dist/src/prompts.d.ts +28 -0
- package/dist/src/prompts.js +63 -0
- package/dist/src/recovery.d.ts +19 -0
- package/dist/src/recovery.js +99 -0
- package/dist/src/runner.d.ts +7 -0
- package/dist/src/runner.js +170 -18
- package/dist/src/runtime/process.d.ts +2 -0
- package/dist/src/runtime/process.js +41 -12
- package/dist/src/store.d.ts +21 -2
- package/dist/src/store.js +122 -1
- package/dist/src/tui/actions.d.ts +16 -0
- package/dist/src/tui/actions.js +23 -0
- package/dist/src/tui/constants.d.ts +6 -0
- package/dist/src/tui/constants.js +3 -0
- package/dist/src/{tui-data.d.ts → tui/data.d.ts} +10 -7
- package/dist/src/tui/data.js +146 -0
- package/dist/src/tui/dialogs.d.ts +17 -0
- package/dist/src/tui/dialogs.js +149 -0
- package/dist/src/tui/format.d.ts +7 -0
- package/dist/src/tui/format.js +62 -0
- package/dist/src/tui/index.d.ts +3 -0
- package/dist/src/tui/index.js +2 -0
- package/dist/src/tui/layout.d.ts +25 -0
- package/dist/src/tui/layout.js +36 -0
- package/dist/src/tui/log-file.d.ts +26 -0
- package/dist/src/tui/log-file.js +156 -0
- package/dist/src/tui/log.d.ts +11 -0
- package/dist/src/tui/log.js +90 -0
- package/dist/src/tui/monitor.d.ts +10 -0
- package/dist/src/tui/monitor.js +284 -0
- package/dist/src/tui/notifications.d.ts +23 -0
- package/dist/src/tui/notifications.js +104 -0
- package/dist/src/tui/text.d.ts +3 -0
- package/dist/src/tui/text.js +10 -0
- package/dist/src/tui/use-log-controller.d.ts +27 -0
- package/dist/src/tui/use-log-controller.js +192 -0
- package/dist/src/tui/views.d.ts +25 -0
- package/dist/src/tui/views.js +327 -0
- package/dist/src/workspace.js +21 -8
- package/docs/api.md +132 -8
- package/docs/architecture.md +21 -4
- package/docs/configuration.md +181 -8
- package/docs/database.md +7 -0
- package/docs/operations.md +160 -5
- package/docs/providers.md +58 -2
- package/docs/releases.md +34 -79
- package/examples/config.ts +6 -6
- package/examples/run.ts +4 -1
- package/package.json +4 -2
- package/dist/src/tui-data.js +0 -89
- package/dist/src/tui.d.ts +0 -5
- package/dist/src/tui.js +0 -69
- package/docs/acceptance.md +0 -35
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { write as writeFileDescriptor } from "node:fs";
|
|
2
|
+
const terminalOutcomes = [
|
|
3
|
+
"completed",
|
|
4
|
+
"failed",
|
|
5
|
+
"blocked",
|
|
6
|
+
"cancelled",
|
|
7
|
+
"no-change",
|
|
8
|
+
"ineligible",
|
|
9
|
+
];
|
|
10
|
+
const terminalOutcomeSet = new Set(terminalOutcomes);
|
|
11
|
+
const whitespace = /\s+/gu;
|
|
12
|
+
const maximumPayloadBytes = 256;
|
|
13
|
+
const notificationPrefix = "agent-workflows: ";
|
|
14
|
+
export class ExecutionNotificationObserver {
|
|
15
|
+
writer;
|
|
16
|
+
seeded = false;
|
|
17
|
+
observedTerminalExecutionIds = new Set();
|
|
18
|
+
constructor(writer) {
|
|
19
|
+
this.writer = writer;
|
|
20
|
+
}
|
|
21
|
+
observe(runs) {
|
|
22
|
+
for (const run of runs) {
|
|
23
|
+
for (const execution of run.executions ?? []) {
|
|
24
|
+
if (!this.seeded) {
|
|
25
|
+
if (isTerminal(execution.outcome))
|
|
26
|
+
this.observedTerminalExecutionIds.add(execution.id);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (this.observedTerminalExecutionIds.has(execution.id) ||
|
|
30
|
+
!isTerminal(execution.outcome))
|
|
31
|
+
continue;
|
|
32
|
+
this.observedTerminalExecutionIds.add(execution.id);
|
|
33
|
+
try {
|
|
34
|
+
this.writer.notify({
|
|
35
|
+
project: run.projectId,
|
|
36
|
+
issue: `#${run.issue.number}`,
|
|
37
|
+
outcome: execution.outcome,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Execution notifications are advisory and never affect monitoring.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
this.seeded = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function createTerminalNotificationWriter({ write = writeTerminal, tmux = Boolean(process.env.TMUX), } = {}) {
|
|
49
|
+
return {
|
|
50
|
+
notify(notification) {
|
|
51
|
+
const suffix = ` · ${sanitize(notification.issue)} · ${notification.outcome}`;
|
|
52
|
+
const projectBytes = Math.max(0, maximumPayloadBytes -
|
|
53
|
+
Buffer.byteLength(notificationPrefix + suffix, "utf8"));
|
|
54
|
+
const message = `${notificationPrefix}${truncateUtf8(sanitize(notification.project), projectBytes)}${suffix}`;
|
|
55
|
+
const osc = `\u001b]9;${message}\u001b\\`;
|
|
56
|
+
const sequence = tmux
|
|
57
|
+
? `\u001bPtmux;${osc.replaceAll("\u001b", "\u001b\u001b")}\u001b\\`
|
|
58
|
+
: osc;
|
|
59
|
+
try {
|
|
60
|
+
const pending = write(sequence);
|
|
61
|
+
if (pending)
|
|
62
|
+
void pending.catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Execution notifications are advisory and never affect monitoring.
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function isTerminal(outcome) {
|
|
71
|
+
return terminalOutcomeSet.has(outcome);
|
|
72
|
+
}
|
|
73
|
+
function sanitize(value) {
|
|
74
|
+
return [...value]
|
|
75
|
+
.filter((character) => {
|
|
76
|
+
const codePoint = character.codePointAt(0);
|
|
77
|
+
return !(codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f));
|
|
78
|
+
})
|
|
79
|
+
.join("")
|
|
80
|
+
.replace(whitespace, " ")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
function writeTerminal(value) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
writeFileDescriptor(process.stdout.fd, value, (error) => {
|
|
86
|
+
if (error)
|
|
87
|
+
reject(error);
|
|
88
|
+
else
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function truncateUtf8(value, maximumBytes) {
|
|
94
|
+
let result = "";
|
|
95
|
+
let bytes = 0;
|
|
96
|
+
for (const character of value) {
|
|
97
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
98
|
+
if (bytes + characterBytes > maximumBytes)
|
|
99
|
+
break;
|
|
100
|
+
result += character;
|
|
101
|
+
bytes += characterBytes;
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
export function terminalText(text) {
|
|
3
|
+
return stripVTControlCharacters(text).replace(
|
|
4
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: remove control bytes from untrusted terminal output.
|
|
5
|
+
/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
6
|
+
}
|
|
7
|
+
/** Literal smart-case match, returning a UTF-16 offset for string slicing. */
|
|
8
|
+
export function matchIndex(text, query) {
|
|
9
|
+
return (query === query.toLowerCase() ? text.toLowerCase() : text).indexOf(query);
|
|
10
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Owned by one keyed log screen; unmount cancels polling and search. */
|
|
2
|
+
export declare function useLogController(path: string, columns: number, rows: number): {
|
|
3
|
+
height: number;
|
|
4
|
+
following: boolean;
|
|
5
|
+
horizontal: number;
|
|
6
|
+
raw: boolean;
|
|
7
|
+
lines: string[];
|
|
8
|
+
total: number;
|
|
9
|
+
message: string;
|
|
10
|
+
editing: boolean;
|
|
11
|
+
draft: string;
|
|
12
|
+
query: string;
|
|
13
|
+
searching: boolean;
|
|
14
|
+
position: number;
|
|
15
|
+
scroll: (delta: number) => void;
|
|
16
|
+
dismissSearch(): boolean;
|
|
17
|
+
beginSearch(): void;
|
|
18
|
+
applySearch(): void;
|
|
19
|
+
repeatSearch(direction: 1 | -1): void;
|
|
20
|
+
eraseDraft(): void;
|
|
21
|
+
appendDraft(input: string): void;
|
|
22
|
+
follow(): void;
|
|
23
|
+
toggleRaw(): void;
|
|
24
|
+
firstPage(): void;
|
|
25
|
+
lastPage(): void;
|
|
26
|
+
pan(delta: number): void;
|
|
27
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
import { screenChromeRows, tuiRefreshIntervalMs } from "./constants.js";
|
|
4
|
+
import { LogFile, presentLogLine } from "./log-file.js";
|
|
5
|
+
import { matchIndex, terminalText } from "./text.js";
|
|
6
|
+
/** Owned by one keyed log screen; unmount cancels polling and search. */
|
|
7
|
+
export function useLogController(path, columns, rows) {
|
|
8
|
+
const file = useMemo(() => new LogFile(path), [path]);
|
|
9
|
+
const height = Math.max(1, rows - screenChromeRows);
|
|
10
|
+
const [following, setFollowing] = useState(true);
|
|
11
|
+
const [top, setTop] = useState(0);
|
|
12
|
+
const [horizontal, setHorizontal] = useState(0);
|
|
13
|
+
const [raw, setRaw] = useState(false);
|
|
14
|
+
const [lines, setLines] = useState([]);
|
|
15
|
+
const [total, setTotal] = useState(0);
|
|
16
|
+
const [message, setMessage] = useState("Loading log…");
|
|
17
|
+
const [editing, setEditing] = useState(false);
|
|
18
|
+
const [draft, setDraft] = useState("");
|
|
19
|
+
const [query, setQuery] = useState("");
|
|
20
|
+
const [searching, setSearching] = useState(false);
|
|
21
|
+
const searchController = useRef(undefined);
|
|
22
|
+
const position = useRef(0);
|
|
23
|
+
useEffect(() => () => searchController.current?.abort(), []);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
let busy = false;
|
|
27
|
+
const update = async () => {
|
|
28
|
+
if (busy)
|
|
29
|
+
return;
|
|
30
|
+
busy = true;
|
|
31
|
+
try {
|
|
32
|
+
await file.refresh(controller.signal);
|
|
33
|
+
const nextTop = following
|
|
34
|
+
? Math.max(0, file.count - height)
|
|
35
|
+
: Math.min(top, Math.max(0, file.count - 1));
|
|
36
|
+
const page = await file.page(nextTop, height, controller.signal);
|
|
37
|
+
if (controller.signal.aborted)
|
|
38
|
+
return;
|
|
39
|
+
position.current = nextTop;
|
|
40
|
+
if (!following && nextTop !== top)
|
|
41
|
+
setTop(nextTop);
|
|
42
|
+
setTotal(file.count);
|
|
43
|
+
setLines(page);
|
|
44
|
+
setMessage((current) => !file.count
|
|
45
|
+
? "No output yet; waiting for log data"
|
|
46
|
+
: /^(Loading log|Log unavailable|No output yet)/.test(current)
|
|
47
|
+
? ""
|
|
48
|
+
: current);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!controller.signal.aborted) {
|
|
52
|
+
setLines([]);
|
|
53
|
+
setMessage(`Log unavailable: ${String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
busy = false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
void update();
|
|
61
|
+
const timer = setInterval(() => void update(), tuiRefreshIntervalMs);
|
|
62
|
+
return () => {
|
|
63
|
+
controller.abort();
|
|
64
|
+
clearInterval(timer);
|
|
65
|
+
};
|
|
66
|
+
}, [file, following, top, height]);
|
|
67
|
+
const search = async (value, direction, first = false) => {
|
|
68
|
+
if (!value)
|
|
69
|
+
return;
|
|
70
|
+
searchController.current?.abort();
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
searchController.current = controller;
|
|
73
|
+
setFollowing(false);
|
|
74
|
+
setQuery(value);
|
|
75
|
+
setSearching(true);
|
|
76
|
+
try {
|
|
77
|
+
const match = await file.search({
|
|
78
|
+
query: value,
|
|
79
|
+
from: first ? position.current - 1 : position.current,
|
|
80
|
+
direction,
|
|
81
|
+
raw,
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
if (controller.signal.aborted)
|
|
85
|
+
return;
|
|
86
|
+
if (match === undefined)
|
|
87
|
+
setMessage(`No matches: ${value}`);
|
|
88
|
+
else {
|
|
89
|
+
position.current = match;
|
|
90
|
+
setTop(match);
|
|
91
|
+
const [line] = await file.page(match, 1, controller.signal);
|
|
92
|
+
if (controller.signal.aborted)
|
|
93
|
+
return;
|
|
94
|
+
const text = presentLogLine(line ?? "", raw);
|
|
95
|
+
const at = matchIndex(text, value);
|
|
96
|
+
setHorizontal(stringWidth(text.slice(0, at + value.length)) > columns
|
|
97
|
+
? Math.max(0, stringWidth(text.slice(0, at)) - 8)
|
|
98
|
+
: 0);
|
|
99
|
+
setMessage(`Match on line ${match + 1} (wraps at file boundary)`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (!controller.signal.aborted)
|
|
104
|
+
setMessage(`Search failed: ${String(error)}`);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (!controller.signal.aborted)
|
|
108
|
+
setSearching(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const scroll = (delta) => {
|
|
112
|
+
setFollowing(false);
|
|
113
|
+
const next = Math.max(0, Math.min(Math.max(0, total - 1), position.current + delta));
|
|
114
|
+
position.current = next;
|
|
115
|
+
setTop(next);
|
|
116
|
+
};
|
|
117
|
+
const cancelSearch = () => {
|
|
118
|
+
searchController.current?.abort();
|
|
119
|
+
setSearching(false);
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
height,
|
|
123
|
+
following,
|
|
124
|
+
horizontal,
|
|
125
|
+
raw,
|
|
126
|
+
lines,
|
|
127
|
+
total,
|
|
128
|
+
message,
|
|
129
|
+
editing,
|
|
130
|
+
draft,
|
|
131
|
+
query,
|
|
132
|
+
searching,
|
|
133
|
+
position: position.current,
|
|
134
|
+
scroll,
|
|
135
|
+
dismissSearch() {
|
|
136
|
+
cancelSearch();
|
|
137
|
+
if (!editing && !query)
|
|
138
|
+
return false;
|
|
139
|
+
setEditing(false);
|
|
140
|
+
setQuery("");
|
|
141
|
+
setDraft("");
|
|
142
|
+
return true;
|
|
143
|
+
},
|
|
144
|
+
beginSearch() {
|
|
145
|
+
setFollowing(false);
|
|
146
|
+
setTop(position.current);
|
|
147
|
+
setDraft("");
|
|
148
|
+
setEditing(true);
|
|
149
|
+
},
|
|
150
|
+
applySearch() {
|
|
151
|
+
setEditing(false);
|
|
152
|
+
void search(draft, 1, true);
|
|
153
|
+
},
|
|
154
|
+
repeatSearch(direction) {
|
|
155
|
+
void search(query, direction);
|
|
156
|
+
},
|
|
157
|
+
eraseDraft() {
|
|
158
|
+
setDraft((value) => Array.from(value).slice(0, -1).join(""));
|
|
159
|
+
},
|
|
160
|
+
appendDraft(input) {
|
|
161
|
+
setDraft((value) => value +
|
|
162
|
+
terminalText(input)
|
|
163
|
+
.replaceAll("\n", "")
|
|
164
|
+
.replaceAll("\r", "")
|
|
165
|
+
.replaceAll("\t", ""));
|
|
166
|
+
},
|
|
167
|
+
follow() {
|
|
168
|
+
cancelSearch();
|
|
169
|
+
setQuery("");
|
|
170
|
+
setFollowing(true);
|
|
171
|
+
setHorizontal(0);
|
|
172
|
+
},
|
|
173
|
+
toggleRaw() {
|
|
174
|
+
cancelSearch();
|
|
175
|
+
setRaw(!raw);
|
|
176
|
+
setQuery("");
|
|
177
|
+
},
|
|
178
|
+
firstPage() {
|
|
179
|
+
setFollowing(false);
|
|
180
|
+
position.current = 0;
|
|
181
|
+
setTop(0);
|
|
182
|
+
},
|
|
183
|
+
lastPage() {
|
|
184
|
+
setFollowing(false);
|
|
185
|
+
position.current = Math.max(0, total - height);
|
|
186
|
+
setTop(position.current);
|
|
187
|
+
},
|
|
188
|
+
pan(delta) {
|
|
189
|
+
setHorizontal((value) => Math.max(0, value + delta));
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunRecord } from "../domain.js";
|
|
2
|
+
import type { EventRecord, InvocationRecord } from "../store.js";
|
|
3
|
+
type AvailableActions = {
|
|
4
|
+
stop: boolean;
|
|
5
|
+
retry: boolean;
|
|
6
|
+
recover: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function wrapDetailLines(lines: string[], width: number): string[];
|
|
9
|
+
export declare function summaryLines(run: RunRecord, now: number, event?: EventRecord): string[];
|
|
10
|
+
export declare function detailLines(run: RunRecord, sessions: InvocationRecord[], now: number, recoveryReason?: string | undefined, width?: number, available?: AvailableActions): string[];
|
|
11
|
+
export declare function Lines({ lines, width, height, offset, outcome, }: {
|
|
12
|
+
lines: string[];
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
offset?: number;
|
|
16
|
+
outcome?: string;
|
|
17
|
+
}): import("react").JSX.Element;
|
|
18
|
+
export declare function RunList({ runs, selected, width, height, now, }: {
|
|
19
|
+
runs: RunRecord[];
|
|
20
|
+
selected?: string;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
now: number;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
export {};
|