Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
65b87ad
prototype(web): whole-session zoomable resource timeline
raphaeltm Sep 29, 2026
27166bc
prototype(web): resource timeline interaction and readability fixes
raphaeltm Sep 29, 2026
5432dee
test(web): cover the resource timeline data layer
raphaeltm Sep 29, 2026
267f462
test(web): drive the new resource drawer from the real chat rail
raphaeltm Sep 29, 2026
6e50164
task: start whole-session resource timeline
raphaeltm Sep 29, 2026
0e2c69d
feat(api): add resource chunk rollup column and timeline env knobs
raphaeltm Sep 29, 2026
5dae57d
feat(api): whole-session resource timeline index, per-minute chunk ro…
raphaeltm Sep 29, 2026
efd5992
test(api): cover resource rollups and the session timeline on a real …
raphaeltm Sep 29, 2026
dc63915
feat(web): read the whole-session resource timeline, drop the prototy…
raphaeltm Sep 29, 2026
e7e3413
fix(web): don't call a truncated resource history the whole session
raphaeltm Sep 29, 2026
c6aabc8
test(api): seed attribution tables in the timeline harness after rebase
raphaeltm Sep 29, 2026
889066b
feat: expired-history state; rewrite Resources docs for the whole-ses…
raphaeltm Sep 29, 2026
9183616
chore(web): sort API exports
raphaeltm Sep 29, 2026
a6bec90
refactor(api): keep rollup upload helper in the rollup module; test f…
raphaeltm Sep 29, 2026
2b68a4b
task: record timeline implementation progress
raphaeltm Sep 29, 2026
7bc9cd6
perf: coalesce timeline gestures per frame; let the index serve the t…
raphaeltm Sep 29, 2026
0d243a0
test(web): drive hover, drag-select, peak zoom, tap and pinch on the …
raphaeltm Sep 29, 2026
b37d122
fix: add uplot to the lockfile without re-resolving better-auth
raphaeltm Sep 29, 2026
1a7eb97
task: backlog chat-page 404s for a session's deleted workspace
raphaeltm Sep 29, 2026
318c3a2
chore: record direct dependency evidence for uplot
raphaeltm Sep 29, 2026
af65f2f
fix(web): a resting mouse no longer overwrites a keyboard-selected mo…
raphaeltm Sep 29, 2026
d5730ba
chore: refresh review evidence after policy change
raphaeltm Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/api-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ user-invocable: false
- `GET /api/projects/:projectId/sessions/:sessionId/state` — Get lightweight ACP activity state for a chat session
- `GET /api/projects/:projectId/sessions/:sessionId/messages` — List persisted session messages (supports `roles`, `before`, `after`, `limit`, `compact`, `order=asc|desc`). `before`/`after` accept an exact `[createdAt,sequence,id]` cursor taken from a page's edge row — required to resume inside a group of rows sharing one timestamp — or a legacy millisecond timestamp that excludes every row at that time. Without `order`, an `after`-only request reads ascending; anything else reads newest-first. Pages are always returned oldest-first.
- `GET /api/projects/:projectId/sessions/:sessionId/messages/:messageId/tool-content` — Lazy-load stored tool content for compact messages, falling back to the private R2 archive when inline payloads have been stripped
- `GET /api/projects/:projectId/sessions/:sessionId/resource-timeline` — Whole-session resource timeline index: every retained chunk (ascending) with its summary and per-minute `rollup`, `runs` per workspace with `reservation`, `omittedChunkCount` past `WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS`, and `collection` (`collected`/`pending`/`unsupported`/`expired`) explaining an empty timeline
- `GET /api/projects/:projectId/sessions/:sessionId/resource-timeline/chunks/:chunkId` — One chunk's 5-second samples and tool spans, scoped to the path's project and session (foreign chunk → 404)
- `GET /api/projects/:projectId/sessions/:sessionId/comments` — List message-anchored comment threads (supports `messageId`, `status=open|sent|resolved`, `afterSequence`, `limit`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments` — Create a message-anchored comment thread (`{ messageId, body, quote?, clientMutationId? }`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/replies` — Append a comment reply (`{ body, clientMutationId? }`)
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/env-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Activity coalescing and binding caches are per Worker isolate. Delayed flushes c
- `NODE_STOPPED_HANDOFF_REQUEST_TIMEOUT_MS` — Per-candidate provider/DNS deadline during stopped-node handoff, capped by remaining sweep time; provider failures enter cleanup backoff (default: `5000`)
- `IDLE_CLEANUP_MAX_RESIDENCE_MS` — Maximum ProjectData idle-cleanup schedule residence before preserved/error outcomes stop re-arming and surface attention (default: `7200000`)
- `IDLE_CLEANUP_MAX_CANDIDATES_PER_SWEEP` — Maximum idle-cleanup schedules or workspace idle checks one ProjectData alarm pass takes, and maximum reporter-scoped task candidates inspected per check (default: `5`)
- `WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS` — Max chunks the session resource timeline lists; older ones are disclosed as omitted (default: `1000`)
- `WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS` / `WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS` — Per-chunk rollup bucket width computed on upload, and the bucket cap that widens it (defaults: `60000` / `60`)
- `WORKSPACE_IDLE_TIMEOUT_MS` — Installation default for how long an active chat session's workspace can go without messages or terminal activity before ProjectData retires it, once its runtime is conclusively dead; the project Workspace Idle Timeout setting overrides it (default: `7200000`)
- `WORKSPACE_IDLE_BACKOFF_BASE_MS` — First retry delay after a ProjectData workspace-idle check finds an idle workspace it cannot retire yet: inconclusive task candidates, a live or unprovable runtime, a missing project identity, or a failed check (default: `600000`; `apps/api/src/durable-objects/project-data/workspace-idle-timeouts.ts`)
- `WORKSPACE_IDLE_BACKOFF_MAX_MS` — Maximum workspace-idle retry delay; the delay doubles from the base and resets on new activity or when the session wakes (default: `21600000`)
Expand Down
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,9 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000
# WORKSPACE_RESOURCE_LIST_LIMIT=24
# WORKSPACE_RESOURCE_CLEANUP_BATCH_SIZE=50
# WORKSPACE_RESOURCE_OBJECT_CLEANUP_LIMIT=5000
# WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS=1000
# WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS=60000
# WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS=60
# PROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_ENABLED=false # Separate fail-closed switch for the unscoped scheduled archive-sharding sweep
# PROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_INTERVAL_MS=86400000 # Persisted cadence gate for unscoped scheduled archive-sharding sweeps (code fallback daily; wrangler.toml ships 1080000: one claim per 20 min on the 5-min cron)
# PROJECT_DATA_ARCHIVE_SHARD_COUNT=128 # Deterministic ProjectData archive shard fanout
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Per-minute resource rollup for each stored chunk, computed on upload from the
-- decoded chunk payload. It lets the session resource timeline draw a whole
-- multi-day session from D1 alone, without downloading every R2 chunk.
--
-- Existing rows stay NULL. They remain fully readable: the timeline falls back
-- to the chunk's own summary_json (one aggregate spanning the chunk), and the
-- full-resolution detail is still read from R2 on zoom.
ALTER TABLE workspace_resource_chunks ADD COLUMN rollup_json TEXT;
2 changes: 2 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,8 @@ export const workspaceResourceChunks = sqliteTable(
toolSpanCount: integer('tool_span_count').notNull().default(0),
completenessJson: text('completeness_json').notNull(),
summaryJson: text('summary_json').notNull(),
/** Per-minute rollup computed on upload; NULL for chunks uploaded before migration 0177. */
rollupJson: text('rollup_json'),
createdAt: integer('created_at').notNull(),
expiresAt: integer('expires_at').notNull(),
uploadedByNodeId: text('uploaded_by_node_id'),
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,9 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv {
WORKSPACE_RESOURCE_LIST_LIMIT?: string; // Max chunk indexes returned by detail list (default: 24)
WORKSPACE_RESOURCE_CLEANUP_BATCH_SIZE?: string; // Max expired chunks/summaries cleaned per sweep (default: 50)
WORKSPACE_RESOURCE_OBJECT_CLEANUP_LIMIT?: string; // Max R2 objects deleted for one project/workspace resource-history prefix cleanup (default: 5000)
WORKSPACE_RESOURCE_TIMELINE_MAX_CHUNKS?: string; // Max chunks listed by the whole-session resource timeline index; older ones are disclosed as omitted (default: 1000)
WORKSPACE_RESOURCE_ROLLUP_BUCKET_MS?: string; // Width of each per-chunk rollup bucket computed on upload (default: 60000)
WORKSPACE_RESOURCE_ROLLUP_MAX_BUCKETS?: string; // Max rollup buckets stored per chunk; the bucket width widens to fit (default: 60)
PROJECT_DATA_STORAGE_TELEMETRY_LIST_LIMIT_DEFAULT?: string;
PROJECT_DATA_STORAGE_TELEMETRY_LIST_LIMIT_MAX?: string;
PROJECT_DATA_TOOL_PAYLOAD_CLEANUP_ENABLED?: string;
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/routes/projects/workspace-resource-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { getUserId } from '../../middleware/auth';
import { errors } from '../../middleware/error';
import { requireProjectAccess } from '../../middleware/project-auth';
import { getWorkspaceResourceHistory } from '../../services/workspace-resource-history';
import {
getSessionResourceTimeline,
getSessionResourceTimelineChunk,
} from '../../services/workspace-resource-timeline';

const projectResourceHistoryRoutes = new Hono<{ Bindings: Env }>();

Expand Down Expand Up @@ -38,6 +42,25 @@ projectResourceHistoryRoutes.get('/:id/sessions/:sessionId/resource-history', as
);
});

projectResourceHistoryRoutes.get('/:id/sessions/:sessionId/resource-timeline', async (c) => {
const projectId = c.req.param('id');
const sessionId = c.req.param('sessionId');
await requireAccess(c.env, projectId, getUserId(c));
return c.json(await getSessionResourceTimeline(c.env, { projectId, sessionId }));
});

projectResourceHistoryRoutes.get(
'/:id/sessions/:sessionId/resource-timeline/chunks/:chunkId',
async (c) => {
const projectId = c.req.param('id');
const sessionId = c.req.param('sessionId');
await requireAccess(c.env, projectId, getUserId(c));
const chunkId = optionalDetailChunkId(c.req.param('chunkId'));
if (!chunkId) throw errors.badRequest('chunkId is required');
return c.json(await getSessionResourceTimelineChunk(c.env, { projectId, sessionId, chunkId }));
}
);

projectResourceHistoryRoutes.get('/:id/tasks/:taskId/resource-history', async (c) => {
const projectId = c.req.param('id');
const taskId = c.req.param('taskId');
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/services/workspace-resource-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { log } from '../lib/logger';
import { parsePositiveInt } from '../lib/route-helpers';
import { parseJsonRecord } from '../lib/runtime-validation';
import { AppError, errors } from '../middleware/error';
import { buildStoredRollupJson } from './workspace-resource-rollup';

export const WORKSPACE_RESOURCE_STORAGE_FORMAT = 'resource-history-gzip-json-v1';
const R2_PREFIX = 'resource-history/v1';
Expand Down Expand Up @@ -629,6 +630,7 @@ async function validateWorkspaceResourceChunkBytes(
actualSha: string;
compressedBytes: number;
uncompressedBytes: number;
payload: WorkspaceResourceChunkPayload;
}> {
const bytes = decodeBase64(body.compressedBase64);
if (bytes.byteLength !== body.compressedBytes) {
Expand Down Expand Up @@ -671,6 +673,7 @@ async function validateWorkspaceResourceChunkBytes(
actualSha: await sha256Hex(sanitized.bytes),
compressedBytes: sanitized.bytes.byteLength,
uncompressedBytes: sanitized.uncompressedBytes,
payload: decodedChunk.value,
};
}

Expand All @@ -694,7 +697,7 @@ export async function storeWorkspaceResourceChunk(
);
const nodeId = validateWorkspaceUploadIdentity(workspace, body, uploadedByNodeId);
assertWorkspaceResourceUploadMetadata(body);
const { bytes, actualSha, compressedBytes, uncompressedBytes } =
const { bytes, actualSha, compressedBytes, uncompressedBytes, payload } =
await validateWorkspaceResourceChunkBytes(env, body);

const db = drizzle(env.DATABASE, { schema });
Expand Down Expand Up @@ -911,6 +914,11 @@ export async function storeWorkspaceResourceChunk(
toolSpanCount: body.toolSpanCount ?? 0,
completenessJson,
summaryJson,
rollupJson: buildStoredRollupJson(env, payload, body, {
projectId,
workspaceId,
chunkSequence: body.chunkSequence,
}),
createdAt: now,
expiresAt,
uploadedByNodeId,
Expand Down Expand Up @@ -1031,7 +1039,7 @@ export function downsamplePreservingSpikes(
return { samples: selected, downsampled: true };
}

async function readChunkPayload(env: Env, chunk: schema.WorkspaceResourceChunkRow) {
export async function readChunkPayload(env: Env, chunk: schema.WorkspaceResourceChunkRow) {
const object = await env.PROJECT_DATA_ARCHIVE_R2.get(chunk.r2Key);
if (!object) throw errors.notFound('Resource history chunk');
const compressedBytes = new Uint8Array(await object.arrayBuffer());
Expand Down
Loading
Loading