@saadnvd1/agent-os 0.3.477 → 0.3.481
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 +26 -1
- package/app/api/schedules/route.ts +1 -0
- package/app/api/sessions/[id]/route.test.ts +66 -0
- package/app/api/sessions/[id]/route.ts +10 -7
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/components/Orchestrator/OrchestratorBar.tsx +12 -0
- package/components/Schedules/ScheduleForm.tsx +94 -12
- package/components/Schedules/SchedulesPanel.tsx +60 -14
- package/components/SessionList/OrchestratorPinRow.tsx +12 -6
- package/components/SessionList/QuickChangeRow.tsx +58 -0
- package/components/SessionList/SessionRow.tsx +4 -0
- package/components/SessionList/SidebarHeader.tsx +1 -1
- package/components/SessionList/useSchedulesGlance.ts +6 -2
- package/components/Tasks/TaskRow.tsx +2 -17
- package/components/Tasks/task-state-labels.ts +18 -0
- package/lib/away/sweep.ts +2 -0
- package/lib/chat/drivers/demo.ts +2 -1
- package/lib/chat/hold.ts +6 -2
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/test-touch.ts +58 -12
- package/lib/chat/worker/client.ts +2 -3
- package/lib/db/migrations.ts +31 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/index.ts +2 -8
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/brief.ts +8 -3
- package/lib/orchestrator/conditions.ts +2 -1
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +13 -0
- package/lib/orchestrator/git-guard.ts +201 -0
- package/lib/orchestrator/pr-wake.ts +9 -1
- package/lib/orchestrator/quick-store.ts +18 -0
- package/lib/orchestrator/quick.ts +307 -0
- package/lib/orchestrator/rebase.ts +11 -1
- package/lib/orchestrator/reminders.ts +91 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +26 -0
- package/lib/orchestrator/signoff.ts +28 -6
- package/lib/orchestrator/task-state.ts +2 -0
- package/lib/orchestrator/tool-names.ts +12 -2
- package/lib/orchestrator/tool-schemas.ts +43 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/orchestrator/write-guard.ts +80 -3
- package/lib/schedules/badge.ts +11 -1
- package/lib/schedules/cron.ts +1 -1
- package/lib/schedules/index.ts +67 -6
- package/lib/schedules/run.ts +23 -6
- package/lib/schedules/scheduler.ts +12 -2
- package/lib/schedules/start.ts +11 -5
- package/lib/schedules/store.ts +133 -16
- package/lib/schedules/when.ts +166 -0
- package/lib/sessions/end.ts +15 -0
- package/lib/sidebar/shelves.ts +19 -1
- package/lib/tasks/finish.ts +2 -8
- package/lib/tasks/index.ts +18 -6
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +2 -6
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
package/README.md
CHANGED
|
@@ -745,6 +745,22 @@ next minute until the brake lifts or a newer run is due (Run now obeys them
|
|
|
745
745
|
too, once). A schedule whose project has moved to another workspace fails
|
|
746
746
|
until it's edited. Removing a schedule keeps its history.
|
|
747
747
|
|
|
748
|
+
**One-off schedules.** Pick **Once** in the form (a date and time in the
|
|
749
|
+
schedule's zone), or `aos schedule add --at "tomorrow 10am" ...` (an ISO time,
|
|
750
|
+
`in 2h`, a weekday; America/Chicago unless `--tz`). It works with every kind,
|
|
751
|
+
runs once and then turns itself off; the run stays in its history, marked
|
|
752
|
+
done, and the schedule moves to the **Done** group. Upcoming one-offs are
|
|
753
|
+
listed first with their run time, how far off it is and who made it (you or an
|
|
754
|
+
orchestrator), each with Run now, Edit and Cancel; the count shows in the
|
|
755
|
+
sidebar menu and the orchestrator's header. The same claim-in-SQLite rule
|
|
756
|
+
applies, so it never runs twice, and one AgentOS missed while off runs when it
|
|
757
|
+
returns, marked **caught up**. Pause holds a one-off until you resume rather
|
|
758
|
+
than skipping it, and the brakes apply as for any run. A past time is refused.
|
|
759
|
+
Run now uses up its only run; edit it to a new time to run it again. An
|
|
760
|
+
orchestrator makes its own with `remind_me`: at least ten minutes ahead, 20
|
|
761
|
+
pending at most, in its own workspace, and it arrives as a scheduled prompt,
|
|
762
|
+
never an approval.
|
|
763
|
+
|
|
748
764
|
Task and session schedules run at most hourly, message schedules at most
|
|
749
765
|
every 10 minutes and skip a run (`still working`) while their session is
|
|
750
766
|
still busy, mid-turn or holding a queued message. A task schedule waits
|
|
@@ -773,6 +789,15 @@ minutes, one message goes to your phone and to each workspace orchestrator,
|
|
|
773
789
|
naming the top sessions and their heavy commands; the next one waits until
|
|
774
790
|
it has been green for ten minutes. `AGENTOS_LOAD=off` turns it all off.
|
|
775
791
|
|
|
792
|
+
### Tmux cleanup
|
|
793
|
+
|
|
794
|
+
Deleting, finishing, dropping or marking a session done ends its tmux session
|
|
795
|
+
and every process its panes started, on the machine it runs on. At start,
|
|
796
|
+
AgentOS also ends tmux sessions it created (its `<agent>-<uuid>` and
|
|
797
|
+
`aos-chat-<uuid>` names, or the `AGENTOS_SESSION_ID` it sets at launch) whose
|
|
798
|
+
session no longer exists, and logs each as `[tmux-sweep]`. Sessions it didn't
|
|
799
|
+
create are never touched. The sweep runs only in production mode (a dev or test instance shares the tmux server but not the database); `AGENTOS_TMUX_SWEEP=off` skips it.
|
|
800
|
+
|
|
776
801
|
### Phone notifications
|
|
777
802
|
|
|
778
803
|
Two things reach your phone: a schedule run that failed, and whatever an
|
|
@@ -984,7 +1009,7 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
984
1009
|
workspace, or the project already at that path moves in from no workspace;
|
|
985
1010
|
folders that hold secrets and projects of another workspace are refused),
|
|
986
1011
|
`note`,
|
|
987
|
-
`rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
|
|
1012
|
+
`rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm`, `remind_me`, `reminders`, `cancel_reminder` and `ask_saad`. All are served in-process to its chat, refuse
|
|
988
1013
|
any target outside its workspace, and validate their arguments. The one
|
|
989
1014
|
exception is other orchestrators: `orchestrators` lists every workspace and
|
|
990
1015
|
whether it has one, and `message_orchestrator` messages another workspace's
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
// What deleting a session ends: tmux sessions by name, and the chat worker.
|
|
6
|
+
const ended = vi.hoisted(() => [] as { host: unknown; name: unknown }[]);
|
|
7
|
+
const stopped = vi.hoisted(() => [] as string[]);
|
|
8
|
+
vi.mock("@/lib/tmux/end", () => ({
|
|
9
|
+
endTmuxSession: async (host: unknown, name: unknown) => {
|
|
10
|
+
ended.push({ host, name });
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("@/lib/chat/runner", async (original) => ({
|
|
14
|
+
...(await original<typeof import("@/lib/chat/runner")>()),
|
|
15
|
+
stopChat: (id: string) => stopped.push(id),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock("@/lib/chat/worker/client", async (original) => ({
|
|
18
|
+
...(await original<typeof import("@/lib/chat/worker/client")>()),
|
|
19
|
+
waitForExit: async () => {},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { DELETE } = await import("@/app/api/sessions/[id]/route");
|
|
23
|
+
const { db } = await import("@/lib/db");
|
|
24
|
+
|
|
25
|
+
const remove = (id: string) =>
|
|
26
|
+
DELETE(new NextRequest("http://x", { method: "DELETE" }), {
|
|
27
|
+
params: Promise.resolve({ id }),
|
|
28
|
+
});
|
|
29
|
+
const exists = (id: string) =>
|
|
30
|
+
!!db.prepare(`SELECT 1 FROM sessions WHERE id = ?`).get(id);
|
|
31
|
+
function add(fields: { tmux_name: string; host_id?: string }) {
|
|
32
|
+
const id = randomUUID();
|
|
33
|
+
db.prepare(
|
|
34
|
+
`INSERT INTO sessions (id, name, tmux_name, working_directory, host_id)
|
|
35
|
+
VALUES (?, 's', ?, '/tmp', ?)`
|
|
36
|
+
).run(id, fields.tmux_name, fields.host_id ?? "local");
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
ended.length = 0;
|
|
42
|
+
stopped.length = 0;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("DELETE /api/sessions/[id]", () => {
|
|
46
|
+
it("ends the session's own tmux session, not just its row", async () => {
|
|
47
|
+
const id = add({ tmux_name: "claude-keepme" });
|
|
48
|
+
const res = await remove(id);
|
|
49
|
+
expect(res.status).toBe(200);
|
|
50
|
+
expect(exists(id)).toBe(false);
|
|
51
|
+
expect(ended).toEqual([{ host: "local", name: "claude-keepme" }]);
|
|
52
|
+
expect(stopped).toEqual([id]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ends it on the machine the session runs on", async () => {
|
|
56
|
+
const id = add({ tmux_name: "claude-far", host_id: "host-1" });
|
|
57
|
+
await remove(id);
|
|
58
|
+
expect(ended).toEqual([{ host: "host-1", name: "claude-far" }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("ends nothing for a session that doesn't exist", async () => {
|
|
62
|
+
const res = await remove(randomUUID());
|
|
63
|
+
expect(res.status).toBe(404);
|
|
64
|
+
expect(ended).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
import { releasePort } from "@/lib/ports";
|
|
10
10
|
import { dropSessionDatabase } from "@/lib/project-config/database";
|
|
11
11
|
import { killWorker } from "@/lib/orchestration";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { endSessionAgent } from "@/lib/sessions/end";
|
|
13
|
+
import { endTmuxSession } from "@/lib/tmux/end";
|
|
14
|
+
import { hostExecFile } from "@/lib/hosts";
|
|
14
15
|
import { supportsChat } from "@/lib/chat/capabilities";
|
|
15
16
|
import { stopChat } from "@/lib/chat/runner";
|
|
16
17
|
import { statusDetector } from "@/lib/status-detector";
|
|
@@ -124,10 +125,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
|
124
125
|
}
|
|
125
126
|
if (body.view === "terminal") stopChat(id);
|
|
126
127
|
if (body.view === "chat" && existing.tmux_name) {
|
|
127
|
-
await
|
|
128
|
-
existing.host_id,
|
|
129
|
-
`tmux kill-session -t ${shellQuote(`=${existing.tmux_name}`)} 2>/dev/null || true`
|
|
130
|
-
).catch(() => {});
|
|
128
|
+
await endTmuxSession(existing.host_id, existing.tmux_name);
|
|
131
129
|
}
|
|
132
130
|
updates.push("view = ?");
|
|
133
131
|
values.push(body.view);
|
|
@@ -276,7 +274,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
276
274
|
void dropSessionDatabase(id);
|
|
277
275
|
|
|
278
276
|
// Delete from database immediately for instant UI feedback
|
|
279
|
-
|
|
277
|
+
// The agent's tmux and processes go on in the background; the worktree
|
|
278
|
+
// waits for them so nothing is still writing in it.
|
|
279
|
+
const agentEnded = endSessionAgent(existing).catch((error) =>
|
|
280
|
+
console.error(`Ending ${id}'s agent failed:`, error)
|
|
281
|
+
);
|
|
280
282
|
deleteItems(id);
|
|
281
283
|
clearQueue(id);
|
|
282
284
|
queries.deleteSession(db).run(id);
|
|
@@ -285,6 +287,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
285
287
|
if (existing.worktree_path && isAgentOSWorktree(existing.worktree_path)) {
|
|
286
288
|
const worktreePath = existing.worktree_path; // Capture for closure
|
|
287
289
|
runInBackground(async () => {
|
|
290
|
+
await agentEnded;
|
|
288
291
|
const gitCommonDir = await mainCheckoutOf(worktreePath);
|
|
289
292
|
|
|
290
293
|
if (gitCommonDir) {
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { getDb, queries, type Session } from "@/lib/db";
|
|
3
3
|
import { getManagedSessionPattern } from "@/lib/providers/registry";
|
|
4
4
|
import { tmux } from "@/lib/tmux/exec";
|
|
5
|
+
import { endTmuxSession } from "@/lib/tmux/end";
|
|
5
6
|
|
|
6
7
|
// POST /api/tmux/kill-all - Kill all AgentOS tmux sessions and remove from database
|
|
7
8
|
export async function POST() {
|
|
@@ -25,7 +26,7 @@ export async function POST() {
|
|
|
25
26
|
const killed: string[] = [];
|
|
26
27
|
for (const session of tmuxSessions) {
|
|
27
28
|
try {
|
|
28
|
-
await
|
|
29
|
+
await endTmuxSession(null, session);
|
|
29
30
|
killed.push(session);
|
|
30
31
|
} catch {
|
|
31
32
|
// Session might already be dead, continue
|
|
@@ -8,6 +8,8 @@ import { usePauseOrchestrator } from "@/data/orchestrators";
|
|
|
8
8
|
import { useOrchestratorHeader } from "@/hooks/useOrchestratorHeader";
|
|
9
9
|
import { headerParts } from "@/lib/orchestrator/header-line";
|
|
10
10
|
import { cn } from "@/lib/utils";
|
|
11
|
+
import { useSchedulesGlance } from "@/components/SessionList/useSchedulesGlance";
|
|
12
|
+
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
11
13
|
import { NO_PASSKEYS_HERE, passkeysHere } from "@/data/presence";
|
|
12
14
|
import { AsksList } from "./AsksList";
|
|
13
15
|
|
|
@@ -21,6 +23,7 @@ export const OrchestratorBar = memo(function OrchestratorBar({
|
|
|
21
23
|
}) {
|
|
22
24
|
const { overview, counts } = useOrchestratorHeader(workspaceId);
|
|
23
25
|
const pause = usePauseOrchestrator(workspaceId);
|
|
26
|
+
const { upcomingText } = useSchedulesGlance(workspaceId);
|
|
24
27
|
const [open, setOpen] = useState(true);
|
|
25
28
|
const canProve = useSyncExternalStore(
|
|
26
29
|
() => () => {},
|
|
@@ -73,6 +76,15 @@ export const OrchestratorBar = memo(function OrchestratorBar({
|
|
|
73
76
|
{counts.paused ? "Resume" : "Pause"}
|
|
74
77
|
</Button>
|
|
75
78
|
</div>
|
|
79
|
+
{upcomingText && (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => schedulesUiActions.open(workspaceId)}
|
|
83
|
+
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center px-1 text-left text-xs md:min-h-8"
|
|
84
|
+
>
|
|
85
|
+
{upcomingText}
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
76
88
|
{counts.paused && (
|
|
77
89
|
<p className="text-muted-foreground px-1 text-xs">
|
|
78
90
|
Paused: events wait and it won't start, merge or message anything
|
|
@@ -31,8 +31,10 @@ import {
|
|
|
31
31
|
minGapMinutes,
|
|
32
32
|
nextRuns,
|
|
33
33
|
presetCron,
|
|
34
|
+
wall,
|
|
34
35
|
type PresetKind,
|
|
35
36
|
} from "@/lib/schedules/cron";
|
|
37
|
+
import { parseWhen, relativeTime } from "@/lib/schedules/when";
|
|
36
38
|
|
|
37
39
|
const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
|
|
38
40
|
{
|
|
@@ -57,7 +59,10 @@ const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
|
|
|
57
59
|
},
|
|
58
60
|
];
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
type Preset = PresetKind | "custom" | "once";
|
|
63
|
+
|
|
64
|
+
const PRESETS: { value: Preset; label: string }[] = [
|
|
65
|
+
{ value: "once", label: "Once" },
|
|
61
66
|
{ value: "every", label: "Every…" },
|
|
62
67
|
{ value: "hourly", label: "Hourly" },
|
|
63
68
|
{ value: "daily", label: "Daily" },
|
|
@@ -115,6 +120,17 @@ function Segmented<T extends string>({
|
|
|
115
120
|
);
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
124
|
+
|
|
125
|
+
// The date and time inputs' values for an instant, as the zone's wall clock.
|
|
126
|
+
function wallParts(ms: number, tz: string) {
|
|
127
|
+
const w = wall(ms, tz);
|
|
128
|
+
return {
|
|
129
|
+
date: `${w.y}-${pad(w.m)}-${pad(w.d)}`,
|
|
130
|
+
time: `${pad(w.h)}:${pad(w.mi)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
const Field = ({
|
|
119
135
|
label,
|
|
120
136
|
children,
|
|
@@ -148,7 +164,8 @@ export function ScheduleForm({
|
|
|
148
164
|
const update = useUpdateSchedule();
|
|
149
165
|
const mutation = existing ? update : create;
|
|
150
166
|
|
|
151
|
-
const initialPreset =
|
|
167
|
+
const initialPreset =
|
|
168
|
+
existing && existing.run_at == null ? cronPreset(existing.cron) : null;
|
|
152
169
|
const [wsId, setWsId] = useState(
|
|
153
170
|
existing?.workspace_id ?? workspaceId ?? workspaces[0]?.id ?? ""
|
|
154
171
|
);
|
|
@@ -161,9 +178,24 @@ export function ScheduleForm({
|
|
|
161
178
|
);
|
|
162
179
|
const [projectId, setProjectId] = useState(existing?.project_id ?? "");
|
|
163
180
|
const [prompt, setPrompt] = useState(existing?.prompt ?? "");
|
|
164
|
-
const [preset, setPreset] = useState<
|
|
165
|
-
existing
|
|
181
|
+
const [preset, setPreset] = useState<Preset>(
|
|
182
|
+
existing?.run_at != null
|
|
183
|
+
? "once"
|
|
184
|
+
: existing
|
|
185
|
+
? (initialPreset?.kind ?? "custom")
|
|
186
|
+
: draft
|
|
187
|
+
? "every"
|
|
188
|
+
: "weekdays"
|
|
189
|
+
);
|
|
190
|
+
// A new one-off starts an hour out.
|
|
191
|
+
const [initialAt] = useState(() =>
|
|
192
|
+
wallParts(
|
|
193
|
+
existing?.run_at ?? Date.now() + 3_600_000,
|
|
194
|
+
existing?.timezone ?? DEFAULT_TIMEZONE
|
|
195
|
+
)
|
|
166
196
|
);
|
|
197
|
+
const [atDate, setAtDate] = useState(initialAt.date);
|
|
198
|
+
const [atTime, setAtTime] = useState(initialAt.time);
|
|
167
199
|
const [every, setEvery] = useState(initialPreset?.every ?? 30);
|
|
168
200
|
const [hour, setHour] = useState(initialPreset?.hour ?? 9);
|
|
169
201
|
const [minute, setMinute] = useState(initialPreset?.minute ?? 0);
|
|
@@ -197,12 +229,14 @@ export function ScheduleForm({
|
|
|
197
229
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
198
230
|
const target = targets.find((s) => s.id === targetId)?.id ?? "";
|
|
199
231
|
|
|
200
|
-
const
|
|
201
|
-
|
|
232
|
+
const once = preset === "once";
|
|
233
|
+
const cron = once
|
|
234
|
+
? ""
|
|
235
|
+
: preset === "custom"
|
|
202
236
|
? custom.trim()
|
|
203
237
|
: presetCron({ kind: preset, hour, minute, weekday, every });
|
|
204
238
|
const tzOk = isTimezone(timezone);
|
|
205
|
-
const cronProblem = cronError(cron);
|
|
239
|
+
const cronProblem = once ? null : cronError(cron);
|
|
206
240
|
// The preview counts from now, kept fresh while the form is open.
|
|
207
241
|
const [openedAt, setOpenedAt] = useState(() => Date.now());
|
|
208
242
|
useEffect(() => {
|
|
@@ -214,13 +248,32 @@ export function ScheduleForm({
|
|
|
214
248
|
[cron, cronProblem, timezone, tzOk, openedAt]
|
|
215
249
|
);
|
|
216
250
|
|
|
251
|
+
// The picked wall time in the zone, or null while it's incomplete.
|
|
252
|
+
const runAt = useMemo(() => {
|
|
253
|
+
if (!once || !tzOk || !atDate || !atTime) return null;
|
|
254
|
+
try {
|
|
255
|
+
return parseWhen(`${atDate}T${atTime}`, timezone);
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}, [once, tzOk, atDate, atTime, timezone]);
|
|
260
|
+
// Saving a done one-off at its old time can't reopen it; a new time must be ahead.
|
|
261
|
+
const atProblem =
|
|
262
|
+
once && tzOk
|
|
263
|
+
? runAt === null
|
|
264
|
+
? "Pick a date and time"
|
|
265
|
+
: runAt <= openedAt && runAt !== existing?.run_at
|
|
266
|
+
? "That time has passed"
|
|
267
|
+
: null
|
|
268
|
+
: null;
|
|
269
|
+
|
|
217
270
|
const submit = (e: React.FormEvent) => {
|
|
218
271
|
e.preventDefault();
|
|
219
272
|
const input = {
|
|
220
273
|
workspaceId: workspace,
|
|
221
274
|
projectId: project || null,
|
|
222
275
|
name,
|
|
223
|
-
cron,
|
|
276
|
+
...(once ? { at: runAt } : { cron }),
|
|
224
277
|
timezone,
|
|
225
278
|
prompt,
|
|
226
279
|
kind,
|
|
@@ -242,10 +295,11 @@ export function ScheduleForm({
|
|
|
242
295
|
const tooOften = useMemo(
|
|
243
296
|
() =>
|
|
244
297
|
minGap > 0 &&
|
|
298
|
+
!once &&
|
|
245
299
|
!cronProblem &&
|
|
246
300
|
tzOk &&
|
|
247
301
|
minGapMinutes(cron, timezone, openedAt) < minGap,
|
|
248
|
-
[minGap, cron, cronProblem, timezone, tzOk, openedAt]
|
|
302
|
+
[minGap, once, cron, cronProblem, timezone, tzOk, openedAt]
|
|
249
303
|
);
|
|
250
304
|
|
|
251
305
|
const ready =
|
|
@@ -253,6 +307,7 @@ export function ScheduleForm({
|
|
|
253
307
|
prompt.trim() &&
|
|
254
308
|
workspace &&
|
|
255
309
|
!cronProblem &&
|
|
310
|
+
!atProblem &&
|
|
256
311
|
tzOk &&
|
|
257
312
|
!tooOften &&
|
|
258
313
|
(kind === "message" ? target : kind === "orchestrator" || project);
|
|
@@ -362,7 +417,24 @@ export function ScheduleForm({
|
|
|
362
417
|
setPreset(v);
|
|
363
418
|
}}
|
|
364
419
|
/>
|
|
365
|
-
{
|
|
420
|
+
{once ? (
|
|
421
|
+
<div className="flex flex-wrap gap-2">
|
|
422
|
+
<Input
|
|
423
|
+
type="date"
|
|
424
|
+
aria-label="Date"
|
|
425
|
+
value={atDate}
|
|
426
|
+
onChange={(e) => setAtDate(e.target.value)}
|
|
427
|
+
className="h-11 w-44 md:h-9"
|
|
428
|
+
/>
|
|
429
|
+
<Input
|
|
430
|
+
type="time"
|
|
431
|
+
aria-label="Time"
|
|
432
|
+
value={atTime}
|
|
433
|
+
onChange={(e) => setAtTime(e.target.value)}
|
|
434
|
+
className="h-11 w-32 md:h-9"
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
437
|
+
) : preset === "custom" ? (
|
|
366
438
|
<Input
|
|
367
439
|
aria-label="Cron expression"
|
|
368
440
|
value={custom}
|
|
@@ -450,10 +522,20 @@ export function ScheduleForm({
|
|
|
450
522
|
className="text-muted-foreground h-11 md:h-8 md:text-xs"
|
|
451
523
|
/>
|
|
452
524
|
<div className="bg-primary/[0.06] rounded-lg px-3 py-2.5 text-sm">
|
|
453
|
-
{cronProblem || !tzOk ? (
|
|
525
|
+
{cronProblem || !tzOk || atProblem ? (
|
|
454
526
|
<p className="text-destructive">
|
|
455
|
-
{
|
|
527
|
+
{!tzOk
|
|
528
|
+
? `Unknown time zone "${timezone}"`
|
|
529
|
+
: (cronProblem ?? atProblem)}
|
|
456
530
|
</p>
|
|
531
|
+
) : once && runAt !== null ? (
|
|
532
|
+
<>
|
|
533
|
+
<p className="font-medium">{formatRunTime(runAt, timezone)}</p>
|
|
534
|
+
<p className="text-muted-foreground mt-1 text-xs">
|
|
535
|
+
Runs once, {relativeTime(runAt, openedAt)}, then turns itself
|
|
536
|
+
off.
|
|
537
|
+
</p>
|
|
538
|
+
</>
|
|
457
539
|
) : (
|
|
458
540
|
<>
|
|
459
541
|
<p className="font-medium">{describeCron(cron)}</p>
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "@/data/schedules";
|
|
26
26
|
import type { ScheduleView } from "@/lib/schedules";
|
|
27
27
|
import { formatRunTime } from "@/lib/schedules/cron";
|
|
28
|
+
import { relativeTime } from "@/lib/schedules/when";
|
|
28
29
|
import { schedulesUi, schedulesUiActions } from "@/stores/schedulesUi";
|
|
29
30
|
import { ScheduleForm } from "./ScheduleForm";
|
|
30
31
|
import { OutcomeChip, RunHistory } from "./RunHistory";
|
|
@@ -50,10 +51,14 @@ const where = (s: ScheduleView) =>
|
|
|
50
51
|
: "";
|
|
51
52
|
|
|
52
53
|
function NextRun({ s }: { s: ScheduleView }) {
|
|
54
|
+
if (s.done_at) return <>Done {formatRunTime(s.done_at, s.timezone)}</>;
|
|
53
55
|
if (!s.enabled) return <>Off</>;
|
|
54
56
|
if (s.paused) return <>Paused with the orchestrator</>;
|
|
55
57
|
return s.nextRunAt ? (
|
|
56
|
-
<>
|
|
58
|
+
<>
|
|
59
|
+
{s.oneOff ? "Runs" : "Next"} {formatRunTime(s.nextRunAt, s.timezone)}
|
|
60
|
+
{s.oneOff && ` (${relativeTime(s.nextRunAt)})`}
|
|
61
|
+
</>
|
|
57
62
|
) : (
|
|
58
63
|
<>No upcoming run</>
|
|
59
64
|
);
|
|
@@ -98,15 +103,44 @@ function ScheduleRow({ s, onOpen }: { s: ScheduleView; onOpen: () => void }) {
|
|
|
98
103
|
</span>
|
|
99
104
|
</div>
|
|
100
105
|
<p className="text-muted-foreground truncate text-xs">
|
|
101
|
-
{s.
|
|
106
|
+
{s.oneOff ? (
|
|
107
|
+
<NextRun s={s} />
|
|
108
|
+
) : (
|
|
109
|
+
<>
|
|
110
|
+
{s.description} · <NextRun s={s} />
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
{s.oneOff && ` · by ${s.createdBy.name}`}
|
|
102
114
|
</p>
|
|
103
115
|
</div>
|
|
104
116
|
{s.lastRun && <OutcomeChip run={s.lastRun} />}
|
|
105
|
-
<EnableSwitch s={s} />
|
|
117
|
+
{!s.done_at && <EnableSwitch s={s} />}
|
|
106
118
|
</div>
|
|
107
119
|
);
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
function Group({
|
|
123
|
+
title,
|
|
124
|
+
rows,
|
|
125
|
+
onOpen,
|
|
126
|
+
}: {
|
|
127
|
+
title: string;
|
|
128
|
+
rows: ScheduleView[];
|
|
129
|
+
onOpen: (id: string) => void;
|
|
130
|
+
}) {
|
|
131
|
+
if (rows.length === 0) return null;
|
|
132
|
+
return (
|
|
133
|
+
<section className="space-y-2">
|
|
134
|
+
<h4 className="text-muted-foreground px-1 text-xs font-medium">
|
|
135
|
+
{title} · {rows.length}
|
|
136
|
+
</h4>
|
|
137
|
+
{rows.map((s) => (
|
|
138
|
+
<ScheduleRow key={s.id} s={s} onOpen={() => onOpen(s.id)} />
|
|
139
|
+
))}
|
|
140
|
+
</section>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
function Detail({
|
|
111
145
|
id,
|
|
112
146
|
onEdit,
|
|
@@ -134,11 +168,12 @@ function Detail({
|
|
|
134
168
|
<div className="space-y-1">
|
|
135
169
|
<div className="flex items-center gap-2">
|
|
136
170
|
<h3 className="min-w-0 flex-1 truncate font-semibold">{s.name}</h3>
|
|
137
|
-
<EnableSwitch s={s} />
|
|
171
|
+
{!s.done_at && <EnableSwitch s={s} />}
|
|
138
172
|
</div>
|
|
139
173
|
<p className="text-muted-foreground text-sm">
|
|
140
174
|
{KIND_LABEL[s.kind]}
|
|
141
175
|
{where(s)} · {s.description}
|
|
176
|
+
{s.oneOff && ` · by ${s.createdBy.name}`}
|
|
142
177
|
</p>
|
|
143
178
|
<p className="text-muted-foreground text-xs">
|
|
144
179
|
<NextRun s={s} /> · {s.timezone}
|
|
@@ -151,7 +186,7 @@ function Detail({
|
|
|
151
186
|
<Button
|
|
152
187
|
size="sm"
|
|
153
188
|
className="h-11 md:h-8"
|
|
154
|
-
disabled={run.isPending}
|
|
189
|
+
disabled={run.isPending || !!s.done_at}
|
|
155
190
|
onClick={() => run.mutate(s.id)}
|
|
156
191
|
>
|
|
157
192
|
<Play className="h-3.5 w-3.5" />
|
|
@@ -174,7 +209,7 @@ function Detail({
|
|
|
174
209
|
disabled={archive.isPending}
|
|
175
210
|
onClick={() => archive.mutate(s.id, { onSuccess: onGone })}
|
|
176
211
|
>
|
|
177
|
-
Remove (keeps history)
|
|
212
|
+
{s.oneOff && !s.done_at ? "Cancel it" : "Remove (keeps history)"}
|
|
178
213
|
</Button>
|
|
179
214
|
) : (
|
|
180
215
|
<Button
|
|
@@ -184,7 +219,7 @@ function Detail({
|
|
|
184
219
|
onClick={() => setConfirm(true)}
|
|
185
220
|
>
|
|
186
221
|
<Trash2 className="h-3.5 w-3.5" />
|
|
187
|
-
Remove
|
|
222
|
+
{s.oneOff && !s.done_at ? "Cancel" : "Remove"}
|
|
188
223
|
</Button>
|
|
189
224
|
)}
|
|
190
225
|
</div>
|
|
@@ -300,13 +335,24 @@ export function SchedulesPanel() {
|
|
|
300
335
|
</p>
|
|
301
336
|
</div>
|
|
302
337
|
)}
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
s
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
338
|
+
{(() => {
|
|
339
|
+
const open = (id: string) => setView({ mode: "detail", id });
|
|
340
|
+
const upcoming = schedules
|
|
341
|
+
.filter((s) => s.oneOff && !s.done_at)
|
|
342
|
+
.sort((a, b) => (a.run_at ?? 0) - (b.run_at ?? 0));
|
|
343
|
+
const done = schedules.filter((s) => s.done_at);
|
|
344
|
+
return (
|
|
345
|
+
<>
|
|
346
|
+
<Group title="Upcoming" rows={upcoming} onOpen={open} />
|
|
347
|
+
<Group
|
|
348
|
+
title="Recurring"
|
|
349
|
+
rows={schedules.filter((s) => !s.oneOff)}
|
|
350
|
+
onOpen={open}
|
|
351
|
+
/>
|
|
352
|
+
<Group title="Done" rows={done} onOpen={open} />
|
|
353
|
+
</>
|
|
354
|
+
);
|
|
355
|
+
})()}
|
|
310
356
|
{!isPending && <PhoneLine />}
|
|
311
357
|
</div>
|
|
312
358
|
)}
|
|
@@ -12,6 +12,7 @@ import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
|
|
|
12
12
|
import { useRowContext } from "./RowContext";
|
|
13
13
|
import { SessionRowMenu } from "./SessionRowMenu";
|
|
14
14
|
import { RowRename, UnreadDot } from "./RowParts";
|
|
15
|
+
import { QuickChangeRow } from "./QuickChangeRow";
|
|
15
16
|
|
|
16
17
|
// A workspace's orchestrator, pinned above its sessions and titled with the
|
|
17
18
|
// workspace's name: what it's doing and how many asks wait on you, on a line
|
|
@@ -126,12 +127,17 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
126
127
|
);
|
|
127
128
|
|
|
128
129
|
return (
|
|
129
|
-
|
|
130
|
-
<CM.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
<>
|
|
131
|
+
<CM.ContextMenu>
|
|
132
|
+
<CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
|
|
133
|
+
<CM.ContextMenuContent className="w-56">
|
|
134
|
+
{menu("context")}
|
|
135
|
+
</CM.ContextMenuContent>
|
|
136
|
+
</CM.ContextMenu>
|
|
137
|
+
{row.quick?.map((q) => (
|
|
138
|
+
<QuickChangeRow key={q.session.id} row={q} />
|
|
139
|
+
))}
|
|
140
|
+
</>
|
|
135
141
|
);
|
|
136
142
|
}, sameProps);
|
|
137
143
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { GitPullRequest } from "lucide-react";
|
|
4
|
+
import type { SidebarRow } from "@/lib/sidebar/shelves";
|
|
5
|
+
import { TASK_STATE } from "@/components/Tasks/task-state-labels";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
// One of an orchestrator's quick changes, under it: its name, its PR and
|
|
9
|
+
// where CI stands. It has no agent to open, so the row opens its PR.
|
|
10
|
+
export function QuickChangeRow({ row }: { row: SidebarRow }) {
|
|
11
|
+
const { session } = row;
|
|
12
|
+
const pr = session.pr_number && session.pr_url ? session.pr_url : null;
|
|
13
|
+
const state = row.task ? TASK_STATE[row.task] : null;
|
|
14
|
+
const label = !session.pr_number
|
|
15
|
+
? "Editing, no PR yet"
|
|
16
|
+
: `PR #${session.pr_number}${state ? ` · ${state.label}` : ""}`;
|
|
17
|
+
const body = (
|
|
18
|
+
<>
|
|
19
|
+
<GitPullRequest
|
|
20
|
+
aria-hidden
|
|
21
|
+
className={cn(
|
|
22
|
+
"h-3.5 w-3.5 shrink-0",
|
|
23
|
+
state?.tone ?? "text-muted-foreground"
|
|
24
|
+
)}
|
|
25
|
+
/>
|
|
26
|
+
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
|
27
|
+
<span className="text-foreground truncate text-[13px]">
|
|
28
|
+
{session.name}
|
|
29
|
+
</span>
|
|
30
|
+
<span
|
|
31
|
+
className={cn(
|
|
32
|
+
"mt-0.5 truncate text-xs",
|
|
33
|
+
session.pr_number && state ? state.tone : "text-muted-foreground"
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{label}
|
|
37
|
+
</span>
|
|
38
|
+
</span>
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
const frame =
|
|
42
|
+
"ml-5 flex min-h-11 items-center gap-2 rounded-md px-2.5 py-1 md:min-h-9";
|
|
43
|
+
return pr ? (
|
|
44
|
+
<a
|
|
45
|
+
href={pr}
|
|
46
|
+
target="_blank"
|
|
47
|
+
rel="noreferrer"
|
|
48
|
+
title={`Quick change: ${session.name}\n${label}`}
|
|
49
|
+
className={cn(frame, "hover:bg-muted/60 transition-colors")}
|
|
50
|
+
>
|
|
51
|
+
{body}
|
|
52
|
+
</a>
|
|
53
|
+
) : (
|
|
54
|
+
<div title={`Quick change: ${session.name}\n${label}`} className={frame}>
|
|
55
|
+
{body}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|