@saadnvd1/agent-os 0.3.457 → 0.3.460
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 +42 -5
- package/app/api/fresh-start/route.ts +19 -0
- package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
- package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
- package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/globals.css +10 -0
- package/components/Chat/ChatPanel.tsx +87 -4
- package/components/Chat/FindBar.tsx +113 -0
- package/components/Chat/Tools.tsx +1 -1
- package/components/Chat/find-highlight.test.ts +56 -0
- package/components/Chat/find-highlight.ts +78 -0
- package/components/Chat/rowState.tsx +44 -4
- package/components/Chat/stick-to-bottom.ts +7 -1
- package/components/Chat/useFind.ts +253 -0
- package/components/Pane/MobileTabBar.tsx +18 -0
- package/components/Pane/index.tsx +6 -0
- package/components/Workspaces/FreshStartForm.tsx +128 -0
- package/components/Workspaces/RulesForm.tsx +166 -0
- package/components/Workspaces/WorkspaceSettings.tsx +13 -1
- package/components/Workspaces/fresh-start.ts +19 -0
- package/data/chat/useChat.ts +29 -0
- package/data/fresh-start/index.ts +34 -0
- package/data/orchestrators/rules.ts +80 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/chat/demo.ts +1 -0
- package/lib/chat/events.ts +7 -1
- package/lib/chat/find.ts +278 -0
- package/lib/chat/refresh-limits.ts +5 -0
- package/lib/chat/refresh.ts +82 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/search.ts +117 -0
- package/lib/chat/socket.ts +12 -0
- package/lib/chat/worker/extras.ts +11 -1
- package/lib/chat/worker/host.ts +70 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +69 -0
- package/lib/db/types.ts +9 -0
- package/lib/orchestrator/brief.ts +16 -3
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +339 -0
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/presence-binding.ts +5 -0
- package/lib/orchestrator/rules.ts +124 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +10 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/watcher.ts +6 -2
- package/lib/orchestrator/worker.ts +5 -0
- package/lib/security/presence.ts +2 -1
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/pr-activity.ts +295 -0
- package/lib/tasks/state.ts +9 -1
- package/lib/workspaces.ts +8 -2
- package/package.json +1 -1
- package/styles/themes.css +5 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Reviews and comments on task PRs, read in two steps: a fingerprint of
|
|
2
|
+
// every watched PR in a repository from one GraphQL query (counts and the
|
|
3
|
+
// latest ids), and one PR's reviews and comments only when its fingerprint
|
|
4
|
+
// moved. Plus the part of a failed Actions job's log around its error.
|
|
5
|
+
|
|
6
|
+
import { redact } from "../orchestrator/untrusted";
|
|
7
|
+
import { run } from "./gh";
|
|
8
|
+
|
|
9
|
+
export interface Repo {
|
|
10
|
+
owner: string;
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const repoOfPR = (url: string): Repo | null => {
|
|
15
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(url);
|
|
16
|
+
return m ? { owner: m[1], name: m[2] } : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface Fingerprint {
|
|
20
|
+
// Changes whenever a review or a conversation comment is added.
|
|
21
|
+
print: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const graphql = async (
|
|
26
|
+
dir: string,
|
|
27
|
+
query: string,
|
|
28
|
+
vars: Record<string, string | number>
|
|
29
|
+
) => {
|
|
30
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
31
|
+
for (const [k, v] of Object.entries(vars))
|
|
32
|
+
args.push(typeof v === "number" ? "-F" : "-f", `${k}=${v}`);
|
|
33
|
+
return JSON.parse(await run("gh", args, dir, 30000)) as {
|
|
34
|
+
data?: { viewer?: { login: string }; repository?: Record<string, unknown> };
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type Last = { totalCount: number; nodes: { id: string }[] };
|
|
39
|
+
|
|
40
|
+
// One query for all the given PRs of a repository.
|
|
41
|
+
export async function fingerprints(
|
|
42
|
+
dir: string,
|
|
43
|
+
repo: Repo,
|
|
44
|
+
numbers: number[]
|
|
45
|
+
): Promise<Map<number, Fingerprint>> {
|
|
46
|
+
const prs = numbers.filter((n) => Number.isInteger(n) && n > 0);
|
|
47
|
+
const out = new Map<number, Fingerprint>();
|
|
48
|
+
if (!prs.length) return out;
|
|
49
|
+
const fields = prs
|
|
50
|
+
.map(
|
|
51
|
+
(n) =>
|
|
52
|
+
`p${n}: pullRequest(number: ${n}) { createdAt reviews(last: 1) { totalCount nodes { id } } comments(last: 1) { totalCount nodes { id } } }`
|
|
53
|
+
)
|
|
54
|
+
.join(" ");
|
|
55
|
+
const res = await graphql(
|
|
56
|
+
dir,
|
|
57
|
+
`query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`,
|
|
58
|
+
{ owner: repo.owner, name: repo.name }
|
|
59
|
+
);
|
|
60
|
+
for (const n of prs) {
|
|
61
|
+
const pr = res.data?.repository?.[`p${n}`] as
|
|
62
|
+
| { createdAt: string; reviews: Last; comments: Last }
|
|
63
|
+
| null
|
|
64
|
+
| undefined;
|
|
65
|
+
if (!pr) continue;
|
|
66
|
+
const part = (l: Last) => `${l.totalCount}:${l.nodes[0]?.id ?? ""}`;
|
|
67
|
+
out.set(n, {
|
|
68
|
+
print: `${part(pr.reviews)}/${part(pr.comments)}`,
|
|
69
|
+
createdAt: pr.createdAt,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PRNote {
|
|
76
|
+
path: string;
|
|
77
|
+
line: number | null;
|
|
78
|
+
body: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PREvent {
|
|
82
|
+
id: string;
|
|
83
|
+
kind: "review" | "comment";
|
|
84
|
+
// A review's: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING.
|
|
85
|
+
state: string | null;
|
|
86
|
+
author: string;
|
|
87
|
+
// GitHub's word on the author: OWNER, MEMBER, COLLABORATOR, NONE...
|
|
88
|
+
association: string;
|
|
89
|
+
at: string;
|
|
90
|
+
body: string;
|
|
91
|
+
// A review's inline comments.
|
|
92
|
+
notes: PRNote[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PRActivity {
|
|
96
|
+
// Who gh is signed in as: the task's own comments are posted as this.
|
|
97
|
+
viewer: string;
|
|
98
|
+
events: PREvent[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type Author = { login: string } | null;
|
|
102
|
+
|
|
103
|
+
// The latest reviews and conversation comments on one PR.
|
|
104
|
+
export async function prActivity(
|
|
105
|
+
dir: string,
|
|
106
|
+
repo: Repo,
|
|
107
|
+
number: number
|
|
108
|
+
): Promise<PRActivity> {
|
|
109
|
+
const res = await graphql(
|
|
110
|
+
dir,
|
|
111
|
+
`
|
|
112
|
+
query ($owner: String!, $name: String!, $number: Int!) {
|
|
113
|
+
viewer {
|
|
114
|
+
login
|
|
115
|
+
}
|
|
116
|
+
repository(owner: $owner, name: $name) {
|
|
117
|
+
pullRequest(number: $number) {
|
|
118
|
+
reviews(last: 30) {
|
|
119
|
+
nodes {
|
|
120
|
+
id
|
|
121
|
+
state
|
|
122
|
+
body
|
|
123
|
+
submittedAt
|
|
124
|
+
authorAssociation
|
|
125
|
+
author {
|
|
126
|
+
login
|
|
127
|
+
}
|
|
128
|
+
comments(first: 20) {
|
|
129
|
+
nodes {
|
|
130
|
+
path
|
|
131
|
+
line
|
|
132
|
+
originalLine
|
|
133
|
+
body
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
comments(last: 30) {
|
|
139
|
+
nodes {
|
|
140
|
+
id
|
|
141
|
+
body
|
|
142
|
+
createdAt
|
|
143
|
+
authorAssociation
|
|
144
|
+
author {
|
|
145
|
+
login
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
`,
|
|
153
|
+
{ owner: repo.owner, name: repo.name, number }
|
|
154
|
+
);
|
|
155
|
+
const pr = res.data?.repository?.pullRequest as
|
|
156
|
+
| {
|
|
157
|
+
reviews: {
|
|
158
|
+
nodes: {
|
|
159
|
+
id: string;
|
|
160
|
+
state: string;
|
|
161
|
+
body: string;
|
|
162
|
+
submittedAt: string | null;
|
|
163
|
+
authorAssociation: string;
|
|
164
|
+
author: Author;
|
|
165
|
+
comments: {
|
|
166
|
+
nodes: {
|
|
167
|
+
path: string;
|
|
168
|
+
line: number | null;
|
|
169
|
+
originalLine: number | null;
|
|
170
|
+
body: string;
|
|
171
|
+
}[];
|
|
172
|
+
};
|
|
173
|
+
}[];
|
|
174
|
+
};
|
|
175
|
+
comments: {
|
|
176
|
+
nodes: {
|
|
177
|
+
id: string;
|
|
178
|
+
body: string;
|
|
179
|
+
createdAt: string;
|
|
180
|
+
authorAssociation: string;
|
|
181
|
+
author: Author;
|
|
182
|
+
}[];
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
| null
|
|
186
|
+
| undefined;
|
|
187
|
+
if (!pr || !res.data?.viewer) throw new Error(`PR #${number} not found`);
|
|
188
|
+
return {
|
|
189
|
+
viewer: res.data.viewer.login,
|
|
190
|
+
events: [
|
|
191
|
+
...pr.reviews.nodes
|
|
192
|
+
.filter((r) => r.submittedAt)
|
|
193
|
+
.map((r) => ({
|
|
194
|
+
id: r.id,
|
|
195
|
+
kind: "review" as const,
|
|
196
|
+
state: r.state,
|
|
197
|
+
author: r.author?.login ?? "ghost",
|
|
198
|
+
association: r.authorAssociation,
|
|
199
|
+
at: r.submittedAt!,
|
|
200
|
+
body: r.body ?? "",
|
|
201
|
+
notes: r.comments.nodes.map((c) => ({
|
|
202
|
+
path: c.path,
|
|
203
|
+
line: c.line ?? c.originalLine,
|
|
204
|
+
body: c.body ?? "",
|
|
205
|
+
})),
|
|
206
|
+
})),
|
|
207
|
+
...pr.comments.nodes.map((c) => ({
|
|
208
|
+
id: c.id,
|
|
209
|
+
kind: "comment" as const,
|
|
210
|
+
state: null,
|
|
211
|
+
author: c.author?.login ?? "ghost",
|
|
212
|
+
association: c.authorAssociation,
|
|
213
|
+
at: c.createdAt,
|
|
214
|
+
body: c.body ?? "",
|
|
215
|
+
notes: [],
|
|
216
|
+
})),
|
|
217
|
+
].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const LOG_LINES = 60;
|
|
222
|
+
const LINE_MAX = 240;
|
|
223
|
+
// The line a failure is reported on; the runner's own last line isn't it.
|
|
224
|
+
const ERROR =
|
|
225
|
+
/##\[error\]|\berror\b|\bfail(ed|ure|s)?\b|\bFAIL\b|✗|×|AssertionError|panic:/i;
|
|
226
|
+
const RUNNER_EXIT = /Process completed with exit code/;
|
|
227
|
+
|
|
228
|
+
export interface FailedLog {
|
|
229
|
+
step: string | null;
|
|
230
|
+
tail: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// `gh run view --log-failed` lines are "job<TAB>step<TAB>time text". Keeps
|
|
234
|
+
// LOG_LINES lines from a little before the first error, without timestamps,
|
|
235
|
+
// colour codes or anything token-shaped.
|
|
236
|
+
export function trimLog(raw: string, max = LOG_LINES): FailedLog {
|
|
237
|
+
const rows = raw
|
|
238
|
+
.split("\n")
|
|
239
|
+
.filter((l) => l.trim())
|
|
240
|
+
.map((l) => {
|
|
241
|
+
const parts = l.split("\t");
|
|
242
|
+
const step = parts.length >= 3 ? parts[1] : null;
|
|
243
|
+
const text = (parts.length >= 3 ? parts.slice(2).join("\t") : l)
|
|
244
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
|
|
245
|
+
.replace(/^?\d{4}-\d\d-\d\dT[\d:.]+Z ?/, "");
|
|
246
|
+
return {
|
|
247
|
+
step,
|
|
248
|
+
text: text.length > LINE_MAX ? `${text.slice(0, LINE_MAX)}…` : text,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
if (!rows.length) return { step: null, tail: "" };
|
|
252
|
+
const hit = rows.findIndex(
|
|
253
|
+
(r) => ERROR.test(r.text) && !RUNNER_EXIT.test(r.text)
|
|
254
|
+
);
|
|
255
|
+
const anchor = hit >= 0 ? hit : rows.length - 1;
|
|
256
|
+
const start =
|
|
257
|
+
hit >= 0
|
|
258
|
+
? Math.max(0, Math.min(hit - Math.floor(max / 3), rows.length - max))
|
|
259
|
+
: Math.max(0, rows.length - max);
|
|
260
|
+
const kept = rows.slice(start, start + max);
|
|
261
|
+
return {
|
|
262
|
+
step: rows[anchor].step,
|
|
263
|
+
tail: redact(kept.map((r) => r.text).join("\n")),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The Actions job a check's details URL points at.
|
|
268
|
+
export const jobOf = (url: string | null | undefined) =>
|
|
269
|
+
url ? (/\/actions\/runs\/\d+\/job\/(\d+)/.exec(url)?.[1] ?? null) : null;
|
|
270
|
+
|
|
271
|
+
// The failed steps' log of an Actions job, trimmed; null for a check that
|
|
272
|
+
// isn't an Actions job.
|
|
273
|
+
export async function failedLog(
|
|
274
|
+
dir: string,
|
|
275
|
+
repo: Repo,
|
|
276
|
+
url: string | null | undefined
|
|
277
|
+
): Promise<FailedLog | null> {
|
|
278
|
+
const job = jobOf(url);
|
|
279
|
+
if (!job) return null;
|
|
280
|
+
const raw = await run(
|
|
281
|
+
"gh",
|
|
282
|
+
[
|
|
283
|
+
"run",
|
|
284
|
+
"view",
|
|
285
|
+
"--repo",
|
|
286
|
+
`${repo.owner}/${repo.name}`,
|
|
287
|
+
"--job",
|
|
288
|
+
job,
|
|
289
|
+
"--log-failed",
|
|
290
|
+
],
|
|
291
|
+
dir,
|
|
292
|
+
60000
|
|
293
|
+
);
|
|
294
|
+
return trimLog(raw);
|
|
295
|
+
}
|
package/lib/tasks/state.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface TaskPR {
|
|
|
18
18
|
// The head commit the checks ran on, and the first failing check's name.
|
|
19
19
|
head?: string;
|
|
20
20
|
failing?: string | null;
|
|
21
|
+
// Where that check's run is (an Actions job URL for a workflow check).
|
|
22
|
+
failingUrl?: string | null;
|
|
21
23
|
// How many checks are registered on the head.
|
|
22
24
|
checkCount?: number;
|
|
23
25
|
// The body's Code review section: null when it has none, undefined
|
|
@@ -340,11 +342,17 @@ export function blockedReason(paneTail: string): string | null {
|
|
|
340
342
|
}
|
|
341
343
|
|
|
342
344
|
// The first failing check in gh's statusCheckRollup, by name.
|
|
345
|
+
const firstFailing = (rollup: RollupEntry[]) =>
|
|
346
|
+
liveChecks(rollup).find((r) => FAILED.includes(outcome(r)));
|
|
347
|
+
|
|
343
348
|
export function failingCheck(rollup: RollupEntry[]): string | null {
|
|
344
|
-
const c =
|
|
349
|
+
const c = firstFailing(rollup);
|
|
345
350
|
return c ? (c.name ?? c.context ?? null) : null;
|
|
346
351
|
}
|
|
347
352
|
|
|
353
|
+
export const failingUrl = (rollup: RollupEntry[]): string | null =>
|
|
354
|
+
firstFailing(rollup)?.detailsUrl ?? null;
|
|
355
|
+
|
|
348
356
|
// Keys that answer Claude's folder-trust prompt with "trust", or null when the
|
|
349
357
|
// pane isn't showing it. The selection marker starts on "No, exit".
|
|
350
358
|
export function trustPromptKeys(pane: string): string[] | null {
|
package/lib/workspaces.ts
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
7
|
import { db, type Workspace } from "./db";
|
|
8
8
|
|
|
9
|
-
type WorkspaceRow = Omit<Workspace, "collapsed"> & {
|
|
9
|
+
type WorkspaceRow = Omit<Workspace, "collapsed" | "wake_tasks_on_pr"> & {
|
|
10
|
+
collapsed: number;
|
|
11
|
+
wake_tasks_on_pr: number;
|
|
12
|
+
};
|
|
10
13
|
|
|
11
14
|
const toWorkspace = (row: WorkspaceRow): Workspace => ({
|
|
12
15
|
...row,
|
|
13
16
|
collapsed: !!row.collapsed,
|
|
17
|
+
wake_tasks_on_pr: !!row.wake_tasks_on_pr,
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
export function listWorkspaces(): Workspace[] {
|
|
@@ -57,18 +61,20 @@ export function updateWorkspace(
|
|
|
57
61
|
collapsed?: boolean;
|
|
58
62
|
sortOrder?: number;
|
|
59
63
|
maxRunningTasks?: number | null;
|
|
64
|
+
wakeTasksOnPR?: boolean;
|
|
60
65
|
}
|
|
61
66
|
): Workspace | null {
|
|
62
67
|
const existing = getWorkspace(id);
|
|
63
68
|
if (!existing) return null;
|
|
64
69
|
const name = updates.name?.trim() || existing.name;
|
|
65
70
|
db.prepare(
|
|
66
|
-
`UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ? WHERE id = ?`
|
|
71
|
+
`UPDATE workspaces SET name = ?, collapsed = ?, sort_order = ?, max_running_tasks = ?, wake_tasks_on_pr = ? WHERE id = ?`
|
|
67
72
|
).run(
|
|
68
73
|
name,
|
|
69
74
|
(updates.collapsed ?? existing.collapsed) ? 1 : 0,
|
|
70
75
|
updates.sortOrder ?? existing.sort_order,
|
|
71
76
|
taskLimit(updates.maxRunningTasks, existing.max_running_tasks),
|
|
77
|
+
(updates.wakeTasksOnPR ?? existing.wake_tasks_on_pr) ? 1 : 0,
|
|
72
78
|
id
|
|
73
79
|
);
|
|
74
80
|
return getWorkspace(id);
|
package/package.json
CHANGED
package/styles/themes.css
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
Cool white with a violet accent
|
|
25
25
|
=========================================================================== */
|
|
26
26
|
:root {
|
|
27
|
+
/* Find in a conversation: every match, and the one it's on. */
|
|
28
|
+
--find-match: 48 96% 53% / 0.45;
|
|
29
|
+
--find-current: 25 95% 53%;
|
|
30
|
+
--find-current-foreground: 0 0% 0%;
|
|
27
31
|
--background: 240 10% 95.5%;
|
|
28
32
|
--foreground: 240 12% 14%;
|
|
29
33
|
--card: 0 0% 100%;
|
|
@@ -287,6 +291,7 @@
|
|
|
287
291
|
Blue-black with a violet accent
|
|
288
292
|
=========================================================================== */
|
|
289
293
|
.dark {
|
|
294
|
+
--find-match: 48 96% 53% / 0.35;
|
|
290
295
|
--background: 240 18% 3.5%;
|
|
291
296
|
--foreground: 240 10% 93%;
|
|
292
297
|
--card: 240 14% 6%;
|