From 24026caba9064555becb96d7d5fbb6b0a50d112f Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Fri, 8 May 2026 15:59:22 +0530 Subject: [PATCH] fix(core): read legacy metadata in json files (#1720) --- packages/core/src/__tests__/metadata.test.ts | 46 +++++++++++++++++++ .../__tests__/session-manager/spawn.test.ts | 28 ++++++++++- packages/core/src/metadata.ts | 9 +++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 0ff20a4ef..8b4331802 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -150,6 +150,30 @@ describe("writeMetadata + readMetadata", () => { const meta = readMetadata(dataDir, "app-6"); expect(meta?.displayName).toBe("Refactor session manager"); }); + + it("reads legacy key=value metadata from a .json file", () => { + writeFileSync( + join(dataDir, "legacy-json-name.json"), + [ + "role=orchestrator", + "status=working", + "runtimeHandle={\"id\":\"ao-orchestrator\",\"runtimeName\":\"tmux\",\"data\":{\"createdAt\":1778013362949,\"workspacePath\":\"/tmp/project\"}}", + "tmuxName=ao-orchestrator", + ].join("\n"), + "utf-8", + ); + + const meta = readMetadata(dataDir, "legacy-json-name"); + expect(meta).not.toBeNull(); + expect(meta?.role).toBe("orchestrator"); + expect(meta?.status).toBe("working"); + expect(meta?.tmuxName).toBe("ao-orchestrator"); + expect(meta?.runtimeHandle).toEqual({ + id: "ao-orchestrator", + runtimeName: "tmux", + data: { createdAt: 1778013362949, workspacePath: "/tmp/project" }, + }); + }); }); describe("readMetadataRaw", () => { @@ -185,6 +209,28 @@ describe("readMetadataRaw", () => { const raw = readMetadataRaw(dataDir, "raw-3"); expect(raw!["runtimeHandle"]).toBe('{"id":"foo","data":{"key":"val"}}'); }); + + it("reads legacy key=value metadata from a .json file", () => { + writeFileSync( + join(dataDir, "raw-legacy.json"), + [ + "role=orchestrator", + "status=working", + "runtimeHandle={\"id\":\"ao-orchestrator\",\"runtimeName\":\"tmux\",\"data\":{\"workspacePath\":\"/tmp/project\"}}", + "tmuxName=ao-orchestrator", + ].join("\n"), + "utf-8", + ); + + const raw = readMetadataRaw(dataDir, "raw-legacy"); + expect(raw).not.toBeNull(); + expect(raw?.["role"]).toBe("orchestrator"); + expect(raw?.["status"]).toBe("working"); + expect(raw?.["tmuxName"]).toBe("ao-orchestrator"); + expect(raw?.["runtimeHandle"]).toBe( + "{\"id\":\"ao-orchestrator\",\"runtimeName\":\"tmux\",\"data\":{\"workspacePath\":\"/tmp/project\"}}", + ); + }); }); describe("updateMetadata", () => { diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 970ea6aa0..6190f6115 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdirSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createSessionManager } from "../../session-manager.js"; import { validateConfig } from "../../config.js"; @@ -1710,6 +1710,32 @@ describe("spawn", () => { expect(mockWorkspace.create).toHaveBeenCalledTimes(1); }); + it("ensureOrchestrator reuses a canonical orchestrator stored as legacy key=value metadata in a .json file", async () => { + const runtimeHandle = makeHandle("ao-orchestrator"); + writeFileSync( + join(sessionsDir, "app-orchestrator.json"), + [ + "role=orchestrator", + "project=my-app", + "status=working", + "branch=orchestrator/app-orchestrator", + `worktree=${join(tmpDir, "legacy-orchestrator")}`, + `runtimeHandle=${JSON.stringify(runtimeHandle)}`, + "tmuxName=ao-orchestrator", + ].join("\n"), + "utf-8", + ); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.ensureOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(session.metadata["role"]).toBe("orchestrator"); + expect(session.runtimeHandle).toEqual(runtimeHandle); + expect(mockRuntime.create).not.toHaveBeenCalled(); + expect(mockWorkspace.create).not.toHaveBeenCalled(); + }); + it("ensureOrchestrator coalesces concurrent creation calls", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 95d354f77..b6282a9c5 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -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"; @@ -43,14 +44,18 @@ function serializeMetadata(data: Record): string { return JSON.stringify(data, null, 2) + "\n"; } -/** Parse JSON metadata file content. Returns null on invalid JSON. */ +/** Parse metadata file content. Supports JSON and legacy key=value content. */ function parseMetadataContent(content: string): Record | null { + const trimmed = content.trimStart(); try { const parsed = JSON.parse(content); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; return parsed as Record; } catch { - return null; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return null; + + const parsed = parseKeyValueContent(content); + return Object.keys(parsed).length > 0 ? parsed : null; } }