mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(story-mode): the rest of the PR review: lookups, polling, frame budget, linear links
From review round 7 (PR review): - With no hook path (normal after a server restart), the transcript was looked up by conversation id only; it now also tries the Codeman id and a recovered session's `restored-<fragment>` transcript, as last-response does, and keeps what it found briefly (a lookup reads every project folder; a miss is kept only 4 s). - A session Story Mode cannot draw is not polled, and a page with nothing drawn yet is polled like a finished one (every 15 s), not every 4 s. - Helper steps: one request per helper is drawn, the newest; a failed refetch never writes over steps already shown. - The Director's cut toggle, a skin change and the fonts arriving redraw within the frame budget, not every panel in one task. - A bare name under an `ls` header finds its folder in one pass over the lines; a search per link over the text before it was quadratic (about 1.5 s for 700 links on agent output). - A page of many questions still keeps to 12 panels: questions fold last. - Opening Story Mode while it is open never starts a second runtime; the status announcer carries data-i18n-skip; the turn key in the ETag is clipped. - The crafted-input timing test runs at sizes where the old code took 6 to 29 s and the new one under 10 ms, with a 1.5 s bound. - Docs: `older`, `origin` (`scheduled`), which route answers a 500; mobile.css's stale comment; an orphaned doc comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e7ce3b8e56
commit
42d1c75a19
10 files changed
+191
-41
No files matched your search
+9
-5
@@ -1860,10 +1860,10 @@ interface DraftPanel {
|
||||
rounds?: number;
|
||||
}
|
||||
|
||||
/** Keeps the newest outputs, hunks and helper texts within the page budget. */
|
||||
/** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */
|
||||
const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? []));
|
||||
|
||||
/** Keeps the newest outputs, hunks and helper texts within the page budget; true when it cut any. */
|
||||
function applyTextBudget(events: StoryEvent[]): boolean {
|
||||
let used = 0;
|
||||
let cut = false;
|
||||
@@ -2000,9 +2000,10 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] {
|
||||
const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0;
|
||||
while (out.length > max) {
|
||||
let best = -1;
|
||||
// Three passes: quiet kinds; then anything but a prompt or a question; then, as a last resort, a
|
||||
// prompt you added mid-turn (the turn's own prompt and a question to you are never folded).
|
||||
search: for (const pass of [0, 1, 2]) {
|
||||
// Four passes: quiet kinds; then anything but a prompt or a question; then a prompt you added
|
||||
// mid-turn; then, only so the page keeps its 12 panels, a question too (the turn's own prompt
|
||||
// is never folded).
|
||||
search: for (const pass of [0, 1, 2, 3]) {
|
||||
for (const lastStart of [out.length - 3, out.length - 2]) {
|
||||
let oldest = -1;
|
||||
for (let k = firstMergeable; k <= lastStart; k++) {
|
||||
@@ -2013,7 +2014,10 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] {
|
||||
? MINOR.has(a.kind) && MINOR.has(b.kind)
|
||||
: pass === 1
|
||||
? ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask')
|
||||
: ![a, b].some((d) => d.kind === 'ask' || (d.kind === 'mission' && d.events[0] === out[0]?.events[0]));
|
||||
: ![a, b].some(
|
||||
(d) =>
|
||||
(pass === 2 && d.kind === 'ask') || (d.kind === 'mission' && d.events[0] === out[0]?.events[0])
|
||||
);
|
||||
if (!ok) continue;
|
||||
if (a.kind === 'montage' || b.kind === 'montage') {
|
||||
best = k;
|
||||
|
||||
+2
-1
@@ -244,7 +244,8 @@ async function loadFrom(
|
||||
info.size - cached.offset <= STORY_TAIL_BYTES &&
|
||||
info.size >= cached.size
|
||||
) {
|
||||
// The file only grew: read what was appended since the last complete line.
|
||||
// The file only grew: read what was appended since the last complete line. (A file rewritten to
|
||||
// a larger size would read as an append; transcripts are append-only, so it never is.)
|
||||
const buf = await readRange(handle, cached.offset, info.size);
|
||||
await ingest(cached, buf, false);
|
||||
cached.size = info.size;
|
||||
|
||||
@@ -47,6 +47,44 @@ export function claudeProjectsDir(configDir?: string): string {
|
||||
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one top-level transcript a recovered session (`restored-<first 8+ hex of its uuid>`, how
|
||||
* reconcileSessions() names a session whose mux record was lost) belongs to, or null when none or
|
||||
* more than one matches. The same recovery `last-response` makes (session-routes.ts).
|
||||
*/
|
||||
export async function findRestoredClaudeTranscriptPath(
|
||||
codemanSessionId: string,
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
const restored = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
|
||||
if (!restored) return null;
|
||||
const fragment = restored[1].toLowerCase();
|
||||
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
const projectsDir = claudeProjectsDir(configDir);
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const found = new Map<string, string>();
|
||||
for (const projectDir of projectDirs) {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(join(projectsDir, projectDir));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
const id = file.endsWith('.jsonl') ? file.slice(0, -'.jsonl'.length) : '';
|
||||
if (!id.toLowerCase().startsWith(fragment) || !uuid.test(id)) continue;
|
||||
const path = join(projectsDir, projectDir, file);
|
||||
if ((await stat(path).catch(() => null))?.isFile()) found.set(id, path);
|
||||
}
|
||||
}
|
||||
return found.size === 1 ? [...found.values()][0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path of the transcript for `conversationId` under any project directory, or null. Unlike
|
||||
* {@link claudeTranscriptExists} it answers with the file (Story Mode reads it), so the id is
|
||||
|
||||
@@ -590,8 +590,8 @@ html.mobile-init .file-browser-panel {
|
||||
(inline approve/deny in mobile-overview.js); the bell would only crowd the
|
||||
header it was designed to stay out of. */
|
||||
.btn-icon-header.btn-approvals,
|
||||
/* Story Mode: opt-in and desktop-first for now; the overlay itself is responsive, a phone
|
||||
entry point is a follow-up rather than one more icon in this header. */
|
||||
/* Story Mode: phones open it from Session Options (#sessionOptionsStoryBtn), not one more icon
|
||||
in this header; the overlay itself is responsive. */
|
||||
.btn-icon-header.btn-story-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1570,7 +1570,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
const st = this._storyState();
|
||||
st.returnFocus = document.activeElement;
|
||||
// Opened again while open (another session): one runtime, never two frame loops and polls.
|
||||
if (st.open) this._storyStopRuntime();
|
||||
else st.returnFocus = document.activeElement;
|
||||
st.open = true;
|
||||
st.sessionId = sessionId;
|
||||
st.turnId = null;
|
||||
@@ -1703,6 +1705,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
bar.append(brand, nav, seg, live, saveBtn, closeBtn);
|
||||
// What a screen reader hears as the page changes: a new turn, a finished one.
|
||||
const announcer = el('p', 'story-sr');
|
||||
announcer.dataset.i18nSkip = ''; // it reads out the agent's prompts
|
||||
announcer.setAttribute('role', 'status');
|
||||
announcer.setAttribute('aria-live', 'polite');
|
||||
|
||||
@@ -1846,7 +1849,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
st.dirBtn.setAttribute('aria-pressed', String(on));
|
||||
st.wire.hidden = !on;
|
||||
if (on) this._storyRenderWire();
|
||||
for (const rt of st.panels) this._storyDrawPanel(rt);
|
||||
// Within the frame budget, never every panel in one task.
|
||||
for (const rt of st.panels) this._storyQueueDraw(rt);
|
||||
},
|
||||
|
||||
_storyPickTurn(id) {
|
||||
@@ -2040,10 +2044,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
st.pollTimer = setInterval(() => {
|
||||
const page = st.data?.page;
|
||||
if (!st.open || document.hidden) return;
|
||||
if (!st.turnId && (!page || !page.complete)) {
|
||||
// A session Story Mode cannot draw does not change that by itself: nothing to poll.
|
||||
if (st.data && st.data.supported === false) return;
|
||||
if (!st.turnId && page && !page.complete && !page.interrupted) {
|
||||
this._storyRefresh();
|
||||
return;
|
||||
}
|
||||
// Nothing drawn yet (no transcript), a finished page or one parked on: now and then only.
|
||||
// A finished newest page: look again now and then (a local command like /compact raises no
|
||||
// hook). An unchanged page is a 304.
|
||||
// Parked on an older turn: the turn list still learns about new turns now and then.
|
||||
@@ -2082,14 +2089,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
window.addEventListener('keydown', st.keyHandler, true);
|
||||
st.skinObserver = new MutationObserver(() => {
|
||||
this._storyApplyTheme();
|
||||
for (const rt of st.panels) this._storyDrawPanel(rt);
|
||||
for (const rt of st.panels) this._storyQueueDraw(rt);
|
||||
});
|
||||
st.skinObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-skin', 'class'] });
|
||||
document.fonts?.ready?.then(() => {
|
||||
if (!st.open) return;
|
||||
for (const rt of st.panels) {
|
||||
rt.bgKey = '';
|
||||
this._storyDrawPanel(rt);
|
||||
this._storyQueueDraw(rt);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -3114,7 +3121,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const m = /^([^@]*)@[^:]*:(\d+)$/.exec(key);
|
||||
back =
|
||||
opens.find((n) => n.dataset.storyOpen === key) ||
|
||||
(m && opens.find((n) => new RegExp(`^${m[1]}@[^:]*:${m[2]}$`).test(n.dataset.storyOpen)));
|
||||
(m &&
|
||||
opens.find((n) => n.dataset.storyOpen.startsWith(`${m[1]}@`) && n.dataset.storyOpen.endsWith(`:${m[2]}`)));
|
||||
}
|
||||
if (refocus && back && document.contains(back)) back.focus({ preventScroll: true });
|
||||
},
|
||||
@@ -3362,10 +3370,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
p.textContent = 'Reading its steps…';
|
||||
box.replaceChildren(p);
|
||||
}
|
||||
// One request per helper at a time, and only the newest one's answer is drawn.
|
||||
st.helperSeq ??= new Map();
|
||||
const seq = (st.helperSeq.get(agentId) || 0) + 1;
|
||||
st.helperSeq.set(agentId, seq);
|
||||
const res = await this._api(
|
||||
`/api/sessions/${encodeURIComponent(st.sessionId)}/story/helpers/${encodeURIComponent(agentId)}`
|
||||
);
|
||||
if (!box.isConnected || !st.open) return;
|
||||
if (!box.isConnected || !st.open || st.helperSeq.get(agentId) !== seq) return;
|
||||
const etag = res?.ok ? res.headers?.get?.('etag') || null : null;
|
||||
if (cached && etag && etag === cached.etag) return;
|
||||
let body = null;
|
||||
@@ -3376,7 +3388,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (!box.isConnected || !st.open) return;
|
||||
if (!body?.success) {
|
||||
if (!cached) box.firstChild.textContent = 'Its steps could not be read.';
|
||||
// Only over the placeholder: never over steps another answer already drew.
|
||||
const only = box.firstElementChild;
|
||||
if (box.childElementCount === 1 && only?.classList.contains('story-empty'))
|
||||
only.textContent = 'Its steps could not be read.';
|
||||
return;
|
||||
}
|
||||
st.helperSteps?.set(agentId, { etag, data: body.data });
|
||||
@@ -3569,6 +3584,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
)
|
||||
: [];
|
||||
const workingDir = this.sessions?.get(this._story.sessionId)?.workingDir;
|
||||
// The `ls` header (`some/dir:`) in force on each line: the newest one above it in its paragraph.
|
||||
// One pass over the lines (a search per link over the text before it was quadratic).
|
||||
const lineDirs = [];
|
||||
const lineEnds = [];
|
||||
{
|
||||
let dir = null;
|
||||
let at = 0;
|
||||
for (const line of value.split('\n')) {
|
||||
lineDirs.push(dir);
|
||||
at += line.length + 1;
|
||||
lineEnds.push(at);
|
||||
if (!line.trim()) dir = null;
|
||||
else {
|
||||
const m = /^([^\s:][^:]*):[ \t]*$/.exec(line);
|
||||
if (m) dir = m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
let lineNo = 0;
|
||||
let last = 0;
|
||||
for (const link of links) {
|
||||
pre.append(document.createTextNode(this._storyShortPath(value.slice(last, link.index))));
|
||||
@@ -3579,15 +3613,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (b.textContent !== link.path) b.title = link.path;
|
||||
// A bare name listed under an `ls` header (`some/dir:`) lives in that directory.
|
||||
let target = link.path;
|
||||
if (!target.includes('/')) {
|
||||
const block =
|
||||
value
|
||||
.slice(0, link.index)
|
||||
.split(/\n\s*\n/)
|
||||
.pop() || '';
|
||||
const header = /(?:^|\n)([^\s:][^\n:]*):[ \t]*\n(?![\s\S]*\n[^\s:][^\n:]*:[ \t]*\n)/.exec(block);
|
||||
if (header) target = `${header[1].replace(/\/+$/, '')}/${target}`;
|
||||
}
|
||||
while (lineNo < lineEnds.length - 1 && lineEnds[lineNo] <= link.index) lineNo++;
|
||||
const dir = lineDirs[lineNo];
|
||||
if (!target.includes('/') && dir) target = `${dir.replace(/\/+$/, '')}/${target}`;
|
||||
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(target, workingDir) : target;
|
||||
b.addEventListener('click', (e) => this._storyOpenFile(resolved, e.currentTarget));
|
||||
pre.append(b);
|
||||
|
||||
@@ -32,7 +32,12 @@ import {
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { STORY_LIMITS, buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
|
||||
import { readSmallRegularFile, readStoryEntries, storyFileVersion } from '../../story-reader.js';
|
||||
import { claudeProjectsDir, findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
|
||||
import {
|
||||
claudeProjectsDir,
|
||||
findClaudeTranscriptPath,
|
||||
findRestoredClaudeTranscriptPath,
|
||||
} from '../../utils/claude-transcript.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import type { ConfigPort, SessionPort } from '../ports/index.js';
|
||||
|
||||
@@ -69,10 +74,40 @@ async function insideClaudeProjects(path: string, configDir?: string): Promise<b
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The hook-reported path when it is a transcript in a projects folder, else a lookup by conversation id. */
|
||||
/**
|
||||
* Transcripts found by id, briefly: a lookup reads every project folder, and a view without a hook
|
||||
* path (normal after a server restart) asks every few seconds. A miss is kept shorter, so a session
|
||||
* whose first prompt is on its way finds its transcript soon.
|
||||
*/
|
||||
const lookups = new LRUMap<string, { path: string | null; at: number }>({ maxSize: 200 });
|
||||
const LOOKUP_HIT_MS = 30_000;
|
||||
const LOOKUP_MISS_MS = 4_000;
|
||||
|
||||
/** For tests: forget the transcripts found by id. */
|
||||
export function clearStoryLookupCache(): void {
|
||||
lookups.clear();
|
||||
}
|
||||
|
||||
/** The transcript by conversation id, then by Codeman id, then a recovered session's, as last-response finds it. */
|
||||
async function lookUpTranscript(ids: string[], codemanId: string, configDir?: string): Promise<string | null> {
|
||||
const key = `${ids.join('|')}|${configDir ?? ''}`;
|
||||
const hit = lookups.get(key);
|
||||
if (hit && Date.now() - hit.at < (hit.path ? LOOKUP_HIT_MS : LOOKUP_MISS_MS)) return hit.path;
|
||||
let path: string | null = null;
|
||||
for (const id of ids) {
|
||||
path = await findClaudeTranscriptPath(id, configDir);
|
||||
if (path) break;
|
||||
}
|
||||
path ??= await findRestoredClaudeTranscriptPath(codemanId, configDir);
|
||||
lookups.set(key, { path, at: Date.now() });
|
||||
return path;
|
||||
}
|
||||
|
||||
/** The hook-reported path when it is a transcript in a projects folder, else a lookup by id. */
|
||||
async function resolveTranscriptPath(
|
||||
hookPath: string | null,
|
||||
conversationId: string,
|
||||
ids: string[],
|
||||
codemanId: string,
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
@@ -82,7 +117,7 @@ async function resolveTranscriptPath(
|
||||
(await insideClaudeProjects(hookPath, configDir))
|
||||
)
|
||||
return hookPath;
|
||||
return findClaudeTranscriptPath(conversationId, configDir);
|
||||
return lookUpTranscript(ids, codemanId, configDir);
|
||||
}
|
||||
|
||||
type Source = { unsupported: string } | { path: string | null };
|
||||
@@ -102,7 +137,8 @@ async function storySource(ctx: SessionPort & ConfigPort, id: string, req: Fasti
|
||||
return {
|
||||
path: await resolveTranscriptPath(
|
||||
ctx.getTranscriptPath(id),
|
||||
session.claudeSessionId || session.id,
|
||||
[...new Set([session.claudeSessionId, session.id].filter((v): v is string => !!v))],
|
||||
session.id,
|
||||
session.claudeConfigDir
|
||||
),
|
||||
};
|
||||
@@ -145,7 +181,7 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con
|
||||
const source = await storySource(ctx, id, req);
|
||||
if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } };
|
||||
if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } };
|
||||
const turnKey = typeof turn === 'string' && turn ? turn : '';
|
||||
const turnKey = typeof turn === 'string' && turn ? turn.slice(0, 200) : '';
|
||||
const tagOf = (version: string) => `${version}-${encodeURIComponent(turnKey || 'latest')}`;
|
||||
// An unchanged file is a 304 from a stat alone: no read, no parse, whatever the cache holds.
|
||||
const before = await storyFileVersion(source.path);
|
||||
|
||||
Reference in new issue
Block a user