@saadnvd1/agent-os 0.3.502 → 0.3.505
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 +47 -0
- package/app/api/projects/[id]/session-logs/route.ts +39 -0
- package/app/api/session-logs/[id]/route.ts +18 -0
- package/components/Pane/DesktopTabBar.tsx +6 -1
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/Pane/PaneViewToggle.tsx +14 -2
- package/components/Pane/index.tsx +28 -1
- package/components/PrStageBadge.tsx +78 -0
- package/components/Projects/ProjectSettingsDialog.tsx +11 -0
- package/components/SessionList/LiveLine.tsx +75 -0
- package/components/SessionList/RowParts.tsx +1 -7
- package/components/SessionList/RowShell.tsx +1 -1
- package/components/SessionList/RowSubtitle.tsx +4 -0
- package/components/SessionList/SessionList.types.ts +2 -0
- package/components/SessionList/SessionRow.tsx +9 -1
- package/components/SessionLogs/LogDocument.tsx +29 -0
- package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
- package/components/views/types.ts +2 -0
- package/contexts/PaneContext.tsx +1 -1
- package/data/session-logs/index.ts +73 -0
- package/lib/db/migrations.ts +24 -0
- package/lib/done/index.ts +2 -0
- package/lib/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/brief.ts +1 -0
- package/lib/orchestrator/checks.ts +2 -0
- package/lib/orchestrator/facts.ts +1 -0
- package/lib/orchestrator/logs.ts +81 -0
- package/lib/orchestrator/serve.ts +5 -0
- package/lib/orchestrator/task-state.ts +30 -7
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +13 -0
- package/lib/orchestrator/tools.ts +1 -0
- package/lib/session-logs/build.ts +253 -0
- package/lib/session-logs/folder.ts +145 -0
- package/lib/session-logs/gather.ts +301 -0
- package/lib/session-logs/index.ts +178 -0
- package/lib/session-logs/store.ts +105 -0
- package/lib/sidebar/live-status.ts +52 -0
- package/lib/sidebar/shelves.ts +5 -0
- package/lib/status/collect.ts +6 -0
- package/lib/status/pr-stages.ts +55 -0
- package/lib/tasks/finish.ts +3 -0
- package/lib/tasks/pr-stage.ts +106 -0
- package/lib/tasks/session.ts +17 -0
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +70 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ScrollText } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import {
|
|
15
|
+
useProjectLogsQuery,
|
|
16
|
+
useSetLogFolder,
|
|
17
|
+
type LogHeader,
|
|
18
|
+
} from "@/data/session-logs";
|
|
19
|
+
import { LogDocument } from "./LogDocument";
|
|
20
|
+
|
|
21
|
+
const day = (h: LogHeader) => (h.mergedAt ?? h.createdAt).slice(0, 10);
|
|
22
|
+
|
|
23
|
+
function LogRow({ log, onOpen }: { log: LogHeader; onOpen: () => void }) {
|
|
24
|
+
return (
|
|
25
|
+
<li>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={onOpen}
|
|
29
|
+
className="hover:bg-accent/50 flex min-h-11 w-full items-center gap-3 px-3 py-2 text-left transition-colors"
|
|
30
|
+
>
|
|
31
|
+
<span className="min-w-0 flex-1">
|
|
32
|
+
<span className="block truncate text-sm font-medium">{log.name}</span>
|
|
33
|
+
<span className="text-muted-foreground block truncate text-xs">
|
|
34
|
+
{day(log)}
|
|
35
|
+
{log.prNumber ? ` · #${log.prNumber}` : ""}
|
|
36
|
+
{` · $${log.costUsd.toFixed(2)}`}
|
|
37
|
+
</span>
|
|
38
|
+
</span>
|
|
39
|
+
<ScrollText className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
40
|
+
</button>
|
|
41
|
+
</li>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A project's logs of merged tasks, newest first, and the folder their
|
|
46
|
+
// files go to (the default is outside every repository).
|
|
47
|
+
export function ProjectLogsSection({
|
|
48
|
+
projectId,
|
|
49
|
+
open,
|
|
50
|
+
}: {
|
|
51
|
+
projectId: string;
|
|
52
|
+
open: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const { data, isPending, isError, error } = useProjectLogsQuery(
|
|
55
|
+
projectId,
|
|
56
|
+
open
|
|
57
|
+
);
|
|
58
|
+
const save = useSetLogFolder(projectId);
|
|
59
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
60
|
+
const [viewing, setViewing] = useState<LogHeader | null>(null);
|
|
61
|
+
const folder = draft ?? data?.folder ?? "";
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<h3 className="text-sm font-medium">Session logs</h3>
|
|
66
|
+
<p className="text-muted-foreground text-xs">
|
|
67
|
+
When a task merges, AgentOS writes a log of it from what it already
|
|
68
|
+
recorded. No model is involved.
|
|
69
|
+
</p>
|
|
70
|
+
<div className="space-y-1.5">
|
|
71
|
+
<label htmlFor={`log-folder-${projectId}`} className="text-sm">
|
|
72
|
+
Session log folder
|
|
73
|
+
</label>
|
|
74
|
+
<div className="flex gap-2">
|
|
75
|
+
<Input
|
|
76
|
+
id={`log-folder-${projectId}`}
|
|
77
|
+
value={folder}
|
|
78
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
79
|
+
placeholder={data ? `${data.defaultFolder}/…` : "~/path/to/logs"}
|
|
80
|
+
className="h-11 flex-1 md:h-9"
|
|
81
|
+
onKeyDown={(e) => {
|
|
82
|
+
if (e.key !== "Enter") return;
|
|
83
|
+
// Not the project form's submit.
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
if (draft !== null)
|
|
86
|
+
save.mutate(draft, { onSuccess: () => setDraft(null) });
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
<Button
|
|
90
|
+
type="button"
|
|
91
|
+
variant="outline"
|
|
92
|
+
className="h-11 md:h-9"
|
|
93
|
+
disabled={save.isPending || draft === null}
|
|
94
|
+
onClick={() =>
|
|
95
|
+
save.mutate(draft ?? "", { onSuccess: () => setDraft(null) })
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
Save
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
{save.isError && (
|
|
102
|
+
<p className="text-destructive text-sm">{save.error.message}</p>
|
|
103
|
+
)}
|
|
104
|
+
<p className="text-muted-foreground text-xs">
|
|
105
|
+
An absolute or ~ path, never inside the project's repository.
|
|
106
|
+
Empty uses the default.
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
{isPending && (
|
|
110
|
+
<div className="bg-muted/40 h-16 animate-pulse rounded-lg" />
|
|
111
|
+
)}
|
|
112
|
+
{isError && <p className="text-destructive text-sm">{error.message}</p>}
|
|
113
|
+
{data &&
|
|
114
|
+
(data.logs.length ? (
|
|
115
|
+
<ul className="bg-muted/40 divide-border max-h-64 divide-y overflow-y-auto rounded-lg">
|
|
116
|
+
{data.logs.map((l) => (
|
|
117
|
+
<LogRow key={l.id} log={l} onOpen={() => setViewing(l)} />
|
|
118
|
+
))}
|
|
119
|
+
</ul>
|
|
120
|
+
) : (
|
|
121
|
+
<p className="text-muted-foreground text-sm">
|
|
122
|
+
No merged tasks logged yet.
|
|
123
|
+
</p>
|
|
124
|
+
))}
|
|
125
|
+
<Dialog open={!!viewing} onOpenChange={(o) => !o && setViewing(null)}>
|
|
126
|
+
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden [&>*]:min-w-0">
|
|
127
|
+
<DialogHeader>
|
|
128
|
+
<DialogTitle className="truncate">
|
|
129
|
+
{viewing?.name ?? "Log"}
|
|
130
|
+
</DialogTitle>
|
|
131
|
+
<DialogDescription>Session log</DialogDescription>
|
|
132
|
+
</DialogHeader>
|
|
133
|
+
<div className="h-[70vh]">
|
|
134
|
+
{viewing && <LogDocument sessionId={viewing.id} />}
|
|
135
|
+
</div>
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PrStage } from "@/lib/tasks/pr-stage";
|
|
1
2
|
import type { SessionNeed } from "@/lib/sidebar/shelves";
|
|
2
3
|
import type { Session } from "@/lib/db";
|
|
3
4
|
import type { ProjectWithDevServers } from "@/lib/projects";
|
|
@@ -15,6 +16,7 @@ export interface SessionStatus {
|
|
|
15
16
|
unread?: boolean;
|
|
16
17
|
// A chat summarising its context; status stays "running".
|
|
17
18
|
compacting?: { since: number; before?: number };
|
|
19
|
+
prStage?: PrStage;
|
|
18
20
|
claudeSessionId?: string | null;
|
|
19
21
|
// From a program's OSC 7501 report: untrusted, rendered as plain text.
|
|
20
22
|
detail?: string | null;
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "@/lib/panes";
|
|
27
27
|
import { useViewport } from "@/hooks/useViewport";
|
|
28
28
|
|
|
29
|
-
export type ViewMode = "terminal" | "files" | "git" | "workers";
|
|
29
|
+
export type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
|
|
30
30
|
|
|
31
31
|
export interface PaneViewState {
|
|
32
32
|
viewMode: ViewMode;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { LogHeader } from "@/lib/session-logs/store";
|
|
3
|
+
|
|
4
|
+
export type { LogHeader };
|
|
5
|
+
|
|
6
|
+
export const sessionLogKeys = {
|
|
7
|
+
all: ["session-logs"] as const,
|
|
8
|
+
task: (id: string) => [...sessionLogKeys.all, "task", id] as const,
|
|
9
|
+
project: (id: string) => [...sessionLogKeys.all, "project", id] as const,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface TaskLog {
|
|
13
|
+
header: LogHeader;
|
|
14
|
+
markdown: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProjectLogs {
|
|
18
|
+
folder: string | null;
|
|
19
|
+
defaultFolder: string;
|
|
20
|
+
logs: LogHeader[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// null: this task has no log (it didn't merge, or it predates logs).
|
|
24
|
+
export function useTaskLogQuery(sessionId: string | undefined, enabled = true) {
|
|
25
|
+
return useQuery({
|
|
26
|
+
queryKey: sessionLogKeys.task(sessionId ?? ""),
|
|
27
|
+
queryFn: async (): Promise<TaskLog | null> => {
|
|
28
|
+
const res = await fetch(
|
|
29
|
+
`/api/session-logs/${encodeURIComponent(sessionId!)}`
|
|
30
|
+
);
|
|
31
|
+
if (res.status === 404) return null;
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
34
|
+
return data;
|
|
35
|
+
},
|
|
36
|
+
enabled: enabled && !!sessionId,
|
|
37
|
+
// A written log doesn't change; a task that just merged may not have
|
|
38
|
+
// one for a moment.
|
|
39
|
+
staleTime: (query) => (query.state.data ? Infinity : 10_000),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function projectLogs(res: Response): Promise<ProjectLogs> {
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useProjectLogsQuery(projectId: string, enabled = true) {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: sessionLogKeys.project(projectId),
|
|
52
|
+
queryFn: async () =>
|
|
53
|
+
projectLogs(await fetch(`/api/projects/${projectId}/session-logs`)),
|
|
54
|
+
enabled,
|
|
55
|
+
staleTime: 30_000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useSetLogFolder(projectId: string) {
|
|
60
|
+
const queryClient = useQueryClient();
|
|
61
|
+
return useMutation({
|
|
62
|
+
mutationFn: async (folder: string) =>
|
|
63
|
+
projectLogs(
|
|
64
|
+
await fetch(`/api/projects/${projectId}/session-logs`, {
|
|
65
|
+
method: "PUT",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ folder }),
|
|
68
|
+
})
|
|
69
|
+
),
|
|
70
|
+
onSuccess: (data) =>
|
|
71
|
+
queryClient.setQueryData(sessionLogKeys.project(projectId), data),
|
|
72
|
+
});
|
|
73
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1593,6 +1593,30 @@ const migrations: Migration[] = [
|
|
|
1593
1593
|
})();
|
|
1594
1594
|
},
|
|
1595
1595
|
},
|
|
1596
|
+
{
|
|
1597
|
+
id: 69,
|
|
1598
|
+
name: "session_logs",
|
|
1599
|
+
up: (db) => {
|
|
1600
|
+
// One log per merged task, written by AgentOS from data it already
|
|
1601
|
+
// holds (lib/session-logs). Never deleted: the database is the record,
|
|
1602
|
+
// the markdown file on disk is a copy.
|
|
1603
|
+
db.exec(`
|
|
1604
|
+
CREATE TABLE IF NOT EXISTS session_logs (
|
|
1605
|
+
session_id TEXT PRIMARY KEY,
|
|
1606
|
+
project_id TEXT,
|
|
1607
|
+
workspace_id TEXT,
|
|
1608
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1609
|
+
markdown TEXT NOT NULL,
|
|
1610
|
+
data TEXT NOT NULL,
|
|
1611
|
+
file_path TEXT
|
|
1612
|
+
);
|
|
1613
|
+
CREATE INDEX IF NOT EXISTS idx_session_logs_project
|
|
1614
|
+
ON session_logs(project_id, created_at);
|
|
1615
|
+
CREATE INDEX IF NOT EXISTS idx_session_logs_workspace
|
|
1616
|
+
ON session_logs(workspace_id, created_at);
|
|
1617
|
+
`);
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1596
1620
|
];
|
|
1597
1621
|
|
|
1598
1622
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/done/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { sessionMergePolicy } from "../tasks/finish";
|
|
|
25
25
|
import { releasePorts } from "../ports";
|
|
26
26
|
import { dropSessionDatabase } from "../project-config/database";
|
|
27
27
|
import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
28
|
+
import { captureMergedTask } from "../session-logs";
|
|
28
29
|
|
|
29
30
|
export interface DoneOptions {
|
|
30
31
|
// Who asked: the orchestrator's gate failures count toward Saad, as its
|
|
@@ -102,6 +103,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
|
|
|
102
103
|
: `UPDATE sessions SET task_status = 'done' WHERE id = ?`
|
|
103
104
|
).run(s.id);
|
|
104
105
|
syncTaskCardInBackground(s, status, pr);
|
|
106
|
+
if (status === "merged") void captureMergedTask(s.id, { head: pr?.head });
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export async function doneSession(
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* database, so it survives a restart.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
15
16
|
import { db, type Session } from "../db";
|
|
16
17
|
import type { TaskPR } from "../tasks/state";
|
|
17
18
|
import { latestApproval } from "./ask-approvals";
|
|
@@ -37,6 +38,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
|
|
|
37
38
|
merge_armed_seen = NULL WHERE id = ?`
|
|
38
39
|
).run(sessionId);
|
|
39
40
|
looked.delete(sessionId);
|
|
41
|
+
notifyStatusChanged();
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
// The workspace a task's PR merges through when armed: its project's, with
|
|
@@ -95,6 +95,7 @@ Reading:
|
|
|
95
95
|
- \`${TOOL_NAMES.sessions}\`: every session in the workspace with its project, view, status, what it's doing, task/PR/CI state and stack position. After them, external sessions: tmux sessions AgentOS didn't start (dispatch, a person's own terminal), on this machine or another, working in one of the workspace's repositories or a worktree of it, each with its branch's open PR. They're read-only: \`read\` works on them; \`send\`, \`stop\` and \`done\` don't. Gate their PRs with \`review\` and \`sign_off\` by number.
|
|
96
96
|
- \`${TOOL_NAMES.read}\` (session, lines?): the end of a session's terminal or chat, or an external session's screen.
|
|
97
97
|
- \`${TOOL_NAMES.cards}\` (board?): the cards on the workspace's LumifyHub boards.
|
|
98
|
+
- \`${TOOL_NAMES.logs}\` (project?, task?, n?): the logs AgentOS wrote when tasks merged. Without task, the latest headers; with it, that task's whole log (brief, plan, timeline, usage, outcome). Check it before repeating work or re-planning a job a merged task already did.
|
|
98
99
|
- \`${TOOL_NAMES.stack_status}\` (id): one stack's items, PRs and progress.
|
|
99
100
|
- \`${TOOL_NAMES.orchestrators}\`: every workspace, and whether it has an orchestrator.
|
|
100
101
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { db } from "../db";
|
|
8
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
8
9
|
|
|
9
10
|
// ci: when the check count on a commit last changed, for settling.
|
|
10
11
|
export type CheckKind = "review" | "scope" | "ci";
|
|
@@ -57,6 +58,7 @@ export function putCheck(row: {
|
|
|
57
58
|
row.status,
|
|
58
59
|
row.detail ?? null
|
|
59
60
|
);
|
|
61
|
+
notifyStatusChanged();
|
|
60
62
|
return getCheck(row.sessionId, row.sha, row.kind)!;
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -200,6 +200,7 @@ export async function statusOf(s: Session): Promise<{
|
|
|
200
200
|
|
|
201
201
|
// A commit's time never changes: read once per sha.
|
|
202
202
|
const committed = new Map<string, number>();
|
|
203
|
+
export const knownCommitTime = (sha: string) => committed.get(sha) ?? 0;
|
|
203
204
|
|
|
204
205
|
export async function settleIn(
|
|
205
206
|
workspaceId: string,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchestrator's `logs` tool: session logs read from the database as
|
|
3
|
+
* they were written. Reading only; nothing here asks a model to summarise.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatDuration } from "../session-logs/build";
|
|
7
|
+
import { getLog, listLogs, type LogHeader } from "../session-logs";
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { workspaceProject } from "./targets";
|
|
10
|
+
import { untrusted } from "./untrusted";
|
|
11
|
+
|
|
12
|
+
// About 8k tokens: what one full log may return.
|
|
13
|
+
export const LOG_CHAR_CAP = 30000;
|
|
14
|
+
|
|
15
|
+
const headerLine = (h: LogHeader) =>
|
|
16
|
+
[
|
|
17
|
+
(h.mergedAt ?? h.createdAt).slice(0, 10),
|
|
18
|
+
`${h.name} [${h.id.slice(0, 8)}]`,
|
|
19
|
+
h.project ?? "no project",
|
|
20
|
+
h.prNumber ? `#${h.prNumber}` : "no PR",
|
|
21
|
+
h.durationMs !== null ? formatDuration(h.durationMs) : "?",
|
|
22
|
+
`$${h.costUsd.toFixed(2)}`,
|
|
23
|
+
h.hasPlan ? "plan" : h.planKept ? "no plan" : "plan not kept",
|
|
24
|
+
].join(" · ");
|
|
25
|
+
|
|
26
|
+
function findOne(
|
|
27
|
+
workspaceId: string,
|
|
28
|
+
projectId: string | null,
|
|
29
|
+
ref: string
|
|
30
|
+
): string {
|
|
31
|
+
const r = ref.trim().toLowerCase();
|
|
32
|
+
const pr = /^#?(\d+)$/.exec(r);
|
|
33
|
+
const rows = db
|
|
34
|
+
.prepare(
|
|
35
|
+
`SELECT session_id AS id, json_extract(data, '$.name') AS name,
|
|
36
|
+
json_extract(data, '$.prNumber') AS pr
|
|
37
|
+
FROM session_logs WHERE workspace_id = ? AND (? IS NULL OR project_id = ?)
|
|
38
|
+
ORDER BY json_extract(data, '$.mergedAt') DESC`
|
|
39
|
+
)
|
|
40
|
+
.all(workspaceId, projectId, projectId) as {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
pr: number | null;
|
|
44
|
+
}[];
|
|
45
|
+
const hit = rows.filter(
|
|
46
|
+
(x) =>
|
|
47
|
+
x.id === ref ||
|
|
48
|
+
x.id.startsWith(r) ||
|
|
49
|
+
x.name.toLowerCase() === r ||
|
|
50
|
+
(pr !== null && x.pr === Number(pr[1]))
|
|
51
|
+
);
|
|
52
|
+
if (hit.length === 1) return hit[0].id;
|
|
53
|
+
if (hit.length > 1)
|
|
54
|
+
throw new Error(
|
|
55
|
+
`"${ref}" matches ${hit.length} logs (${hit.map((x) => x.id.slice(0, 8)).join(", ")}); use an id`
|
|
56
|
+
);
|
|
57
|
+
throw new Error(`No log for "${ref}" in this workspace.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readLogs(
|
|
61
|
+
workspaceId: string,
|
|
62
|
+
a: { project?: string; task?: string; n?: number }
|
|
63
|
+
): string {
|
|
64
|
+
const projectId = a.project
|
|
65
|
+
? workspaceProject(workspaceId, a.project).id
|
|
66
|
+
: null;
|
|
67
|
+
if (a.task) {
|
|
68
|
+
const row = getLog(findOne(workspaceId, projectId, a.task))!;
|
|
69
|
+
const md =
|
|
70
|
+
row.markdown.length > LOG_CHAR_CAP
|
|
71
|
+
? `${row.markdown.slice(0, LOG_CHAR_CAP)}\n… (trimmed; ${row.markdown.length} characters in all)`
|
|
72
|
+
: row.markdown;
|
|
73
|
+
return untrusted("session log", md);
|
|
74
|
+
}
|
|
75
|
+
const logs = listLogs(
|
|
76
|
+
{ workspaceId, ...(projectId ? { projectId } : {}) },
|
|
77
|
+
a.n ?? 10
|
|
78
|
+
);
|
|
79
|
+
if (!logs.length) return "No session logs yet.";
|
|
80
|
+
return untrusted("session logs", logs.map(headerLine).join("\n"));
|
|
81
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
stop,
|
|
13
13
|
} from "./act";
|
|
14
14
|
import { readCards } from "./cards";
|
|
15
|
+
import { readLogs } from "./logs";
|
|
15
16
|
import { describeSessions } from "./describe";
|
|
16
17
|
import { sessionFacts } from "./facts";
|
|
17
18
|
import { describeExternal, externalSessions } from "./external-sessions";
|
|
@@ -85,6 +86,10 @@ export async function runTool(
|
|
|
85
86
|
}
|
|
86
87
|
case "cards":
|
|
87
88
|
return readCards(w, p(tool, raw).board);
|
|
89
|
+
case "logs": {
|
|
90
|
+
const a = p(tool, raw);
|
|
91
|
+
return readLogs(w, a);
|
|
92
|
+
}
|
|
88
93
|
case "send": {
|
|
89
94
|
const a = p(tool, raw);
|
|
90
95
|
return send(w, a.session, a.message, {
|
|
@@ -114,6 +114,20 @@ async function peerChatWaiting(
|
|
|
114
114
|
// check has registered on it for 2 minutes. Seconds still to wait.
|
|
115
115
|
export const SETTLE_S = 120;
|
|
116
116
|
|
|
117
|
+
// When the check count last changed, as ciSettleIn stored it.
|
|
118
|
+
const lastSeen = (detail: string | null | undefined) =>
|
|
119
|
+
detail ? (JSON.parse(detail) as { count: number; at: number }) : null;
|
|
120
|
+
|
|
121
|
+
const settleLeft = (
|
|
122
|
+
last: { count: number; at: number } | null,
|
|
123
|
+
checkCount: number,
|
|
124
|
+
committedAt: number,
|
|
125
|
+
now: number
|
|
126
|
+
) => {
|
|
127
|
+
const changedAt = last && last.count === checkCount ? last.at : now;
|
|
128
|
+
return Math.max(0, Math.max(committedAt, changedAt) + SETTLE_S - now);
|
|
129
|
+
};
|
|
130
|
+
|
|
117
131
|
export function ciSettleIn(input: {
|
|
118
132
|
workspaceId: string;
|
|
119
133
|
taskId: string;
|
|
@@ -123,11 +137,7 @@ export function ciSettleIn(input: {
|
|
|
123
137
|
now?: number;
|
|
124
138
|
}): number {
|
|
125
139
|
const now = Math.floor((input.now ?? Date.now()) / 1000);
|
|
126
|
-
const
|
|
127
|
-
const last = seen?.detail
|
|
128
|
-
? (JSON.parse(seen.detail) as { count: number; at: number })
|
|
129
|
-
: null;
|
|
130
|
-
const changedAt = last && last.count === input.checkCount ? last.at : now;
|
|
140
|
+
const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
|
|
131
141
|
if (!last || last.count !== input.checkCount)
|
|
132
142
|
putCheck({
|
|
133
143
|
workspaceId: input.workspaceId,
|
|
@@ -137,8 +147,21 @@ export function ciSettleIn(input: {
|
|
|
137
147
|
status: "pass",
|
|
138
148
|
detail: JSON.stringify({ count: input.checkCount, at: now }),
|
|
139
149
|
});
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
return settleLeft(last, input.checkCount, input.committedAt, now);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The same answer from what ciSettleIn stored, writing nothing: for a
|
|
154
|
+
// status that only looks.
|
|
155
|
+
export function ciSettleLeft(input: {
|
|
156
|
+
taskId: string;
|
|
157
|
+
sha: string;
|
|
158
|
+
checkCount: number;
|
|
159
|
+
committedAt: number;
|
|
160
|
+
now?: number;
|
|
161
|
+
}): number {
|
|
162
|
+
const now = Math.floor((input.now ?? Date.now()) / 1000);
|
|
163
|
+
const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
|
|
164
|
+
return settleLeft(last, input.checkCount, input.committedAt, now);
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
// A head with no checks for this long after its PR became mergeable, in a
|
|
@@ -57,6 +57,19 @@ export const TOOL_SHAPES = {
|
|
|
57
57
|
cards: {
|
|
58
58
|
board: ref("One board, by board or project name").optional(),
|
|
59
59
|
},
|
|
60
|
+
logs: {
|
|
61
|
+
project: project.optional(),
|
|
62
|
+
task: ref(
|
|
63
|
+
"One merged task's log, by task name, id (or its first characters) or PR (#12); without it, the latest headers"
|
|
64
|
+
).optional(),
|
|
65
|
+
n: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(50)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("How many headers, newest first (default 10)"),
|
|
72
|
+
},
|
|
60
73
|
send: {
|
|
61
74
|
session,
|
|
62
75
|
message: z.string().trim().min(1).max(8000),
|
|
@@ -55,6 +55,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
55
55
|
"Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
|
|
56
56
|
read: "The end of one session's terminal or chat, or an external session's screen, as plain text (capped at about 4k tokens).",
|
|
57
57
|
cards: "Cards on this workspace's linked LumifyHub boards, by list.",
|
|
58
|
+
logs: "Session logs AgentOS wrote when tasks merged: the last N headers (task, PR, cost, duration) of the workspace or one project, or one log in full (brief, plan, timeline, usage, outcome). Read straight from the database; nothing is summarised.",
|
|
58
59
|
send: "Message a session in this workspace over the bus. It arrives as its next prompt. When Saad changes a running task's scope, tell it with scope_change set, so its review judges the PR against the brief as amended.",
|
|
59
60
|
orchestrators:
|
|
60
61
|
"Every workspace, and whether it has an orchestrator you can message.",
|