Skip to content

Commit d18aa91

Browse files
elkholy90elkaix
andauthored
fix(vis): recover headerless journals and session-root task logs (#314)
## Related Issue Internal visualizer recovery for sessions whose journals or task logs use older on-disk layouts. ## Problem The visualizer rejected headerless `wire.jsonl` files, treated untyped JSON as a broken main wire, and missed main-agent background tasks that still live under the session-root `tasks/` directory. Imported debug manifests also passed through free-form `shellEnv` and omitted desktop/web log fields. Session `workDir` was empty when the append index was missing even if `state.json` still had `cwd`. ## What changed - Read headerless journals as protocol 1.4 and migrate them in memory. Normalize legacy `plan.revision` `path` values to the current storage `key`. - Recover `workDir` from `state.json` when the session index does not have it. Sanitize untrusted agent metadata and keep Dynamic Workflow item labels. - Sanitize imported debug manifests: string-only `shellEnv` fields plus desktop/web log and desktop version fields. - List and page main-agent task output from the agent homedir first, then the legacy session-root `tasks/` directory. Treat an empty primary log as present so it is not shadowed. - Parse both epoch-ms and ISO timestamps in the session and state views. - Include `apps/vis/server` and `apps/vis/web` in the root Vitest project list. No changeset: vis packages are not published changelog entries. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. Co-authored-by: elkaix <melkholy@techmatrix.com>
1 parent 09d69fe commit d18aa91

22 files changed

Lines changed: 891 additions & 174 deletions

apps/vis/server/src/lib/agent-record-types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,17 @@ export interface ImportManifest {
237237
workspaceDir?: string;
238238
sessionLogPath?: string;
239239
globalLogPath?: string;
240+
desktopLogPath?: string;
241+
webLogPath?: string;
242+
desktopVersion?: string;
240243
installSource?: string;
241-
shellEnv?: unknown;
244+
shellEnv?: {
245+
term?: string;
246+
termProgram?: string;
247+
termProgramVersion?: string;
248+
multiplexer?: string;
249+
shell?: string;
250+
};
242251
}
243252

244253
/** vis-side bookkeeping for one imported bundle, written to
@@ -297,6 +306,7 @@ export interface AgentInfo {
297306
agentId: string;
298307
type: 'main' | 'sub' | 'independent';
299308
parentAgentId: string | null;
309+
profileName: string | null;
300310
homedir: string;
301311
wireExists: boolean;
302312
wireRecordCount: number;

apps/vis/server/src/lib/import-store.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,20 @@ async function readManifest(dir: string): Promise<ImportManifest | null> {
133133
}
134134
}
135135

136-
/** Declared string fields of {@link ImportManifest}. `shellEnv` is free-form. */
136+
/** Declared string fields of {@link ImportManifest}. */
137137
const MANIFEST_STRING_FIELDS = [
138138
'sessionId', 'exportedAt', 'pythinkerCodeVersion', 'wireProtocolVersion', 'os',
139139
'nodejsVersion', 'sessionFirstActivity', 'sessionLastActivity', 'title',
140-
'workspaceDir', 'sessionLogPath', 'globalLogPath', 'installSource',
140+
'workspaceDir', 'sessionLogPath', 'globalLogPath', 'desktopLogPath',
141+
'webLogPath', 'desktopVersion', 'installSource',
142+
] as const;
143+
144+
const SHELL_ENV_STRING_FIELDS = [
145+
'term',
146+
'termProgram',
147+
'termProgramVersion',
148+
'multiplexer',
149+
'shell',
141150
] as const;
142151

143152
/**
@@ -153,7 +162,15 @@ function sanitizeManifest(raw: unknown): ImportManifest | null {
153162
for (const field of MANIFEST_STRING_FIELDS) {
154163
if (typeof o[field] === 'string') m[field] = o[field];
155164
}
156-
if (o['shellEnv'] !== undefined) m['shellEnv'] = o['shellEnv'];
165+
const shellEnv = o['shellEnv'];
166+
if (typeof shellEnv === 'object' && shellEnv !== null && !Array.isArray(shellEnv)) {
167+
const source = shellEnv as Record<string, unknown>;
168+
const sanitized: Record<string, string> = {};
169+
for (const field of SHELL_ENV_STRING_FIELDS) {
170+
if (typeof source[field] === 'string') sanitized[field] = source[field];
171+
}
172+
m['shellEnv'] = sanitized;
173+
}
157174
return m as ImportManifest;
158175
}
159176

apps/vis/server/src/lib/session-store.ts

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,20 @@ export function isSafeAgentId(id: string): boolean {
2222
interface StateJson {
2323
createdAt?: string | number;
2424
updatedAt?: string | number;
25+
cwd?: string;
26+
workDir?: string;
2527
title?: string;
2628
isCustomTitle?: boolean;
2729
lastPrompt?: string;
2830
// Agent metadata comes from an untrusted state.json (a corrupt or imported
29-
// bundle may hold non-object entries like `{ "main": null }`), so the value
30-
// type allows null and inventoryAgents skips anything that isn't an object.
31+
// bundle may hold non-object entries like `{ "main": null }`), so inventory
32+
// skips anything that is not a record.
3133
//
3234
// v2 writes the REAL parent / dynamic-workflow-item label under `labels`
3335
// (its top-level `parentAgentId` is a fixed 'main' placeholder for sub
3436
// agents); v1 wrote them top-level. Read labels first, top-level as
3537
// fallback — the same order the engine itself uses.
36-
agents?: Record<string, {
37-
type: 'main' | 'sub' | 'independent';
38-
parentAgentId?: string | null;
39-
dynamicWorkflowItem?: string;
40-
labels?: { parentAgentId?: string; dynamicWorkflowItem?: string };
41-
} | null>;
38+
agents?: Record<string, unknown>;
4239
custom?: Record<string, unknown>;
4340
}
4441

@@ -90,7 +87,15 @@ export async function readSessionDetail(home: string, sessionId: string): Promis
9087
}
9188
if (state.custom?.['imported_from_pythinker_cli'] === true) return null;
9289
const agents = await inventoryAgents(sessionDir, state);
93-
return { sessionId, sessionDir, workDir, state, agents, imported: false, importMeta: null };
90+
return {
91+
sessionId,
92+
sessionDir,
93+
workDir: recoverWorkDir(state, workDir),
94+
state,
95+
agents,
96+
imported: false,
97+
importMeta: null,
98+
};
9499
}
95100

96101
/** Detail for an imported bundle. Same readers as a local session, but the
@@ -117,7 +122,15 @@ async function readImportedDetail(home: string, importId: string): Promise<Sessi
117122
if (agents.length === 0) {
118123
agents = await discoverAgentsFromDisk(sessionDir);
119124
}
120-
return { sessionId: importId, sessionDir, workDir, state, agents, imported: true, importMeta: meta };
125+
return {
126+
sessionId: importId,
127+
sessionDir,
128+
workDir: recoverWorkDir(state, workDir),
129+
state,
130+
agents,
131+
imported: true,
132+
importMeta: meta,
133+
};
121134
}
122135

123136
/** Fallback inventory used when `state.json` is unreadable: walk
@@ -154,6 +167,7 @@ async function discoverAgentsFromDisk(sessionDir: string): Promise<AgentInfo[]>
154167
agentId: id,
155168
type: id === 'main' ? 'main' : 'independent',
156169
parentAgentId: null,
170+
profileName: null,
157171
homedir: join(agentsDir, id),
158172
wireExists: readable,
159173
wireRecordCount: info.count,
@@ -205,7 +219,7 @@ async function tryReadSummary(
205219
return {
206220
sessionId,
207221
sessionDir,
208-
workDir,
222+
workDir: recoverWorkDir(state, workDir),
209223
title: state.title ?? null,
210224
lastPrompt: state.lastPrompt ?? null,
211225
isCustomTitle: state.isCustomTitle ?? false,
@@ -271,7 +285,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise<Ag
271285
// A type-corrupt entry (e.g. `{ "main": null }`) must not throw on the
272286
// field dereferences below; skip it so the empty-inventory fallback in
273287
// readImportedDetail can recover the agent from disk instead.
274-
if (typeof meta !== 'object' || meta === null) continue;
288+
if (!isRecord(meta)) continue;
289+
const labels = isRecord(meta['labels']) ? meta['labels'] : undefined;
275290
const wirePath = join(sessionDir, 'agents', id, 'wire.jsonl');
276291
const exists = await pathExists(wirePath);
277292
let readable = exists;
@@ -289,13 +304,18 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise<Ag
289304
}
290305
result.push({
291306
agentId: id,
292-
type: meta.type,
293-
parentAgentId: meta.labels?.parentAgentId ?? meta.parentAgentId ?? null,
307+
type: normalizeAgentType(meta['type'], id),
308+
parentAgentId:
309+
normalizeNonEmptyString(labels?.['parentAgentId']) ??
310+
normalizeNonEmptyString(meta['parentAgentId']),
311+
profileName: normalizeNonEmptyString(labels?.['profileName']),
294312
homedir: join(sessionDir, 'agents', id),
295313
wireExists: readable,
296314
wireRecordCount: info.count,
297315
wireProtocolVersion: info.protocolVersion,
298-
dynamicWorkflowItem: meta.labels?.dynamicWorkflowItem ?? meta.dynamicWorkflowItem ?? null,
316+
dynamicWorkflowItem:
317+
normalizeNonEmptyString(labels?.['dynamicWorkflowItem']) ??
318+
normalizeNonEmptyString(meta['dynamicWorkflowItem']),
299319
});
300320
}
301321
return result.toSorted((a, b) => compareAgentIds(a.agentId, b.agentId));
@@ -354,20 +374,26 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion:
354374
let protocolVersion: string | null = null;
355375
for await (const line of rl) {
356376
if (line.length === 0) continue;
377+
let parsed: unknown;
378+
try {
379+
parsed = JSON.parse(line);
380+
} catch {
381+
continue;
382+
}
383+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) continue;
384+
const record = parsed as Record<string, unknown>;
385+
if (typeof record['type'] !== 'string') continue;
357386
if (protocolVersion === null) {
358-
// Strict: the first non-empty line MUST be a well-formed
359-
// `metadata` record. Otherwise the list-view health would say
360-
// "ok" while the wire-reader rejects the file on open.
361-
let parsed: { type?: unknown; protocol_version?: unknown };
362-
try {
363-
parsed = JSON.parse(line) as typeof parsed;
364-
} catch {
365-
throw new Error(`wire metadata is not valid JSON at line 1`);
366-
}
367-
if (parsed.type !== 'metadata' || typeof parsed.protocol_version !== 'string') {
368-
throw new Error(`wire is missing a metadata header on line 1`);
387+
if (record['type'] !== 'metadata') {
388+
protocolVersion = '1.4';
389+
} else {
390+
const version = record['protocol_version'];
391+
const createdAt = record['created_at'];
392+
if (typeof version !== 'string' || typeof createdAt !== 'number') {
393+
throw new TypeError('wire metadata is malformed');
394+
}
395+
protocolVersion = version;
369396
}
370-
protocolVersion = parsed.protocol_version;
371397
}
372398
count += 1;
373399
}
@@ -377,6 +403,32 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion:
377403
return { count, protocolVersion };
378404
}
379405

406+
function normalizeAgentType(
407+
value: unknown,
408+
agentId: string,
409+
): AgentInfo['type'] {
410+
if (value === 'main' || value === 'sub' || value === 'independent') return value;
411+
return agentId === 'main' ? 'main' : 'sub';
412+
}
413+
414+
function normalizeNonEmptyString(value: unknown): string | null {
415+
if (typeof value !== 'string') return null;
416+
const trimmed = value.trim();
417+
return trimmed.length > 0 ? trimmed : null;
418+
}
419+
420+
function isRecord(value: unknown): value is Record<string, unknown> {
421+
return typeof value === 'object' && value !== null && !Array.isArray(value);
422+
}
423+
424+
function recoverWorkDir(state: StateJson, preferred: string): string {
425+
if (preferred.length > 0) return preferred;
426+
if (typeof state.cwd === 'string' && state.cwd.length > 0) return state.cwd;
427+
if (typeof state.workDir === 'string' && state.workDir.length > 0) return state.workDir;
428+
const customCwd = state.custom?.['cwd'];
429+
return typeof customCwd === 'string' && customCwd.length > 0 ? customCwd : '';
430+
}
431+
380432
function parseTs(input: string | number | undefined): number {
381433
if (typeof input === 'number') return Number.isFinite(input) ? input : 0;
382434
if (!input) return 0;

0 commit comments

Comments
 (0)