@saadnvd1/agent-os 0.3.584 → 0.3.587
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 +1 -0
- package/app/api/bookmarks/[id]/route.ts +35 -0
- package/app/api/bookmarks/route.test.ts +103 -0
- package/app/api/bookmarks/route.ts +18 -0
- package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
- package/app/api/tasks/[id]/merge/route.ts +5 -1
- package/app/globals.css +1 -0
- package/app/page.tsx +16 -0
- package/components/Bookmarks/BookmarkButton.tsx +149 -0
- package/components/Bookmarks/BookmarkRow.tsx +161 -0
- package/components/Bookmarks/BookmarksDialog.tsx +186 -0
- package/components/Bookmarks/ChatBookmarks.tsx +58 -0
- package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
- package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
- package/components/Bookmarks/context.ts +41 -0
- package/components/Bookmarks/helpers.test.ts +69 -0
- package/components/Bookmarks/helpers.ts +65 -0
- package/components/Bookmarks/index.ts +4 -0
- package/components/Bookmarks/useBookmarkToggle.ts +51 -0
- package/components/Chat/ChatPanel.tsx +73 -32
- package/components/Chat/Messages.test.ts +14 -9
- package/components/Chat/Messages.tsx +59 -43
- package/components/Chat/Tools.tsx +3 -1
- package/components/Chat/useLongPress.ts +43 -0
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Pane/DesktopTabBar.tsx +10 -0
- package/components/Pane/MobileTabBar.tsx +10 -0
- package/components/Pane/index.tsx +8 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/data/bookmarks/index.ts +2 -0
- package/data/bookmarks/keys.ts +7 -0
- package/data/bookmarks/queries.ts +146 -0
- package/data/chat/useChat.ts +4 -0
- package/data/push/topics.ts +3 -0
- package/lib/bookmarks/body.ts +23 -0
- package/lib/bookmarks/snapshot.ts +33 -0
- package/lib/bookmarks/store.ts +333 -0
- package/lib/bookmarks/types.ts +38 -0
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +28 -0
- package/lib/orchestrator/act.ts +47 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/serve.ts +8 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +22 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/tasks/finish.ts +26 -4
- package/lib/tasks/move-bundle.ts +28 -0
- package/lib/tasks/move-chat.ts +10 -1
- package/lib/tasks/queue.ts +133 -10
- package/lib/tasks/remote.ts +21 -3
- package/package.json +1 -1
- package/stores/bookmarksUi.ts +50 -0
- package/styles/themes.css +3 -0
package/lib/tasks/finish.ts
CHANGED
|
@@ -24,7 +24,13 @@ import {
|
|
|
24
24
|
import { mergePR } from "./merge-pr";
|
|
25
25
|
import { getProject } from "../projects";
|
|
26
26
|
import { canSignOff } from "./state";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
forgetPR,
|
|
29
|
+
getTaskSession,
|
|
30
|
+
isAllowedBase,
|
|
31
|
+
prFor,
|
|
32
|
+
projectPathFor,
|
|
33
|
+
} from "./session";
|
|
28
34
|
import { dropSessionDatabase } from "../project-config/database";
|
|
29
35
|
import { captureMergedTask } from "../session-logs";
|
|
30
36
|
import { sessionEnded } from "../experiments/hooks";
|
|
@@ -114,7 +120,13 @@ async function finishMerge(
|
|
|
114
120
|
// machine merged it and didn't say).
|
|
115
121
|
export async function signOffTask(
|
|
116
122
|
id: string,
|
|
117
|
-
opts: {
|
|
123
|
+
opts: {
|
|
124
|
+
wait?: boolean;
|
|
125
|
+
head?: string;
|
|
126
|
+
device?: string | null;
|
|
127
|
+
// The base another machine judged the gates against (a forwarded merge).
|
|
128
|
+
base?: string;
|
|
129
|
+
} = {}
|
|
118
130
|
): Promise<MergeMethod | null> {
|
|
119
131
|
const session = getTaskSession(id);
|
|
120
132
|
if (session.task_status !== "running")
|
|
@@ -124,14 +136,24 @@ export async function signOffTask(
|
|
|
124
136
|
if (held) throw new Error(held);
|
|
125
137
|
// Its own machine checks the gates and merges.
|
|
126
138
|
// That machine's settings chose the method; null if it didn't say.
|
|
127
|
-
if (isMirror(session))
|
|
139
|
+
if (isMirror(session))
|
|
140
|
+
return remoteTaskAction(session, "merge", opts.head, session.base_branch);
|
|
128
141
|
const repo = projectPathFor(session);
|
|
129
142
|
if (!repo) throw new Error("Task has no project");
|
|
130
143
|
const refusal = signOffRefusal(id);
|
|
131
144
|
if (refusal) throw new Error(refusal);
|
|
132
145
|
// prFor follows a retarget onto the record, so compare with what the
|
|
133
146
|
// checks above were judged against.
|
|
134
|
-
|
|
147
|
+
// A forwarded merge was judged on the other machine against the base it
|
|
148
|
+
// recorded, which may be newer than this machine's row; only a base this
|
|
149
|
+
// task may have is taken.
|
|
150
|
+
if (
|
|
151
|
+
opts.base &&
|
|
152
|
+
opts.base !== session.base_branch &&
|
|
153
|
+
!(await isAllowedBase(session, repo, opts.base))
|
|
154
|
+
)
|
|
155
|
+
throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
|
|
156
|
+
const judgedBase = opts.base ?? session.base_branch;
|
|
135
157
|
const pr = await prFor(session, true);
|
|
136
158
|
const verdict = canSignOff(pr);
|
|
137
159
|
if (!verdict.ok) throw new Error(verdict.reason);
|
package/lib/tasks/move-bundle.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { isBranchName } from "../git";
|
|
7
7
|
import { CHAT_ACCESS, type ChatAccess } from "../chat/events";
|
|
8
8
|
import type { ProjectRef } from "./project-ref";
|
|
9
|
+
import type { MovedBookmark } from "../bookmarks/store";
|
|
10
|
+
import { MAX_NOTE_CHARS, MAX_SNAPSHOT_CHARS } from "../bookmarks/types";
|
|
9
11
|
import { isClaudeSessionId } from "./transcript";
|
|
10
12
|
|
|
11
13
|
// A long task's conversation runs to a few MB; this is far past that.
|
|
@@ -92,11 +94,17 @@ export interface ChatBundle {
|
|
|
92
94
|
// An undo's resume point not yet used up, and the context meter.
|
|
93
95
|
resumeAt: string | null;
|
|
94
96
|
context: string | null;
|
|
97
|
+
// Its bookmarks; absent from a machine that predates them.
|
|
98
|
+
bookmarks?: MovedBookmark[];
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
const isStr = (v: unknown): v is string => typeof v === "string";
|
|
98
102
|
const isStrOrNull = (v: unknown) => v === null || isStr(v);
|
|
99
103
|
|
|
104
|
+
const BOOKMARK_KINDS = ["user", "assistant", "tool", "turn"];
|
|
105
|
+
// Far past what a person stars in one chat.
|
|
106
|
+
export const MAX_MOVED_BOOKMARKS = 2000;
|
|
107
|
+
|
|
100
108
|
function validateChat(c: ChatBundle): ChatBundle {
|
|
101
109
|
if (!c || typeof c !== "object" || !Array.isArray(c.items))
|
|
102
110
|
throw new Error("Bad chat");
|
|
@@ -119,6 +127,26 @@ function validateChat(c: ChatBundle): ChatBundle {
|
|
|
119
127
|
throw new Error("Bad queued message");
|
|
120
128
|
bytes += m.id.length + m.text.length + (m.images?.length ?? 0);
|
|
121
129
|
}
|
|
130
|
+
if (c.bookmarks !== undefined && !Array.isArray(c.bookmarks))
|
|
131
|
+
throw new Error("Bad chat bookmarks");
|
|
132
|
+
if ((c.bookmarks?.length ?? 0) > MAX_MOVED_BOOKMARKS)
|
|
133
|
+
throw new Error("Too many bookmarks to move");
|
|
134
|
+
for (const b of c.bookmarks ?? []) {
|
|
135
|
+
if (
|
|
136
|
+
!b ||
|
|
137
|
+
!isStr(b.itemId) ||
|
|
138
|
+
b.itemId.length > 200 ||
|
|
139
|
+
!(b.scope === "item" || b.scope === "turn") ||
|
|
140
|
+
!BOOKMARK_KINDS.includes(b.kind) ||
|
|
141
|
+
!isStr(b.text) ||
|
|
142
|
+
b.text.length > MAX_SNAPSHOT_CHARS + 1 ||
|
|
143
|
+
!isStrOrNull(b.note) ||
|
|
144
|
+
(b.note?.length ?? 0) > MAX_NOTE_CHARS ||
|
|
145
|
+
!isStr(b.createdAt)
|
|
146
|
+
)
|
|
147
|
+
throw new Error("Bad bookmark");
|
|
148
|
+
bytes += b.itemId.length + b.text.length + (b.note?.length ?? 0);
|
|
149
|
+
}
|
|
122
150
|
if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
|
|
123
151
|
if (!isStrOrNull(c.resumeAt) || !isStrOrNull(c.context))
|
|
124
152
|
throw new Error("Bad chat");
|
package/lib/tasks/move-chat.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { settle, listItems } from "../chat/store";
|
|
|
11
11
|
import { stopChatAtTurnEnd } from "../chat/stop";
|
|
12
12
|
import { rewritePaths } from "./transcript";
|
|
13
13
|
import { MAX_CHAT_BYTES, type ChatBundle } from "./move-bundle";
|
|
14
|
+
import { bookmarksForMove, insertMovedBookmarks } from "../bookmarks/store";
|
|
14
15
|
|
|
15
16
|
// A turn this long is left to finish: the move is refused, not cut short.
|
|
16
17
|
export const MOVE_TURN_WAIT_MS = 3 * 60_000;
|
|
@@ -50,6 +51,7 @@ export function packChat(session: Session): ChatBundle {
|
|
|
50
51
|
sentBy: r.sent_by,
|
|
51
52
|
createdAt: r.created_at,
|
|
52
53
|
}));
|
|
54
|
+
const bookmarks = bookmarksForMove(session.id);
|
|
53
55
|
const chat: ChatBundle = {
|
|
54
56
|
items,
|
|
55
57
|
queue,
|
|
@@ -57,10 +59,15 @@ export function packChat(session: Session): ChatBundle {
|
|
|
57
59
|
plan: !!session.chat_plan,
|
|
58
60
|
resumeAt: session.chat_resume_at,
|
|
59
61
|
context: session.chat_context,
|
|
62
|
+
bookmarks,
|
|
60
63
|
};
|
|
61
64
|
const bytes =
|
|
62
65
|
items.reduce((n, i) => n + i.id.length + i.data.length, 0) +
|
|
63
|
-
queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0)
|
|
66
|
+
queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0) +
|
|
67
|
+
bookmarks.reduce(
|
|
68
|
+
(n, b) => n + b.itemId.length + b.text.length + (b.note?.length ?? 0),
|
|
69
|
+
0
|
|
70
|
+
);
|
|
64
71
|
if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
|
|
65
72
|
return chat;
|
|
66
73
|
}
|
|
@@ -103,6 +110,7 @@ export function unpackChat(
|
|
|
103
110
|
m.createdAt
|
|
104
111
|
)
|
|
105
112
|
);
|
|
113
|
+
insertMovedBookmarks(id, chat.bookmarks ?? []);
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
const imageCount = (images: string) => {
|
|
@@ -118,4 +126,5 @@ const imageCount = (images: string) => {
|
|
|
118
126
|
export function dropChat(id: string): void {
|
|
119
127
|
db.prepare(`DELETE FROM chat_items WHERE session_id = ?`).run(id);
|
|
120
128
|
db.prepare(`DELETE FROM chat_queue WHERE session_id = ?`).run(id);
|
|
129
|
+
db.prepare(`DELETE FROM chat_bookmarks WHERE session_id = ?`).run(id);
|
|
121
130
|
}
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -312,6 +312,26 @@ export interface QueueRequest {
|
|
|
312
312
|
priority?: Priority;
|
|
313
313
|
// "mac" pins it to a Mac; "any" says it needs nothing in particular.
|
|
314
314
|
machine?: "any" | "mac";
|
|
315
|
+
// Start at once, past the running-task limit, instead of queueing for a
|
|
316
|
+
// slot. Still needs the machine it needs, and an orchestrator's start
|
|
317
|
+
// still passes its brakes.
|
|
318
|
+
now?: boolean;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// How full the workspace's running-task limit is for this project.
|
|
322
|
+
export function slotState(projectId: string): {
|
|
323
|
+
running: number;
|
|
324
|
+
limit: number | null;
|
|
325
|
+
full: boolean;
|
|
326
|
+
} {
|
|
327
|
+
const workspaceId = workspaceOf(projectId);
|
|
328
|
+
const limit = limitOf(workspaceId);
|
|
329
|
+
if (!workspaceId || limit === null)
|
|
330
|
+
return { running: 0, limit: null, full: false };
|
|
331
|
+
const running =
|
|
332
|
+
runningIn({ workspaceId, projectId }, true).length +
|
|
333
|
+
startingIn(workspaceId);
|
|
334
|
+
return { running, limit, full: running >= limit };
|
|
315
335
|
}
|
|
316
336
|
|
|
317
337
|
const waitingIn = (workspaceId: string) =>
|
|
@@ -349,8 +369,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
349
369
|
}
|
|
350
370
|
} else if (after)
|
|
351
371
|
afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
|
|
372
|
+
if (req.now && waits)
|
|
373
|
+
throw new Error("now and after don't go together: now starts it at once");
|
|
352
374
|
const full =
|
|
353
375
|
!req.planOnly &&
|
|
376
|
+
!req.now &&
|
|
354
377
|
limit !== null &&
|
|
355
378
|
!!workspaceId &&
|
|
356
379
|
(runningIn({ workspaceId, projectId: project.id }, true).length +
|
|
@@ -364,9 +387,18 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
364
387
|
throw new Error(`Machine is "any" or "mac"`);
|
|
365
388
|
// The orchestrator's own starts wait out a loaded machine; a person's
|
|
366
389
|
// direct start goes ahead, as "start now" always has.
|
|
390
|
+
if (
|
|
391
|
+
req.now &&
|
|
392
|
+
!req.planOnly &&
|
|
393
|
+
needsUnmetHere(project.id, req.prompt, setting)
|
|
394
|
+
)
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Can't start now: this task needs a Mac, and this machine isn't one. Queue it without now.`
|
|
397
|
+
);
|
|
367
398
|
const loaded =
|
|
368
399
|
!waits &&
|
|
369
400
|
!full &&
|
|
401
|
+
!req.now &&
|
|
370
402
|
!req.planOnly &&
|
|
371
403
|
!!req.originWorkspaceId &&
|
|
372
404
|
loadHeldStart(project.id, req.prompt, setting);
|
|
@@ -426,7 +458,8 @@ export async function startOrQueue<T>(
|
|
|
426
458
|
const queued = queueIfNeeded(req);
|
|
427
459
|
if (queued) return { queued };
|
|
428
460
|
const workspaceId = workspaceOf(req.projectId);
|
|
429
|
-
|
|
461
|
+
// A plan-only start never holds a slot, as its queue row doesn't.
|
|
462
|
+
if (!workspaceId || req.planOnly) return { started: await start() };
|
|
430
463
|
reserved.set(workspaceId, (reserved.get(workspaceId) ?? 0) + 1);
|
|
431
464
|
try {
|
|
432
465
|
return { started: await start() };
|
|
@@ -539,6 +572,33 @@ function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
|
|
|
539
572
|
return window ? { code: "window", text: `Waiting: ${window}` } : null;
|
|
540
573
|
}
|
|
541
574
|
|
|
575
|
+
const requestNeeds = (
|
|
576
|
+
projectId: string,
|
|
577
|
+
workingDirectory: string,
|
|
578
|
+
prompt: string,
|
|
579
|
+
setting: Need[] | null
|
|
580
|
+
): Need[] =>
|
|
581
|
+
setting ??
|
|
582
|
+
needsOf({
|
|
583
|
+
projectId,
|
|
584
|
+
projectPath: expandHome(workingDirectory),
|
|
585
|
+
prompt,
|
|
586
|
+
}).needs;
|
|
587
|
+
|
|
588
|
+
// Whether the task needs something this machine lacks (a Mac).
|
|
589
|
+
function needsUnmetHere(
|
|
590
|
+
projectId: string,
|
|
591
|
+
prompt: string,
|
|
592
|
+
setting: Need[] | null
|
|
593
|
+
): boolean {
|
|
594
|
+
const project = getProject(projectId);
|
|
595
|
+
if (!project || isRemoteHost(project.host_id)) return false;
|
|
596
|
+
return !meetsNeeds(
|
|
597
|
+
requestNeeds(projectId, project.working_directory, prompt, setting),
|
|
598
|
+
machineCapabilities()
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
542
602
|
// Whether this machine's load rules out starting a task for this project now.
|
|
543
603
|
function loadHeldStart(
|
|
544
604
|
projectId: string,
|
|
@@ -547,13 +607,12 @@ function loadHeldStart(
|
|
|
547
607
|
): boolean {
|
|
548
608
|
const project = getProject(projectId);
|
|
549
609
|
if (!project || isRemoteHost(project.host_id)) return false;
|
|
550
|
-
const needs =
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}).needs;
|
|
610
|
+
const needs = requestNeeds(
|
|
611
|
+
projectId,
|
|
612
|
+
project.working_directory,
|
|
613
|
+
prompt,
|
|
614
|
+
setting
|
|
615
|
+
);
|
|
557
616
|
const hold = machineHold(
|
|
558
617
|
{
|
|
559
618
|
heavy: heavyNeeds(needs),
|
|
@@ -593,6 +652,8 @@ interface StartOpts {
|
|
|
593
652
|
// Start on this linked machine instead of the project's.
|
|
594
653
|
placement?: Placement | null;
|
|
595
654
|
heavy?: boolean;
|
|
655
|
+
// Pass this workspace's brakes even for a row no orchestrator queued.
|
|
656
|
+
brakeAs?: string;
|
|
596
657
|
}
|
|
597
658
|
|
|
598
659
|
async function startRow(
|
|
@@ -683,9 +744,10 @@ async function startRow(
|
|
|
683
744
|
try {
|
|
684
745
|
// The orchestrator's queued starts pass its brakes when they start,
|
|
685
746
|
// a person's "start now" included: it skips only the limit and `after`.
|
|
686
|
-
const
|
|
747
|
+
const brakeWs = origin ?? opts.brakeAs;
|
|
748
|
+
const session = brakeWs
|
|
687
749
|
? await braked(
|
|
688
|
-
|
|
750
|
+
brakeWs,
|
|
689
751
|
"task",
|
|
690
752
|
() => createTask(task),
|
|
691
753
|
(s) => s.id,
|
|
@@ -1090,6 +1152,67 @@ export async function startQueuedNow(id: string): Promise<StartOutcome> {
|
|
|
1090
1152
|
return startRow(r, { force: true });
|
|
1091
1153
|
}
|
|
1092
1154
|
|
|
1155
|
+
export interface ForcedStart {
|
|
1156
|
+
outcome: "started" | "held" | "failed";
|
|
1157
|
+
// Why it is held, or what failed; empty when it started.
|
|
1158
|
+
text: string;
|
|
1159
|
+
// The running-task limit was full when it started.
|
|
1160
|
+
wasFull: boolean;
|
|
1161
|
+
running: number;
|
|
1162
|
+
limit: number | null;
|
|
1163
|
+
// It skipped an `after`.
|
|
1164
|
+
skippedAfter: boolean;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// The orchestrator's start now: past the limit and `after`, never past
|
|
1168
|
+
// Pause, its brakes or a machine need. A held task stays queued.
|
|
1169
|
+
export async function startQueuedForOrchestrator(
|
|
1170
|
+
workspaceId: string,
|
|
1171
|
+
id: string
|
|
1172
|
+
): Promise<ForcedStart> {
|
|
1173
|
+
const r = row(id);
|
|
1174
|
+
const project = r ? getProject(r.project_id) : null;
|
|
1175
|
+
if (!r || r.status !== "queued" || project?.workspace_id !== workspaceId)
|
|
1176
|
+
throw new Error("That task isn't waiting in the queue");
|
|
1177
|
+
const slots = slotState(r.project_id);
|
|
1178
|
+
const base = {
|
|
1179
|
+
wasFull: slots.full,
|
|
1180
|
+
running: slots.running,
|
|
1181
|
+
limit: slots.limit,
|
|
1182
|
+
skippedAfter: !!(r.after_task || r.after_any),
|
|
1183
|
+
};
|
|
1184
|
+
const held = (text: string): ForcedStart => ({
|
|
1185
|
+
...base,
|
|
1186
|
+
outcome: "held",
|
|
1187
|
+
text,
|
|
1188
|
+
});
|
|
1189
|
+
if (isPaused(workspaceId)) return held("the orchestrator is paused by Saad");
|
|
1190
|
+
if (isRemoteHost(r.host_id ?? project.host_id))
|
|
1191
|
+
return held(
|
|
1192
|
+
"it runs on another machine, and the orchestrator starts tasks on this one only"
|
|
1193
|
+
);
|
|
1194
|
+
if (
|
|
1195
|
+
!meetsNeeds(
|
|
1196
|
+
parseNeeds(r.machine_needs) ??
|
|
1197
|
+
requestNeeds(project.id, project.working_directory, r.prompt, null),
|
|
1198
|
+
machineCapabilities()
|
|
1199
|
+
)
|
|
1200
|
+
)
|
|
1201
|
+
return held("it needs a Mac, and this machine isn't one");
|
|
1202
|
+
try {
|
|
1203
|
+
const outcome = await startRow(r, { force: true, brakeAs: workspaceId });
|
|
1204
|
+
if (outcome === "started") return { ...base, outcome, text: "" };
|
|
1205
|
+
return held("it could not be claimed just now; try again");
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (error instanceof BrakeRefused) return held(error.reason);
|
|
1208
|
+
return {
|
|
1209
|
+
...base,
|
|
1210
|
+
outcome: "failed",
|
|
1211
|
+
text: row(id)?.error ?? String(error),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1093
1216
|
// A task still waiting in the line (or failed to start) by id, an id prefix
|
|
1094
1217
|
// of 4+ characters or, when asked, its name; in one workspace.
|
|
1095
1218
|
export function findQueued(
|
package/lib/tasks/remote.ts
CHANGED
|
@@ -191,7 +191,8 @@ export function setMirrorStatus(id: string, status: TaskStatus): void {
|
|
|
191
191
|
export async function remoteTaskAction(
|
|
192
192
|
session: Session,
|
|
193
193
|
action: "merge" | "drop",
|
|
194
|
-
head?: string
|
|
194
|
+
head?: string,
|
|
195
|
+
base?: string | null
|
|
195
196
|
): Promise<MergeMethod | null> {
|
|
196
197
|
const link = requireHostLink(session.host_id);
|
|
197
198
|
// An AgentOS that predates pinned merges would ignore the head and merge
|
|
@@ -203,11 +204,28 @@ export async function remoteTaskAction(
|
|
|
203
204
|
`${link.hostName}'s AgentOS can't pin a merge to a commit; update it first`
|
|
204
205
|
);
|
|
205
206
|
}
|
|
207
|
+
// base: what the gates here were judged against; the machine that merges
|
|
208
|
+
// compares the PR with it, not with a base only it recorded.
|
|
206
209
|
const res = await hostApi<{ head?: string | null; method?: unknown }>(
|
|
207
210
|
link,
|
|
208
211
|
`/api/tasks/${encodeURIComponent(session.id)}/${action}`,
|
|
209
|
-
{
|
|
210
|
-
|
|
212
|
+
{
|
|
213
|
+
body: { ...(head && { head }), ...(head && base && { base }) },
|
|
214
|
+
timeout: 300000,
|
|
215
|
+
}
|
|
216
|
+
).catch(async (err) => {
|
|
217
|
+
if (action !== "merge" || !unknownOutcome(err)) throw err;
|
|
218
|
+
// A proxy or a dropped connection after the merge ran answers 502 with
|
|
219
|
+
// nothing from AgentOS: ask what state the task is in before failing.
|
|
220
|
+
const merged = await hostTasks(link, true)
|
|
221
|
+
.then((l) => l.tasks.find((t) => t.id === session.id)?.state === "merged")
|
|
222
|
+
.catch(() => false);
|
|
223
|
+
if (!merged)
|
|
224
|
+
throw new Error(
|
|
225
|
+
`${(err as Error).message}: couldn't confirm whether ${link.hostName} merged ${session.name}; check its PR before signing off again`
|
|
226
|
+
);
|
|
227
|
+
return { head: head ?? null, method: undefined };
|
|
228
|
+
});
|
|
211
229
|
forgetHostTasks(link.hostId);
|
|
212
230
|
setMirrorStatus(session.id, action === "merge" ? "merged" : "dropped");
|
|
213
231
|
if (head && res.head !== head)
|
package/package.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
|
|
3
|
+
// The Bookmarks views: every chat's (`open`, one workspace's or all), or one
|
|
4
|
+
// chat's (`chatSessionId`); the bookmark whose note is being written; and a
|
|
5
|
+
// jump waiting for its chat to be on screen.
|
|
6
|
+
export const bookmarksUi = proxy<{
|
|
7
|
+
open: boolean;
|
|
8
|
+
workspaceId: string | null;
|
|
9
|
+
chatSessionId: string | null;
|
|
10
|
+
editing: string | null;
|
|
11
|
+
jump: { sessionId: string; itemId: string; at: number } | null;
|
|
12
|
+
}>({
|
|
13
|
+
open: false,
|
|
14
|
+
workspaceId: null,
|
|
15
|
+
chatSessionId: null,
|
|
16
|
+
editing: null,
|
|
17
|
+
jump: null,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const bookmarksUiActions = {
|
|
21
|
+
open: (workspaceId: string | null = null) => {
|
|
22
|
+
bookmarksUi.workspaceId = workspaceId;
|
|
23
|
+
bookmarksUi.open = true;
|
|
24
|
+
},
|
|
25
|
+
setOpen: (open: boolean) => {
|
|
26
|
+
bookmarksUi.open = open;
|
|
27
|
+
if (!open) bookmarksUi.editing = null;
|
|
28
|
+
},
|
|
29
|
+
openChat: (sessionId: string, editing: string | null = null) => {
|
|
30
|
+
bookmarksUi.editing = editing;
|
|
31
|
+
bookmarksUi.chatSessionId = sessionId;
|
|
32
|
+
},
|
|
33
|
+
closeChat: () => {
|
|
34
|
+
bookmarksUi.chatSessionId = null;
|
|
35
|
+
bookmarksUi.editing = null;
|
|
36
|
+
},
|
|
37
|
+
edit: (id: string | null) => {
|
|
38
|
+
bookmarksUi.editing = id;
|
|
39
|
+
},
|
|
40
|
+
jump: (sessionId: string, itemId: string) => {
|
|
41
|
+
bookmarksUi.jump = { sessionId, itemId, at: Date.now() };
|
|
42
|
+
},
|
|
43
|
+
// The chat showing it takes the jump; one taken isn't taken twice.
|
|
44
|
+
takeJump: (sessionId: string): string | null => {
|
|
45
|
+
const j = bookmarksUi.jump;
|
|
46
|
+
if (!j || j.sessionId !== sessionId) return null;
|
|
47
|
+
bookmarksUi.jump = null;
|
|
48
|
+
return j.itemId;
|
|
49
|
+
},
|
|
50
|
+
};
|
package/styles/themes.css
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
--find-match: 48 96% 53% / 0.45;
|
|
29
29
|
--find-current: 25 95% 53%;
|
|
30
30
|
--find-current-foreground: 0 0% 0%;
|
|
31
|
+
/* A bookmarked item's star. */
|
|
32
|
+
--bookmark: 30 90% 40%;
|
|
31
33
|
--background: 240 10% 95.5%;
|
|
32
34
|
--foreground: 240 12% 14%;
|
|
33
35
|
--card: 0 0% 100%;
|
|
@@ -292,6 +294,7 @@
|
|
|
292
294
|
=========================================================================== */
|
|
293
295
|
.dark {
|
|
294
296
|
--find-match: 48 96% 53% / 0.35;
|
|
297
|
+
--bookmark: 43 96% 56%;
|
|
295
298
|
--background: 240 18% 3.5%;
|
|
296
299
|
--foreground: 240 10% 93%;
|
|
297
300
|
--card: 240 14% 6%;
|