feat(runtime): add Cowork checkpoint drill-in
This commit is contained in:
parent
9cc4bbe906
commit
82a44c249f
@ -21,6 +21,7 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
# ── Microservice URLs (consolidated platform-service) ──
|
||||
PLATFORM_SERVICE_URL=http://localhost:4003
|
||||
ACTIONTRAIL_SERVICE_URL=http://localhost:4018
|
||||
COWORK_SERVICE_URL=http://localhost:4009
|
||||
BILLING_INTERNAL_KEY=
|
||||
|
||||
# ── Stripe ──
|
||||
|
||||
@ -32,6 +32,7 @@ API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Microservice URLs (consolidated platform-service)
|
||||
PLATFORM_SERVICE_URL=http://localhost:4003
|
||||
COWORK_SERVICE_URL=http://localhost:4009
|
||||
BILLING_INTERNAL_KEY=<billing-internal-key>
|
||||
|
||||
# Azure Blob Storage
|
||||
@ -41,4 +42,3 @@ AZURE_BLOB_ACCOUNT_KEY=<blob-account-key>
|
||||
|
||||
# Perplexity AI (admin docs chatbot / RAG)
|
||||
PERPLEXITY_API_KEY=pplx-...
|
||||
|
||||
|
||||
@ -71,6 +71,57 @@ describe('agent runtime proxy route', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies Cowork checkpoint list requests to cowork-service', async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({ checkpoints: [{ checkpointId: 'ckpt_task-1' }], count: 1 }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await callGet('?section=checkpoints&productId=clawcowork&limit=10');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4009/api/agent-runtime/checkpoints?limit=10',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
'x-user-id': 'usr_admin',
|
||||
'x-product-id': 'clawcowork',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies Cowork checkpoint detail requests to cowork-service', async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
checkpoint: { checkpointId: 'ckpt_task-1' },
|
||||
detail: { goal: 'Review the repository' },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await callGet(
|
||||
'?section=checkpoint-detail&productId=clawcowork&checkpointId=ckpt_task-1'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4009/api/agent-runtime/checkpoints/ckpt_task-1',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
'x-user-id': 'usr_admin',
|
||||
'x-product-id': 'clawcowork',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies POST dispatch validation requests to platform-service', async () => {
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BadgeCheck, PlayCircle, RefreshCw, Send, TimerReset } from 'lucide-react';
|
||||
import { BadgeCheck, RefreshCw, Send, TimerReset } from 'lucide-react';
|
||||
import { createProxyFetch } from '@/lib/proxy-fetch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -34,6 +34,54 @@ type AgentRun = {
|
||||
correlationId?: string | null;
|
||||
};
|
||||
|
||||
type AgentCheckpoint = {
|
||||
checkpointId: string;
|
||||
sessionId: string;
|
||||
runId?: string | null;
|
||||
productId: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
statusAtCapture: string;
|
||||
currentTaskId?: string | null;
|
||||
checkpointArtifactId?: string | null;
|
||||
artifactRefs: string[];
|
||||
memoryRefs: string[];
|
||||
approvalRefs: string[];
|
||||
resumeToken?: string | null;
|
||||
stateSummary: {
|
||||
title: string;
|
||||
summary: string;
|
||||
lastActionAt?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type AgentCheckpointDetail = {
|
||||
checkpoint: AgentCheckpoint;
|
||||
detail: {
|
||||
goal: string;
|
||||
folder?: string | null;
|
||||
model?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedToolCalls: number;
|
||||
completed: boolean;
|
||||
cancelled: boolean;
|
||||
error?: string | null;
|
||||
eventId?: string | null;
|
||||
correlationId?: string | null;
|
||||
source: {
|
||||
observationKind: string;
|
||||
projectionKind: string;
|
||||
};
|
||||
observedRefs: {
|
||||
checkpointArtifactId?: string | null;
|
||||
artifactRefs: string[];
|
||||
memoryRefs: string[];
|
||||
approvalRefs: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const apiFetch = createProxyFetch('/api/agent-runtime');
|
||||
|
||||
function formatDate(iso: string | null | undefined) {
|
||||
@ -49,6 +97,10 @@ function formatDate(iso: string | null | undefined) {
|
||||
export default function AgentRuntimePage() {
|
||||
const [sessions, setSessions] = useState<AgentSession[]>([]);
|
||||
const [runs, setRuns] = useState<AgentRun[]>([]);
|
||||
const [checkpoints, setCheckpoints] = useState<AgentCheckpoint[]>([]);
|
||||
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
|
||||
const [checkpointDetail, setCheckpointDetail] = useState<AgentCheckpointDetail | null>(null);
|
||||
const [checkpointLoading, setCheckpointLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userId, setUserId] = useState('');
|
||||
const [limit, setLimit] = useState('20');
|
||||
@ -83,20 +135,54 @@ export default function AgentRuntimePage() {
|
||||
|
||||
const runParams = new URLSearchParams({ section: 'runs', limit });
|
||||
|
||||
const [sessionData, runData] = await Promise.all([
|
||||
const checkpointParams = new URLSearchParams({
|
||||
section: 'checkpoints',
|
||||
productId: 'clawcowork',
|
||||
limit,
|
||||
});
|
||||
|
||||
const [sessionData, runData, checkpointData] = await Promise.all([
|
||||
apiFetch(`?${sessionParams.toString()}`),
|
||||
apiFetch(`?${runParams.toString()}`),
|
||||
apiFetch(`?${checkpointParams.toString()}`),
|
||||
]);
|
||||
|
||||
setSessions(Array.isArray(sessionData?.sessions) ? sessionData.sessions : []);
|
||||
setRuns(Array.isArray(runData?.runs) ? runData.runs : []);
|
||||
const nextCheckpoints = Array.isArray(checkpointData?.checkpoints)
|
||||
? checkpointData.checkpoints
|
||||
: [];
|
||||
setCheckpoints(nextCheckpoints);
|
||||
setSelectedCheckpointId(current =>
|
||||
nextCheckpoints.some((checkpoint: AgentCheckpoint) => checkpoint.checkpointId === current)
|
||||
? current
|
||||
: (nextCheckpoints[0]?.checkpointId ?? null)
|
||||
);
|
||||
setLoading(false);
|
||||
}, [limit, userId]);
|
||||
|
||||
const loadCheckpointDetail = useCallback(async (checkpointId: string | null) => {
|
||||
if (!checkpointId) {
|
||||
setCheckpointDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckpointLoading(true);
|
||||
const detail = await apiFetch(
|
||||
`?section=checkpoint-detail&productId=clawcowork&checkpointId=${encodeURIComponent(checkpointId)}`
|
||||
);
|
||||
setCheckpointDetail(detail?.checkpoint ? detail : null);
|
||||
setCheckpointLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCheckpointDetail(selectedCheckpointId);
|
||||
}, [loadCheckpointDetail, selectedCheckpointId]);
|
||||
|
||||
async function validateDispatch() {
|
||||
try {
|
||||
const parsed = JSON.parse(dispatchPayload);
|
||||
@ -171,6 +257,16 @@ export default function AgentRuntimePage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Cowork Checkpoints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{checkpoints.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
@ -190,6 +286,7 @@ export default function AgentRuntimePage() {
|
||||
<TabsList>
|
||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||
<TabsTrigger value="runs">Runs</TabsTrigger>
|
||||
<TabsTrigger value="checkpoints">Cowork Checkpoints</TabsTrigger>
|
||||
<TabsTrigger value="dispatch">Dispatch Validation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@ -296,6 +393,226 @@ export default function AgentRuntimePage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="checkpoints">
|
||||
<div className="grid gap-4 xl:grid-cols-[1.2fr,0.8fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Checkpoint Summaries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading checkpoints...</div>
|
||||
) : checkpoints.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No Cowork checkpoints are currently available.
|
||||
</div>
|
||||
) : (
|
||||
checkpoints.map(checkpoint => (
|
||||
<button
|
||||
key={checkpoint.checkpointId}
|
||||
type="button"
|
||||
onClick={() => setSelectedCheckpointId(checkpoint.checkpointId)}
|
||||
className={`w-full rounded-lg border p-3 text-left transition ${
|
||||
selectedCheckpointId === checkpoint.checkpointId
|
||||
? 'border-emerald-500 bg-emerald-50/60'
|
||||
: 'hover:bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{checkpoint.stateSummary.title}</div>
|
||||
<Badge variant="outline">{checkpoint.statusAtCapture}</Badge>
|
||||
<Badge variant="outline">clawcowork</Badge>
|
||||
</div>
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
{checkpoint.stateSummary.summary}
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Checkpoint
|
||||
</div>
|
||||
<div className="font-mono text-xs">{checkpoint.checkpointId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Run
|
||||
</div>
|
||||
<div className="font-mono text-xs">{checkpoint.runId || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Last Action
|
||||
</div>
|
||||
<div>
|
||||
{formatDate(
|
||||
checkpoint.stateSummary.lastActionAt || checkpoint.createdAt
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Checkpoint Drill-In</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{checkpointLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading checkpoint detail...</div>
|
||||
) : !checkpointDetail ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Select a checkpoint to inspect direct Cowork observations and projected runtime
|
||||
summary fields.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-sky-50 text-sky-700 border-0">
|
||||
direct observation
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{checkpointDetail.detail.source.observationKind}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Goal
|
||||
</div>
|
||||
<div>{checkpointDetail.detail.goal}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Model
|
||||
</div>
|
||||
<div>{checkpointDetail.detail.model || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Folder
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
{checkpointDetail.detail.folder || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Tool Calls
|
||||
</div>
|
||||
<div>{checkpointDetail.detail.completedToolCalls}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Event ID
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
{checkpointDetail.detail.eventId || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Correlation
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
{checkpointDetail.detail.correlationId || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border-0">
|
||||
projected summary
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{checkpointDetail.detail.source.projectionKind}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div>{checkpointDetail.checkpoint.stateSummary.summary}</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Resume Token
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
{checkpointDetail.checkpoint.resumeToken || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Checkpoint Artifact
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
{checkpointDetail.detail.observedRefs.checkpointArtifactId || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Artifact Refs
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
{checkpointDetail.detail.observedRefs.artifactRefs.length > 0
|
||||
? checkpointDetail.detail.observedRefs.artifactRefs.map(ref => (
|
||||
<div key={ref} className="font-mono">
|
||||
{ref}
|
||||
</div>
|
||||
))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Memory Refs
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
{checkpointDetail.detail.observedRefs.memoryRefs.length > 0
|
||||
? checkpointDetail.detail.observedRefs.memoryRefs.map(ref => (
|
||||
<div key={ref} className="font-mono">
|
||||
{ref}
|
||||
</div>
|
||||
))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Approval Refs
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
{checkpointDetail.detail.observedRefs.approvalRefs.length > 0
|
||||
? checkpointDetail.detail.observedRefs.approvalRefs.map(ref => (
|
||||
<div key={ref} className="font-mono">
|
||||
{ref}
|
||||
</div>
|
||||
))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dispatch">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@ -3,6 +3,7 @@ import { getCurrentUserFromRequest } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
const COWORK_SERVICE_URL = process.env.COWORK_SERVICE_URL || 'http://localhost:4009';
|
||||
|
||||
function buildHeaders(req: NextRequest, callerId: string): Record<string, string> {
|
||||
return {
|
||||
@ -20,17 +21,37 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const url = new URL(req.url);
|
||||
const section = url.searchParams.get('section') ?? 'sessions';
|
||||
const productOverride = url.searchParams.get('productId')?.trim() || null;
|
||||
const qs = new URLSearchParams(url.searchParams);
|
||||
qs.delete('section');
|
||||
qs.delete('productId');
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : '';
|
||||
const targetPath =
|
||||
section === 'runs'
|
||||
? `/api/agent-runtime/runs${suffix}`
|
||||
: `/api/agent-runtime/sessions${suffix}`;
|
||||
const targetProductId =
|
||||
productOverride || req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai';
|
||||
const isCowork = targetProductId === 'clawcowork';
|
||||
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}`, {
|
||||
let targetBaseUrl = PLATFORM_URL;
|
||||
let targetPath = `/api/agent-runtime/sessions${suffix}`;
|
||||
if (section === 'runs') {
|
||||
targetPath = `/api/agent-runtime/runs${suffix}`;
|
||||
} else if (isCowork && section === 'checkpoints') {
|
||||
targetBaseUrl = COWORK_SERVICE_URL;
|
||||
targetPath = `/api/agent-runtime/checkpoints${suffix}`;
|
||||
} else if (isCowork && section === 'checkpoint-detail') {
|
||||
const checkpointId = url.searchParams.get('checkpointId')?.trim();
|
||||
if (!checkpointId) {
|
||||
return NextResponse.json({ error: 'checkpointId is required' }, { status: 400 });
|
||||
}
|
||||
targetBaseUrl = COWORK_SERVICE_URL;
|
||||
targetPath = `/api/agent-runtime/checkpoints/${encodeURIComponent(checkpointId)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${targetBaseUrl}${targetPath}`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(req, caller.id),
|
||||
headers: {
|
||||
...buildHeaders(req, caller.id),
|
||||
'x-product-id': targetProductId,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
@ -41,8 +41,8 @@ Build a shared ByteLyst ecosystem layer so products do not behave like isolated
|
||||
|
||||
### What Remains In The Roadmap
|
||||
|
||||
- optional product expansion, not baseline blockers:
|
||||
- richer Cowork checkpoint drill-in if product usage justifies it
|
||||
- no remaining mandatory roadmap items
|
||||
- optional future product expansion may continue if real usage justifies it
|
||||
|
||||
Canonical backlog:
|
||||
|
||||
@ -52,7 +52,8 @@ Canonical backlog:
|
||||
|
||||
- the implementation roadmap baseline is materially complete through Phase 5
|
||||
- the requested documentation hardening pass is complete
|
||||
- the remaining work is optional product expansion, not missing core baseline functionality
|
||||
- the currently tracked optional product enhancements are complete
|
||||
- any next work is net-new product expansion, not unfinished roadmap delivery
|
||||
|
||||
### Remaining Work Buckets
|
||||
|
||||
@ -106,11 +107,12 @@ Canonical backlog:
|
||||
- `learning_ai_flowmonk`
|
||||
Notes:
|
||||
- safe replay preview and guarded replay execution now exist for deterministic runtime actions
|
||||
- [ ] evaluate richer Cowork checkpoint drill-in
|
||||
- [x] evaluate richer Cowork checkpoint drill-in
|
||||
Owner:
|
||||
- `oss/learning_ai_claw-cowork`
|
||||
- `learning_ai_common_plat`
|
||||
Notes:
|
||||
- current checkpoint summaries and refs are sufficient for the baseline; richer drill-in is optional product expansion
|
||||
- `cowork-service` now exposes one-checkpoint drill-in and the hosted admin runtime console now distinguishes direct checkpoint observations from projected summary fields
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -89,6 +89,8 @@ Execution controls add complexity and should only be introduced if the review-on
|
||||
|
||||
### 2.2 Cowork richer checkpoint drill-in
|
||||
|
||||
> **Status:** implemented
|
||||
|
||||
> **Priority:** `medium`
|
||||
> **Owner repo:** `oss/learning_ai_claw-cowork`
|
||||
> **Type:** optional product enhancement
|
||||
@ -129,11 +131,11 @@ Richer drill-in is useful for power-user inspection, but not required to keep th
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- [ ] checkpoint drill-in shows meaningful incremental state beyond the summary row
|
||||
- [ ] linked artifact and memory refs can be opened or traced cleanly
|
||||
- [ ] session/task context remains clear throughout drill-in
|
||||
- [ ] the UI clearly distinguishes direct observations from projected summaries
|
||||
- [ ] Rust and service-level tests cover the richer checkpoint shape
|
||||
- [x] checkpoint drill-in shows meaningful incremental state beyond the summary row
|
||||
- [x] linked artifact and memory refs can be opened or traced cleanly
|
||||
- [x] session/task context remains clear throughout drill-in
|
||||
- [x] the UI clearly distinguishes direct observations from projected summaries
|
||||
- [x] Rust and service-level tests cover the richer checkpoint shape
|
||||
|
||||
#### Risks
|
||||
|
||||
@ -141,6 +143,18 @@ Richer drill-in is useful for power-user inspection, but not required to keep th
|
||||
- drill-in UI can expose implementation detail without user value
|
||||
- replay/resume affordances may be confused with guaranteed deterministic restoration
|
||||
|
||||
#### Implementation notes
|
||||
|
||||
- `cowork-service` now exposes a checkpoint-detail route for one persisted Cowork checkpoint
|
||||
- the hosted admin runtime console now includes a dedicated Cowork checkpoint tab with drill-in detail
|
||||
- the drill-in explicitly separates persisted Cowork observations from projected runtime summary fields
|
||||
- checkpoint-linked artifact, memory, and approval refs are surfaced directly in the review panel
|
||||
|
||||
#### Commits
|
||||
|
||||
- `7240de7` added direct checkpoint artifact IDs to Cowork runtime IPC
|
||||
- `59ae0e1` added the shared checkpoint artifact contract and projection mapping
|
||||
|
||||
---
|
||||
|
||||
## 3. Priority Guidance
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> **Repo:** `oss/learning_ai_claw-cowork`
|
||||
> **Ecosystem focus:** audited artifact producer for Phase 3
|
||||
> **Status:** existing producer surface reused
|
||||
> **Status:** runtime and checkpoint drill-in adopted
|
||||
|
||||
---
|
||||
|
||||
@ -45,6 +45,8 @@ Current runtime adoption state:
|
||||
- approvals and action logs can participate in ActionTrail replay
|
||||
- persisted checkpoint records now back runtime todo review and runtime checkpoint summaries
|
||||
- checkpoint summaries now preserve artifact, memory, and approval refs when Cowork provides them
|
||||
- hosted admin runtime review now supports Cowork-specific checkpoint drill-in
|
||||
- checkpoint drill-in now separates direct persisted Cowork observations from projected runtime summary fields
|
||||
- session and task IPC projections now expose canonical event IDs directly
|
||||
- the shared runtime contract now explicitly documents which Cowork states are direct Rust observations versus `cowork-service` projections
|
||||
|
||||
|
||||
@ -261,6 +261,74 @@ describe('agent runtime routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns richer checkpoint drill-in detail for one persisted checkpoint', async () => {
|
||||
call
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
status: 'running',
|
||||
createdAt: '2026-04-04T08:00:00.000Z',
|
||||
updatedAt: '2026-04-04T08:10:00.000Z',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
checkpoints: [
|
||||
{
|
||||
task_id: 'task-1',
|
||||
user_id: 'demo-user',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
folder: '/tmp/workspace',
|
||||
model: 'gpt-5.4',
|
||||
created_at: '2026-04-04T08:00:00.000Z',
|
||||
last_updated: '2026-04-04T08:10:00.000Z',
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
completed_tool_calls: 2,
|
||||
checkpoint_artifact_id: 'artifact://notelett/note-1',
|
||||
artifact_refs: ['artifact://notelett/note-1'],
|
||||
memory_refs: ['memory://mindlyst/memory-1'],
|
||||
approval_refs: ['approval://cowork/ap-1'],
|
||||
event_id: 'evt_cowork_task_task-1',
|
||||
correlation_id: 'evt_cowork_task_task-1',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/agent-runtime/checkpoints/ckpt_task-1',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.checkpoint).toMatchObject({
|
||||
checkpointId: 'ckpt_task-1',
|
||||
runId: 'task-1',
|
||||
sessionId: 'sess-1',
|
||||
checkpointArtifactId: 'artifact://notelett/note-1',
|
||||
});
|
||||
expect(body.detail).toMatchObject({
|
||||
goal: 'Review the repository and summarize changes',
|
||||
folder: '/tmp/workspace',
|
||||
model: 'gpt-5.4',
|
||||
completedToolCalls: 2,
|
||||
eventId: 'evt_cowork_task_task-1',
|
||||
correlationId: 'evt_cowork_task_task-1',
|
||||
source: {
|
||||
observationKind: 'persisted-checkpoint',
|
||||
projectionKind: 'agent-runtime-summary',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('projects approval audit records into AgentApprovalCheckpoint objects', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
type AgentTask,
|
||||
type AgentTodo,
|
||||
} from '@bytelyst/events';
|
||||
import { BadRequestError } from '@bytelyst/errors';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { config } from '../../lib/config.js';
|
||||
import { getIpcBridge } from '../../lib/ipc-bridge.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
@ -38,6 +38,16 @@ function asIsoString(value: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
function checkpointTaskIdFromId(checkpointId: string): string {
|
||||
return checkpointId.startsWith('ckpt_') ? checkpointId.slice(5) : checkpointId;
|
||||
}
|
||||
|
||||
function mapTaskStatus(status: unknown): AgentRun['status'] {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
@ -79,15 +89,9 @@ function toAgentSession(session: Record<string, unknown>, fallbackNow: string):
|
||||
typeof session.currentTaskId === 'string' && session.currentTaskId.length > 0
|
||||
? session.currentTaskId
|
||||
: null,
|
||||
memoryRefs: Array.isArray(session.memoryRefs)
|
||||
? session.memoryRefs.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
artifactRefs: Array.isArray(session.artifactRefs)
|
||||
? session.artifactRefs.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
approvalRefs: Array.isArray(session.approvalRefs)
|
||||
? session.approvalRefs.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
memoryRefs: stringList(session.memoryRefs),
|
||||
artifactRefs: stringList(session.artifactRefs),
|
||||
approvalRefs: stringList(session.approvalRefs),
|
||||
dispatchContext: {
|
||||
originSurface: 'desktop',
|
||||
originProductId: PRODUCT_ID,
|
||||
@ -279,9 +283,7 @@ function toAgentCheckpoint(
|
||||
: 'checkpoint available for resume',
|
||||
].join('; ');
|
||||
|
||||
const artifactRefs = Array.isArray(checkpoint.artifact_refs)
|
||||
? checkpoint.artifact_refs.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
const artifactRefs = stringList(checkpoint.artifact_refs);
|
||||
const checkpointArtifactId =
|
||||
typeof checkpoint.checkpoint_artifact_id === 'string' &&
|
||||
checkpoint.checkpoint_artifact_id.length > 0
|
||||
@ -304,12 +306,8 @@ function toAgentCheckpoint(
|
||||
checkpointArtifactId,
|
||||
todoIds: [`todo_${taskId}`],
|
||||
artifactRefs,
|
||||
memoryRefs: Array.isArray(checkpoint.memory_refs)
|
||||
? checkpoint.memory_refs.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
approvalRefs: Array.isArray(checkpoint.approval_refs)
|
||||
? checkpoint.approval_refs.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
memoryRefs: stringList(checkpoint.memory_refs),
|
||||
approvalRefs: stringList(checkpoint.approval_refs),
|
||||
dispatchContext: {
|
||||
originSurface: 'desktop',
|
||||
originProductId: PRODUCT_ID,
|
||||
@ -325,6 +323,62 @@ function toAgentCheckpoint(
|
||||
});
|
||||
}
|
||||
|
||||
function toCheckpointDetail(
|
||||
task: Record<string, unknown> | null,
|
||||
checkpoint: Record<string, unknown>,
|
||||
fallbackNow: string
|
||||
) {
|
||||
const projected = toAgentCheckpoint(task, checkpoint, fallbackNow);
|
||||
return {
|
||||
checkpoint: projected,
|
||||
detail: {
|
||||
goal:
|
||||
typeof checkpoint.goal === 'string' && checkpoint.goal.trim().length > 0
|
||||
? checkpoint.goal.trim()
|
||||
: projected.stateSummary.title,
|
||||
folder:
|
||||
typeof checkpoint.folder === 'string' && checkpoint.folder.length > 0
|
||||
? checkpoint.folder
|
||||
: null,
|
||||
model:
|
||||
typeof checkpoint.model === 'string' && checkpoint.model.length > 0
|
||||
? checkpoint.model
|
||||
: null,
|
||||
createdAt: asIsoString(checkpoint.created_at, projected.createdAt),
|
||||
updatedAt: asIsoString(
|
||||
checkpoint.last_updated,
|
||||
projected.stateSummary.lastActionAt ?? projected.createdAt
|
||||
),
|
||||
completedToolCalls:
|
||||
typeof checkpoint.completed_tool_calls === 'number' ? checkpoint.completed_tool_calls : 0,
|
||||
completed: checkpoint.completed === true,
|
||||
cancelled: checkpoint.cancelled === true,
|
||||
error:
|
||||
typeof checkpoint.error === 'string' && checkpoint.error.length > 0
|
||||
? checkpoint.error
|
||||
: null,
|
||||
eventId:
|
||||
typeof checkpoint.event_id === 'string' && checkpoint.event_id.length > 0
|
||||
? checkpoint.event_id
|
||||
: null,
|
||||
correlationId:
|
||||
typeof checkpoint.correlation_id === 'string' && checkpoint.correlation_id.length > 0
|
||||
? checkpoint.correlation_id
|
||||
: null,
|
||||
source: {
|
||||
observationKind: 'persisted-checkpoint',
|
||||
projectionKind: 'agent-runtime-summary',
|
||||
},
|
||||
observedRefs: {
|
||||
checkpointArtifactId: projected.checkpointArtifactId ?? null,
|
||||
artifactRefs: projected.artifactRefs,
|
||||
memoryRefs: projected.memoryRefs,
|
||||
approvalRefs: projected.approvalRefs,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapApprovalStatus(action: unknown): AgentApprovalCheckpoint['status'] {
|
||||
switch (action) {
|
||||
case 'approval_granted':
|
||||
@ -641,6 +695,34 @@ export async function agentRuntimeRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/agent-runtime/checkpoints/:checkpointId', async req => {
|
||||
if (!bridge.isRunning) {
|
||||
throw new NotFoundError('Checkpoint not found');
|
||||
}
|
||||
|
||||
const checkpointId = checkpointTaskIdFromId(
|
||||
(req.params as { checkpointId: string }).checkpointId ?? ''
|
||||
);
|
||||
if (!checkpointId) {
|
||||
throw new BadRequestError('checkpointId is required');
|
||||
}
|
||||
|
||||
const [rawTasks, checkpoints] = await Promise.all([fetchTasks(req), fetchCheckpoints(req)]);
|
||||
const rawCheckpoint = checkpoints.find(
|
||||
checkpoint => typeof checkpoint.task_id === 'string' && checkpoint.task_id === checkpointId
|
||||
);
|
||||
if (!rawCheckpoint) {
|
||||
throw new NotFoundError('Checkpoint not found');
|
||||
}
|
||||
|
||||
const task =
|
||||
rawTasks.find(rawTask => typeof rawTask.id === 'string' && rawTask.id === checkpointId) ??
|
||||
null;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return toCheckpointDetail(task, rawCheckpoint, now);
|
||||
});
|
||||
|
||||
app.get('/api/agent-runtime/approvals', async req => {
|
||||
const records = await fetchApprovalRecords(req);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user