Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
91 changes: 91 additions & 0 deletions packages/core/src/__tests__/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,3 +638,94 @@ describe("corrupt JSON handling", () => {
expect(meta!.status).toBe("working");
});
});

describe("legacy key=value metadata in .json files", () => {
it("readMetadata parses key=value content from a .json file", () => {
writeFileSync(
join(dataDir, "ao-orchestrator.json"),
[
"codexThreadId=019df929-1773-7720-b07d-08e3cb9851fb",
"codexModel=gpt-5.4",
"status=working",
'runtimeHandle={"id":"ao-orchestrator","runtimeName":"tmux","data":{"createdAt":1778013362949,"workspacePath":"/Users/test"}}',
"tmuxName=ao-orchestrator",
].join("\n"),
"utf-8",
);

const meta = readMetadata(dataDir, "ao-orchestrator");
expect(meta).not.toBeNull();
expect(meta!.tmuxName).toBe("ao-orchestrator");
expect(meta!.runtimeHandle).toBeDefined();
expect(meta!.runtimeHandle?.id).toBe("ao-orchestrator");
expect(meta!.runtimeHandle?.runtimeName).toBe("tmux");
expect(meta!.status).toBe("working");
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the session-manager regression test for ensureOrchestrator recovery would require deeper mocking of the spawn/reservation path. The existing unit tests now cover the core parsing and round-trip behavior. A higher-level integration test can be added in a follow-up.

it("readMetadataRaw parses key=value content from a .json file", () => {
writeFileSync(
join(dataDir, "ao-orchestrator-raw.json"),
[
"codexThreadId=019df929-1773-7720-b07d-08e3cb9851fb",
"codexModel=gpt-5.4",
"status=working",
'runtimeHandle={"id":"ao-orchestrator","runtimeName":"tmux","data":{"createdAt":1778013362949}}',
"tmuxName=ao-orchestrator",
].join("\n"),
"utf-8",
);

const raw = readMetadataRaw(dataDir, "ao-orchestrator-raw");
expect(raw).not.toBeNull();
expect(raw!["codexThreadId"]).toBe("019df929-1773-7720-b07d-08e3cb9851fb");
expect(raw!["tmuxName"]).toBe("ao-orchestrator");
expect(raw!["status"]).toBe("working");
});

it("readMetadata still returns null for empty key=value file", () => {
writeFileSync(join(dataDir, "empty-kv.json"), "", "utf-8");
expect(readMetadata(dataDir, "empty-kv")).toBeNull();
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

it("readMetadata returns null for comment-only/blank key=value file (exercises fallback path)", () => {
writeFileSync(join(dataDir, "blank-kv.json"), "# comment\n\n \n", "utf-8");
expect(readMetadata(dataDir, "blank-kv")).toBeNull();
});

it("mutateMetadata round-trips legacy key=value content into JSON", () => {
writeFileSync(
join(dataDir, "rt-legacy.json"),
[
"codexThreadId=019df929-1773-7720-b07d-08e3cb9851fb",
"codexModel=gpt-5.4",
"status=working",
'runtimeHandle={"id":"ao-orchestrator","runtimeName":"tmux","data":{"createdAt":1778013362949,"workspacePath":"/Users/test"}}',
"tmuxName=ao-orchestrator",
].join("\n"),
"utf-8",
);

// mutateMetadata should read the legacy format and rewrite as JSON
mutateMetadata(dataDir, "rt-legacy", (existing) => {
return { ...existing, status: "idle" };
});

// Verify the file is now valid JSON and all fields are preserved
const meta = readMetadata(dataDir, "rt-legacy");
expect(meta).not.toBeNull();
expect(meta!.tmuxName).toBe("ao-orchestrator");
expect(meta!.runtimeHandle).toBeDefined();
expect(meta!.runtimeHandle?.id).toBe("ao-orchestrator");
expect(meta!.runtimeHandle?.runtimeName).toBe("tmux");
expect(meta!.status).toBe("idle");

// Verify the underlying file is now valid JSON (not key=value)
const raw = readFileSync(join(dataDir, "rt-legacy.json"), "utf-8");
expect(() => JSON.parse(raw)).not.toThrow();
});

it("readMetadata still returns null for corrupt JSON that starts with {", () => {
writeFileSync(join(dataDir, "corrupt-kv.json"), "{this is not valid json", "utf-8");
expect(readMetadata(dataDir, "corrupt-kv")).toBeNull();
});
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
20 changes: 18 additions & 2 deletions packages/core/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { assertValidSessionIdComponent, SESSION_ID_COMPONENT_PATTERN } from "./u
import { flattenToStringRecord } from "./utils/metadata-flatten.js";
import { validateStatus } from "./utils/validation.js";
import { withFileLockSync } from "./file-lock.js";
import { parseKeyValueContent } from "./key-value.js";

const JSON_EXTENSION = ".json";

Expand All @@ -43,14 +44,29 @@ function serializeMetadata(data: Record<string, unknown>): string {
return JSON.stringify(data, null, 2) + "\n";
}

/** Parse JSON metadata file content. Returns null on invalid JSON. */
/** Parse metadata file content. Returns null on invalid content.
* Supports JSON format (current) and legacy key=value format.
* If content starts with '{' or '[' but fails JSON parse, it's corrupt — return null.
* Otherwise, fall back to key=value parsing for legacy metadata files.
*/
function parseMetadataContent(content: string): Record<string, unknown> | null {
// Try JSON first — this is the current format.
try {
const parsed = JSON.parse(content);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
// JSON parse failed. If the content looks like it was intended to be JSON
// (starts with '{' or '['), treat it as corrupt rather than falling through
// to the key=value parser.
const firstChar = content.trim()[0];
if (firstChar === "{" || firstChar === "[") return null;

// Fall back to legacy key=value format (pre-V2 metadata files that were
// stored with a .json extension).
const kv = parseKeyValueContent(content);
if (Object.keys(kv).length === 0) return null;
return kv as Record<string, unknown>;
}
}

Expand Down