@made-by-moonlight/athene-core 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +241 -0
- package/dist/activity-events.d.ts +42 -0
- package/dist/activity-events.d.ts.map +1 -0
- package/dist/activity-events.js +192 -0
- package/dist/activity-events.js.map +1 -0
- package/dist/activity-log.d.ts +71 -0
- package/dist/activity-log.d.ts.map +1 -0
- package/dist/activity-log.js +203 -0
- package/dist/activity-log.js.map +1 -0
- package/dist/activity-signal.d.ts +20 -0
- package/dist/activity-signal.d.ts.map +1 -0
- package/dist/activity-signal.js +91 -0
- package/dist/activity-signal.js.map +1 -0
- package/dist/agent-report.d.ts +148 -0
- package/dist/agent-report.d.ts.map +1 -0
- package/dist/agent-report.js +516 -0
- package/dist/agent-report.js.map +1 -0
- package/dist/agent-selection.d.ts +31 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +69 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/agent-workspace-hooks.d.ts +74 -0
- package/dist/agent-workspace-hooks.d.ts.map +1 -0
- package/dist/agent-workspace-hooks.js +988 -0
- package/dist/agent-workspace-hooks.js.map +1 -0
- package/dist/atomic-write.d.ts +6 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +49 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/cleanup-stack.d.ts +37 -0
- package/dist/cleanup-stack.d.ts.map +1 -0
- package/dist/cleanup-stack.js +45 -0
- package/dist/cleanup-stack.js.map +1 -0
- package/dist/code-review-manager.d.ts +118 -0
- package/dist/code-review-manager.d.ts.map +1 -0
- package/dist/code-review-manager.js +719 -0
- package/dist/code-review-manager.js.map +1 -0
- package/dist/code-review-store.d.ts +114 -0
- package/dist/code-review-store.d.ts.map +1 -0
- package/dist/code-review-store.js +346 -0
- package/dist/code-review-store.js.map +1 -0
- package/dist/config-generator.d.ts +84 -0
- package/dist/config-generator.d.ts.map +1 -0
- package/dist/config-generator.js +295 -0
- package/dist/config-generator.js.map +1 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +852 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon-children.d.ts +55 -0
- package/dist/daemon-children.d.ts.map +1 -0
- package/dist/daemon-children.js +435 -0
- package/dist/daemon-children.js.map +1 -0
- package/dist/dashboard-notifications.d.ts +42 -0
- package/dist/dashboard-notifications.d.ts.map +1 -0
- package/dist/dashboard-notifications.js +123 -0
- package/dist/dashboard-notifications.js.map +1 -0
- package/dist/events-db.d.ts +39 -0
- package/dist/events-db.d.ts.map +1 -0
- package/dist/events-db.js +185 -0
- package/dist/events-db.js.map +1 -0
- package/dist/feature-flags.d.ts +2 -0
- package/dist/feature-flags.d.ts.map +1 -0
- package/dist/feature-flags.js +9 -0
- package/dist/feature-flags.js.map +1 -0
- package/dist/feedback-tools.d.ts +97 -0
- package/dist/feedback-tools.d.ts.map +1 -0
- package/dist/feedback-tools.js +161 -0
- package/dist/feedback-tools.js.map +1 -0
- package/dist/file-lock.d.ts +5 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +59 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/format-automated-comments.d.ts +18 -0
- package/dist/format-automated-comments.d.ts.map +1 -0
- package/dist/gh-trace.d.ts +57 -0
- package/dist/gh-trace.d.ts.map +1 -0
- package/dist/gh-trace.js +320 -0
- package/dist/gh-trace.js.map +1 -0
- package/dist/git-activity.d.ts +10 -0
- package/dist/git-activity.d.ts.map +1 -0
- package/dist/git-activity.js +30 -0
- package/dist/git-activity.js.map +1 -0
- package/dist/global-config.d.ts +1085 -0
- package/dist/global-config.d.ts.map +1 -0
- package/dist/global-config.js +1067 -0
- package/dist/global-config.js.map +1 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/key-value.d.ts +7 -0
- package/dist/key-value.d.ts.map +1 -0
- package/dist/key-value.js +24 -0
- package/dist/key-value.js.map +1 -0
- package/dist/lifecycle-manager.d.ts +22 -0
- package/dist/lifecycle-manager.d.ts.map +1 -0
- package/dist/lifecycle-manager.js +2813 -0
- package/dist/lifecycle-manager.js.map +1 -0
- package/dist/lifecycle-state.d.ts +28 -0
- package/dist/lifecycle-state.d.ts.map +1 -0
- package/dist/lifecycle-state.js +446 -0
- package/dist/lifecycle-state.js.map +1 -0
- package/dist/lifecycle-status-decisions.d.ts +85 -0
- package/dist/lifecycle-status-decisions.d.ts.map +1 -0
- package/dist/lifecycle-status-decisions.js +262 -0
- package/dist/lifecycle-status-decisions.js.map +1 -0
- package/dist/lifecycle-transition.d.ts +81 -0
- package/dist/lifecycle-transition.d.ts.map +1 -0
- package/dist/lifecycle-transition.js +207 -0
- package/dist/lifecycle-transition.js.map +1 -0
- package/dist/metadata.d.ts +54 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +484 -0
- package/dist/metadata.js.map +1 -0
- package/dist/migration/storage-v2.d.ts +76 -0
- package/dist/migration/storage-v2.d.ts.map +1 -0
- package/dist/migration/storage-v2.js +1614 -0
- package/dist/migration/storage-v2.js.map +1 -0
- package/dist/notification-data.d.ts +135 -0
- package/dist/notification-data.d.ts.map +1 -0
- package/dist/notification-data.js +204 -0
- package/dist/notification-data.js.map +1 -0
- package/dist/notification-observability.d.ts +21 -0
- package/dist/notification-observability.d.ts.map +1 -0
- package/dist/notification-observability.js +154 -0
- package/dist/notification-observability.js.map +1 -0
- package/dist/notifier-resolution.d.ts +14 -0
- package/dist/notifier-resolution.d.ts.map +1 -0
- package/dist/notifier-resolution.js +23 -0
- package/dist/notifier-resolution.js.map +1 -0
- package/dist/observability.d.ts +100 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +535 -0
- package/dist/observability.js.map +1 -0
- package/dist/opencode-agents-md.d.ts +3 -0
- package/dist/opencode-agents-md.d.ts.map +1 -0
- package/dist/opencode-agents-md.js +40 -0
- package/dist/opencode-agents-md.js.map +1 -0
- package/dist/opencode-config.d.ts +2 -0
- package/dist/opencode-config.d.ts.map +1 -0
- package/dist/opencode-config.js +17 -0
- package/dist/opencode-config.js.map +1 -0
- package/dist/opencode-session-id.d.ts +2 -0
- package/dist/opencode-session-id.d.ts.map +1 -0
- package/dist/opencode-session-id.js +12 -0
- package/dist/opencode-session-id.js.map +1 -0
- package/dist/opencode-shared.d.ts +80 -0
- package/dist/opencode-shared.d.ts.map +1 -0
- package/dist/opencode-shared.js +202 -0
- package/dist/opencode-shared.js.map +1 -0
- package/dist/orchestrator-prompt.d.ts +19 -0
- package/dist/orchestrator-prompt.d.ts.map +1 -0
- package/dist/orchestrator-prompt.js +130 -0
- package/dist/orchestrator-prompt.js.map +1 -0
- package/dist/orchestrator-session-strategy.d.ts +5 -0
- package/dist/orchestrator-session-strategy.d.ts.map +1 -0
- package/dist/orchestrator-session-strategy.js +13 -0
- package/dist/orchestrator-session-strategy.js.map +1 -0
- package/dist/paths.d.ts +145 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +288 -0
- package/dist/paths.js.map +1 -0
- package/dist/platform.d.ts +32 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +211 -0
- package/dist/platform.js.map +1 -0
- package/dist/plugin-registry.d.ts +15 -0
- package/dist/plugin-registry.d.ts.map +1 -0
- package/dist/plugin-registry.js +499 -0
- package/dist/plugin-registry.js.map +1 -0
- package/dist/portfolio-projects.d.ts +7 -0
- package/dist/portfolio-projects.d.ts.map +1 -0
- package/dist/portfolio-projects.js +65 -0
- package/dist/portfolio-projects.js.map +1 -0
- package/dist/portfolio-registry.d.ts +42 -0
- package/dist/portfolio-registry.d.ts.map +1 -0
- package/dist/portfolio-registry.js +311 -0
- package/dist/portfolio-registry.js.map +1 -0
- package/dist/portfolio-routing.d.ts +5 -0
- package/dist/portfolio-routing.d.ts.map +1 -0
- package/dist/portfolio-routing.js +24 -0
- package/dist/portfolio-routing.js.map +1 -0
- package/dist/portfolio-session-service.d.ts +15 -0
- package/dist/portfolio-session-service.d.ts.map +1 -0
- package/dist/portfolio-session-service.js +206 -0
- package/dist/portfolio-session-service.js.map +1 -0
- package/dist/process-cache.d.ts +32 -0
- package/dist/process-cache.d.ts.map +1 -0
- package/dist/process-cache.js +44 -0
- package/dist/process-cache.js.map +1 -0
- package/dist/project-resolver.d.ts +5 -0
- package/dist/project-resolver.d.ts.map +1 -0
- package/dist/project-resolver.js +20 -0
- package/dist/project-resolver.js.map +1 -0
- package/dist/prompt-builder.d.ts +42 -0
- package/dist/prompt-builder.d.ts.map +1 -0
- package/dist/prompt-builder.js +182 -0
- package/dist/prompt-builder.js.map +1 -0
- package/dist/prompts/orchestrator.md.js +4 -0
- package/dist/prompts/orchestrator.md.js.map +1 -0
- package/dist/query-activity-events.d.ts +42 -0
- package/dist/query-activity-events.d.ts.map +1 -0
- package/dist/query-activity-events.js +170 -0
- package/dist/query-activity-events.js.map +1 -0
- package/dist/recovery/actions.d.ts +7 -0
- package/dist/recovery/actions.d.ts.map +1 -0
- package/dist/recovery/index.d.ts +8 -0
- package/dist/recovery/index.d.ts.map +1 -0
- package/dist/recovery/logger.d.ts +12 -0
- package/dist/recovery/logger.d.ts.map +1 -0
- package/dist/recovery/manager.d.ts +24 -0
- package/dist/recovery/manager.d.ts.map +1 -0
- package/dist/recovery/scanner.d.ts +11 -0
- package/dist/recovery/scanner.d.ts.map +1 -0
- package/dist/recovery/types.d.ts +170 -0
- package/dist/recovery/types.d.ts.map +1 -0
- package/dist/recovery/validator.d.ts +8 -0
- package/dist/recovery/validator.d.ts.map +1 -0
- package/dist/report-watcher.d.ts +93 -0
- package/dist/report-watcher.d.ts.map +1 -0
- package/dist/report-watcher.js +182 -0
- package/dist/report-watcher.js.map +1 -0
- package/dist/scm-webhook-utils.d.ts +6 -0
- package/dist/scm-webhook-utils.d.ts.map +1 -0
- package/dist/scm-webhook-utils.js +36 -0
- package/dist/scm-webhook-utils.js.map +1 -0
- package/dist/session-manager.d.ts +22 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +3077 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/spawn-target.d.ts +23 -0
- package/dist/spawn-target.d.ts.map +1 -0
- package/dist/spawn-target.js +39 -0
- package/dist/spawn-target.js.map +1 -0
- package/dist/storage-key.d.ts +9 -0
- package/dist/storage-key.d.ts.map +1 -0
- package/dist/storage-key.js +59 -0
- package/dist/storage-key.js.map +1 -0
- package/dist/tmux.d.ts +39 -0
- package/dist/tmux.d.ts.map +1 -0
- package/dist/tmux.js +141 -0
- package/dist/tmux.js.map +1 -0
- package/dist/types.d.ts +1496 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/update-cache.d.ts +59 -0
- package/dist/update-cache.d.ts.map +1 -0
- package/dist/update-cache.js +77 -0
- package/dist/update-cache.js.map +1 -0
- package/dist/utils/metadata-flatten.d.ts +3 -0
- package/dist/utils/metadata-flatten.d.ts.map +1 -0
- package/dist/utils/metadata-flatten.js +18 -0
- package/dist/utils/metadata-flatten.js.map +1 -0
- package/dist/utils/pr.d.ts +7 -0
- package/dist/utils/pr.d.ts.map +1 -0
- package/dist/utils/pr.js +97 -0
- package/dist/utils/pr.js.map +1 -0
- package/dist/utils/session-from-metadata.d.ts +16 -0
- package/dist/utils/session-from-metadata.d.ts.map +1 -0
- package/dist/utils/session-from-metadata.js +87 -0
- package/dist/utils/session-from-metadata.js.map +1 -0
- package/dist/utils/session-id.d.ts +4 -0
- package/dist/utils/session-id.d.ts.map +1 -0
- package/dist/utils/session-id.js +9 -0
- package/dist/utils/session-id.js.map +1 -0
- package/dist/utils/validation.d.ts +9 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +45 -0
- package/dist/utils/validation.js.map +1 -0
- package/dist/utils.d.ts +65 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +189 -0
- package/dist/utils.js.map +1 -0
- package/dist/version-compare.d.ts +27 -0
- package/dist/version-compare.d.ts.map +1 -0
- package/dist/version-compare.js +121 -0
- package/dist/version-compare.js.map +1 -0
- package/dist/windows-pty-registry.d.ts +27 -0
- package/dist/windows-pty-registry.d.ts.map +1 -0
- package/dist/windows-pty-registry.js +109 -0
- package/dist/windows-pty-registry.js.map +1 -0
- package/package.json +110 -0
|
@@ -0,0 +1,1614 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync, writeFileSync, unlinkSync, mkdirSync, rmSync, renameSync, readFileSync, cpSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
|
+
import { parseKeyValueContent } from '../key-value.js';
|
|
6
|
+
import { generateSessionPrefix } from '../paths.js';
|
|
7
|
+
import { atomicWriteFileSync } from '../atomic-write.js';
|
|
8
|
+
import { withFileLockSync } from '../file-lock.js';
|
|
9
|
+
import { recordActivityEvent } from '../activity-events.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Storage V2 migration — converts old hash-based storage layout to
|
|
13
|
+
* the new `projects/{projectId}/` layout with JSON metadata.
|
|
14
|
+
*
|
|
15
|
+
* Old layout: ~/.agent-orchestrator/{12-hex}-{projectId}/sessions/{sessionId}
|
|
16
|
+
* New layout: ~/.agent-orchestrator/projects/{projectId}/sessions/{sessionId}.json
|
|
17
|
+
*
|
|
18
|
+
* This module is intentionally self-contained — it must NOT import
|
|
19
|
+
* deriveStorageKey, legacyProjectHash, or any old hash functions.
|
|
20
|
+
* Detection uses a single regex: /^([0-9a-f]{12})-(.+)$/
|
|
21
|
+
*/
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/** Regex to detect old hash-based directory names: {12-hex}-{projectId}. */
|
|
26
|
+
const HASH_DIR_PATTERN = /^([0-9a-f]{12})-(.+)$/;
|
|
27
|
+
/** Regex to detect bare 12-hex hash directories (no project suffix). */
|
|
28
|
+
const BARE_HASH_DIR_PATTERN = /^([0-9a-f]{12})$/;
|
|
29
|
+
/** Regex to detect .migrated directories (for rollback). */
|
|
30
|
+
const MIGRATED_DIR_PATTERN = /^([0-9a-f]{12})-(.+)\.migrated$/;
|
|
31
|
+
/** Regex to detect bare .migrated directories. */
|
|
32
|
+
const BARE_MIGRATED_DIR_PATTERN = /^([0-9a-f]{12})\.migrated$/;
|
|
33
|
+
/** Directory name suffixes that are NOT project data and must be skipped by migration. */
|
|
34
|
+
const NON_PROJECT_SUFFIXES = new Set(["observability"]);
|
|
35
|
+
/** Marker file written during migration for crash-safety detection on re-run. */
|
|
36
|
+
const MIGRATION_MARKER = ".migration-in-progress";
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Inventory — detect old hash-based directories
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function inventoryHashDirs(aoBaseDir, globalConfigPath) {
|
|
41
|
+
if (!existsSync(aoBaseDir))
|
|
42
|
+
return [];
|
|
43
|
+
// Build a storageKey→projectId lookup from global config (for bare hash dirs)
|
|
44
|
+
const storageKeyToProject = buildStorageKeyLookup(globalConfigPath);
|
|
45
|
+
const entries = [];
|
|
46
|
+
for (const name of readdirSync(aoBaseDir)) {
|
|
47
|
+
let hash;
|
|
48
|
+
let projectId;
|
|
49
|
+
// Skip already-migrated directories — prevents .migrated.migrated on re-run
|
|
50
|
+
if (name.endsWith(".migrated"))
|
|
51
|
+
continue;
|
|
52
|
+
const hashNameMatch = HASH_DIR_PATTERN.exec(name);
|
|
53
|
+
const bareHashMatch = BARE_HASH_DIR_PATTERN.exec(name);
|
|
54
|
+
if (hashNameMatch) {
|
|
55
|
+
hash = hashNameMatch[1];
|
|
56
|
+
projectId = sanitizeLegacyProjectId(hashNameMatch[2]);
|
|
57
|
+
// Skip non-project directories (e.g. {hash}-observability)
|
|
58
|
+
if (NON_PROJECT_SUFFIXES.has(hashNameMatch[2]))
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
else if (bareHashMatch) {
|
|
62
|
+
hash = bareHashMatch[1];
|
|
63
|
+
// Derive projectId: config lookup → session metadata → fallback to hash
|
|
64
|
+
const rawId = storageKeyToProject.get(hash) ?? deriveProjectIdFromDir(join(aoBaseDir, name)) ?? hash;
|
|
65
|
+
projectId = sanitizeLegacyProjectId(rawId);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const dirPath = join(aoBaseDir, name);
|
|
71
|
+
try {
|
|
72
|
+
if (!statSync(dirPath).isDirectory())
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// A directory is empty if it has no session files and no worktrees
|
|
79
|
+
const sessionsDir = join(dirPath, "sessions");
|
|
80
|
+
const worktreesDir = join(dirPath, "worktrees");
|
|
81
|
+
const hasSessions = existsSync(sessionsDir) && readdirSync(sessionsDir).some((f) => !f.startsWith(".") && f !== "archive");
|
|
82
|
+
const hasWorktrees = existsSync(worktreesDir) && readdirSync(worktreesDir).length > 0;
|
|
83
|
+
entries.push({
|
|
84
|
+
path: dirPath,
|
|
85
|
+
hash,
|
|
86
|
+
projectId,
|
|
87
|
+
empty: !hasSessions && !hasWorktrees,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return entries;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build a storageKey → projectId lookup from the global config.
|
|
94
|
+
* Used to identify which project a bare hash directory belongs to.
|
|
95
|
+
*/
|
|
96
|
+
function buildStorageKeyLookup(globalConfigPath) {
|
|
97
|
+
const lookup = new Map();
|
|
98
|
+
if (!globalConfigPath || !existsSync(globalConfigPath))
|
|
99
|
+
return lookup;
|
|
100
|
+
try {
|
|
101
|
+
const content = readFileSync(globalConfigPath, "utf-8");
|
|
102
|
+
const parsed = parse(content);
|
|
103
|
+
const projects = parsed?.["projects"];
|
|
104
|
+
if (!projects || typeof projects !== "object")
|
|
105
|
+
return lookup;
|
|
106
|
+
for (const [projectId, entry] of Object.entries(projects)) {
|
|
107
|
+
if (entry && typeof entry === "object" && typeof entry["storageKey"] === "string") {
|
|
108
|
+
lookup.set(entry["storageKey"], projectId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Config unreadable — proceed without lookup
|
|
114
|
+
}
|
|
115
|
+
return lookup;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Extract known project name prefixes from the global config.
|
|
119
|
+
* Used by detectActiveSessions to match V2 tmux session names.
|
|
120
|
+
*/
|
|
121
|
+
function extractProjectPrefixes(globalConfigPath) {
|
|
122
|
+
if (!globalConfigPath || !existsSync(globalConfigPath))
|
|
123
|
+
return [];
|
|
124
|
+
try {
|
|
125
|
+
const content = readFileSync(globalConfigPath, "utf-8");
|
|
126
|
+
const parsed = parse(content);
|
|
127
|
+
const projects = parsed?.["projects"];
|
|
128
|
+
if (!projects || typeof projects !== "object")
|
|
129
|
+
return [];
|
|
130
|
+
return Array.from(new Set(Object.entries(projects).map(([projectId, entry]) => {
|
|
131
|
+
if (entry && typeof entry["sessionPrefix"] === "string" && entry["sessionPrefix"].trim()) {
|
|
132
|
+
return entry["sessionPrefix"].trim();
|
|
133
|
+
}
|
|
134
|
+
if (entry && typeof entry["path"] === "string" && entry["path"].trim()) {
|
|
135
|
+
return generateSessionPrefix(basename(entry["path"].trim()));
|
|
136
|
+
}
|
|
137
|
+
return generateSessionPrefix(projectId);
|
|
138
|
+
})));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Try to derive a projectId from session metadata files inside a directory.
|
|
146
|
+
* Reads the first session file that has a "project" field.
|
|
147
|
+
*/
|
|
148
|
+
function deriveProjectIdFromDir(dirPath) {
|
|
149
|
+
const sessionsDir = join(dirPath, "sessions");
|
|
150
|
+
if (!existsSync(sessionsDir))
|
|
151
|
+
return null;
|
|
152
|
+
try {
|
|
153
|
+
for (const file of readdirSync(sessionsDir)) {
|
|
154
|
+
if (file === "archive" || file.startsWith("."))
|
|
155
|
+
continue;
|
|
156
|
+
const filePath = join(sessionsDir, file);
|
|
157
|
+
try {
|
|
158
|
+
if (!statSync(filePath).isFile())
|
|
159
|
+
continue;
|
|
160
|
+
const content = readFileSync(filePath, "utf-8").trim();
|
|
161
|
+
if (!content)
|
|
162
|
+
continue;
|
|
163
|
+
// Try JSON first, then key=value
|
|
164
|
+
let projectField;
|
|
165
|
+
if (content.startsWith("{")) {
|
|
166
|
+
const parsed = JSON.parse(content);
|
|
167
|
+
projectField = typeof parsed["project"] === "string" ? parsed["project"] : undefined;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const kv = parseKeyValueContent(content);
|
|
171
|
+
projectField = kv["project"];
|
|
172
|
+
}
|
|
173
|
+
if (projectField)
|
|
174
|
+
return projectField;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Can't read sessions dir
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Active session detection
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
/**
|
|
190
|
+
* Detect active AO tmux sessions. Returns session names that match
|
|
191
|
+
* either legacy ({hash}-{prefix}-{num}) or V2 ({prefix}-{num}) patterns.
|
|
192
|
+
*
|
|
193
|
+
* Legacy names: {12-hex}-{prefix}-{num} (e.g. abcdef012345-ao-1)
|
|
194
|
+
* V2 names: {prefix}-{num} (e.g. ao-17, app-orchestrator-1)
|
|
195
|
+
*
|
|
196
|
+
* To distinguish V2 names from unrelated tmux sessions, we match:
|
|
197
|
+
* - Any session ending in `-orchestrator-{num}` (always AO)
|
|
198
|
+
* - Sessions matching known AO prefixes: ao-{num}
|
|
199
|
+
* - If knownPrefixes are provided, also match {prefix}-{num}
|
|
200
|
+
*/
|
|
201
|
+
async function detectActiveSessions(knownPrefixes) {
|
|
202
|
+
try {
|
|
203
|
+
const { execSync } = await import('node:child_process');
|
|
204
|
+
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null", {
|
|
205
|
+
encoding: "utf-8",
|
|
206
|
+
timeout: 5000,
|
|
207
|
+
}).trim();
|
|
208
|
+
if (!output)
|
|
209
|
+
return [];
|
|
210
|
+
// Legacy pattern: {12-hex}-{anything}-{num}
|
|
211
|
+
const legacyPattern = /^[0-9a-f]{12}-.+-\d+$/;
|
|
212
|
+
// V2: default "ao" prefix
|
|
213
|
+
const v2DefaultPattern = /^ao-\d+$/;
|
|
214
|
+
// Build V2 prefix patterns from known project prefixes (workers + orchestrators)
|
|
215
|
+
const v2PrefixPatterns = (knownPrefixes ?? [])
|
|
216
|
+
.filter((p) => p && p !== "ao") // "ao" already covered above
|
|
217
|
+
.flatMap((p) => [
|
|
218
|
+
new RegExp(`^${escapeRegExp(p)}-\\d+$`),
|
|
219
|
+
new RegExp(`^${escapeRegExp(p)}-orchestrator-\\d+$`),
|
|
220
|
+
]);
|
|
221
|
+
return output.split("\n").filter((name) => {
|
|
222
|
+
if (legacyPattern.test(name))
|
|
223
|
+
return true;
|
|
224
|
+
if (v2DefaultPattern.test(name))
|
|
225
|
+
return true;
|
|
226
|
+
if (/^ao-orchestrator-\d+$/.test(name))
|
|
227
|
+
return true;
|
|
228
|
+
return v2PrefixPatterns.some((pattern) => pattern.test(name));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// tmux not available or no sessions
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function escapeRegExp(s) {
|
|
237
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
238
|
+
}
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Key-value to JSON conversion
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
/**
|
|
243
|
+
* Convert old key=value metadata content to a JSON object.
|
|
244
|
+
* Handles all the grouping and type conversions.
|
|
245
|
+
*/
|
|
246
|
+
function convertKeyValueToJson(kvContent) {
|
|
247
|
+
const kv = parseKeyValueContent(kvContent);
|
|
248
|
+
const result = {};
|
|
249
|
+
// Direct string fields
|
|
250
|
+
const stringFields = [
|
|
251
|
+
"project", "agent", "createdAt", "branch", "tmuxName",
|
|
252
|
+
"issue", "pr", "summary", "restoredAt", "role",
|
|
253
|
+
"opencodeSessionId", "pinnedSummary", "userPrompt",
|
|
254
|
+
];
|
|
255
|
+
for (const field of stringFields) {
|
|
256
|
+
if (kv[field])
|
|
257
|
+
result[field] = kv[field];
|
|
258
|
+
}
|
|
259
|
+
// Worktree: keep as-is (will be made relative in the migration step)
|
|
260
|
+
if (kv["worktree"])
|
|
261
|
+
result["worktree"] = kv["worktree"];
|
|
262
|
+
// prAutoDetect: "on"/"off" → true/false
|
|
263
|
+
if (kv["prAutoDetect"] === "on")
|
|
264
|
+
result["prAutoDetect"] = true;
|
|
265
|
+
else if (kv["prAutoDetect"] === "off")
|
|
266
|
+
result["prAutoDetect"] = false;
|
|
267
|
+
// runtimeHandle: parse JSON string → object
|
|
268
|
+
if (kv["runtimeHandle"]) {
|
|
269
|
+
try {
|
|
270
|
+
result["runtimeHandle"] = JSON.parse(kv["runtimeHandle"]);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
result["runtimeHandle"] = kv["runtimeHandle"];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// statePayload → lifecycle object
|
|
277
|
+
if (kv["statePayload"]) {
|
|
278
|
+
try {
|
|
279
|
+
result["lifecycle"] = JSON.parse(kv["statePayload"]);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// If statePayload is unparseable, leave it as-is for debugging
|
|
283
|
+
result["statePayload"] = kv["statePayload"];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Drop "stateVersion" (inside lifecycle).
|
|
287
|
+
// Preserve status for pre-lifecycle sessions that have no statePayload —
|
|
288
|
+
// without it, readMetadata falls through to "unknown".
|
|
289
|
+
if (!result["lifecycle"] && kv["status"]) {
|
|
290
|
+
result["status"] = kv["status"];
|
|
291
|
+
}
|
|
292
|
+
// Port fields: string → number
|
|
293
|
+
const portFields = {
|
|
294
|
+
dashboardPort: "port",
|
|
295
|
+
terminalWsPort: "terminalWsPort",
|
|
296
|
+
directTerminalWsPort: "directTerminalWsPort",
|
|
297
|
+
};
|
|
298
|
+
const dashboard = {};
|
|
299
|
+
for (const [kvKey, jsonKey] of Object.entries(portFields)) {
|
|
300
|
+
if (kv[kvKey]) {
|
|
301
|
+
const num = Number(kv[kvKey]);
|
|
302
|
+
if (Number.isFinite(num))
|
|
303
|
+
dashboard[jsonKey] = num;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (Object.keys(dashboard).length > 0)
|
|
307
|
+
result["dashboard"] = dashboard;
|
|
308
|
+
// Agent report + report watcher fields stay flat to match runtime
|
|
309
|
+
// behavior. Live readers (agent-report.ts:565 — parseExistingAgentReport,
|
|
310
|
+
// lifecycle-manager.ts:2083, etc.) look up these keys directly on
|
|
311
|
+
// session.metadata, and readMetadataRaw → flattenToStringRecord does
|
|
312
|
+
// not unfold nested objects back into flat keys. Nesting them here
|
|
313
|
+
// would silently lose this state for migrated sessions until restart
|
|
314
|
+
// (and even then the freshness window means stale-yet-present is
|
|
315
|
+
// safer than missing). Same rationale as the `detecting*` fields below.
|
|
316
|
+
const flatPassthroughKeys = [
|
|
317
|
+
"agentReportedState",
|
|
318
|
+
"agentReportedAt",
|
|
319
|
+
"agentReportedNote",
|
|
320
|
+
"agentReportedPrUrl",
|
|
321
|
+
"agentReportedPrNumber",
|
|
322
|
+
"agentReportedPrIsDraft",
|
|
323
|
+
"reportWatcherLastAuditedAt",
|
|
324
|
+
"reportWatcherActiveTrigger",
|
|
325
|
+
"reportWatcherTriggerActivatedAt",
|
|
326
|
+
"reportWatcherTriggerCount",
|
|
327
|
+
];
|
|
328
|
+
for (const flatKey of flatPassthroughKeys) {
|
|
329
|
+
if (kv[flatKey])
|
|
330
|
+
result[flatKey] = kv[flatKey];
|
|
331
|
+
}
|
|
332
|
+
// detecting fields — keep at top level to match runtime behavior.
|
|
333
|
+
// The lifecycle manager reads/writes these as flat top-level fields
|
|
334
|
+
// (session.metadata["detectingAttempts"], etc.), not from lifecycle.detecting.
|
|
335
|
+
if (kv["lifecycleEvidence"])
|
|
336
|
+
result["lifecycleEvidence"] = kv["lifecycleEvidence"];
|
|
337
|
+
if (kv["detectingAttempts"])
|
|
338
|
+
result["detectingAttempts"] = kv["detectingAttempts"];
|
|
339
|
+
if (kv["detectingStartedAt"])
|
|
340
|
+
result["detectingStartedAt"] = kv["detectingStartedAt"];
|
|
341
|
+
if (kv["detectingEvidenceHash"])
|
|
342
|
+
result["detectingEvidenceHash"] = kv["detectingEvidenceHash"];
|
|
343
|
+
// Preserve unknown fields that weren't handled above.
|
|
344
|
+
// This prevents data loss for custom or future metadata fields.
|
|
345
|
+
const handledKeys = new Set([
|
|
346
|
+
...stringFields, "worktree", "prAutoDetect", "runtimeHandle",
|
|
347
|
+
"statePayload", "stateVersion", "status",
|
|
348
|
+
"dashboardPort", "terminalWsPort", "directTerminalWsPort",
|
|
349
|
+
...flatPassthroughKeys,
|
|
350
|
+
"lifecycleEvidence", "detectingAttempts", "detectingStartedAt", "detectingEvidenceHash",
|
|
351
|
+
]);
|
|
352
|
+
for (const [key, value] of Object.entries(kv)) {
|
|
353
|
+
if (!handledKeys.has(key) && !(key in result)) {
|
|
354
|
+
result[key] = value;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Detect if content is JSON or key=value format.
|
|
361
|
+
*/
|
|
362
|
+
function isJsonContent(content) {
|
|
363
|
+
const trimmed = content.trim();
|
|
364
|
+
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Read and convert a metadata file — handles both old key=value and JSON.
|
|
368
|
+
*/
|
|
369
|
+
function readAndConvertMetadata(filePath) {
|
|
370
|
+
try {
|
|
371
|
+
const content = readFileSync(filePath, "utf-8").trim();
|
|
372
|
+
if (!content)
|
|
373
|
+
return null;
|
|
374
|
+
if (isJsonContent(content)) {
|
|
375
|
+
return JSON.parse(content);
|
|
376
|
+
}
|
|
377
|
+
return convertKeyValueToJson(content);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Legacy project ID sanitization
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
/** Pattern for safe project IDs — must match SAFE_PROJECT_ID_PATTERN in paths.ts. */
|
|
387
|
+
const SAFE_PROJECT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
388
|
+
/**
|
|
389
|
+
* Sanitize a legacy project ID so it is safe for use as a V2 directory name.
|
|
390
|
+
* Replaces spaces and other disallowed characters with hyphens, collapses
|
|
391
|
+
* consecutive hyphens, trims leading/trailing hyphens, and ensures the ID
|
|
392
|
+
* starts with an alphanumeric character.
|
|
393
|
+
*/
|
|
394
|
+
function sanitizeLegacyProjectId(projectId) {
|
|
395
|
+
if (SAFE_PROJECT_ID_PATTERN.test(projectId) && projectId.length <= 128) {
|
|
396
|
+
return projectId;
|
|
397
|
+
}
|
|
398
|
+
let sanitized = projectId
|
|
399
|
+
.replace(/[^a-zA-Z0-9._-]/g, "-") // replace unsafe chars with hyphens
|
|
400
|
+
.replace(/-{2,}/g, "-") // collapse consecutive hyphens
|
|
401
|
+
.replace(/^[-._]+/, "") // strip leading non-alphanumeric
|
|
402
|
+
.replace(/[-._]+$/, ""); // strip trailing non-alphanumeric
|
|
403
|
+
if (!sanitized || !/^[a-zA-Z0-9]/.test(sanitized)) {
|
|
404
|
+
sanitized = `project-${sanitized || "unknown"}`;
|
|
405
|
+
}
|
|
406
|
+
if (sanitized.length > 128) {
|
|
407
|
+
sanitized = sanitized.slice(0, 128);
|
|
408
|
+
}
|
|
409
|
+
return sanitized;
|
|
410
|
+
}
|
|
411
|
+
/** Get file mtime as epoch ms, returning 0 on error. */
|
|
412
|
+
function fileMtime(filePath) {
|
|
413
|
+
try {
|
|
414
|
+
return statSync(filePath).mtimeMs;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Move a directory, falling back to recursive copy + delete on EXDEV
|
|
422
|
+
* (cross-device rename failure, e.g. Docker volumes, NFS mounts).
|
|
423
|
+
*/
|
|
424
|
+
function crossDeviceMove(src, dest, log) {
|
|
425
|
+
try {
|
|
426
|
+
renameSync(src, dest);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
if (err.code === "EXDEV") {
|
|
430
|
+
log(` Cross-device move detected, copying: ${basename(src)}`);
|
|
431
|
+
cpSync(src, dest, { recursive: true });
|
|
432
|
+
rmSync(src, { recursive: true, force: true });
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function migrateProject(projectId, hashDirs, aoBaseDir, dryRun, log) {
|
|
440
|
+
const projectDir = join(aoBaseDir, "projects", projectId);
|
|
441
|
+
const sessionsDir = join(projectDir, "sessions");
|
|
442
|
+
const worktreesDir = join(projectDir, "worktrees");
|
|
443
|
+
if (!dryRun) {
|
|
444
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
445
|
+
mkdirSync(worktreesDir, { recursive: true });
|
|
446
|
+
}
|
|
447
|
+
const result = {
|
|
448
|
+
sessions: 0,
|
|
449
|
+
worktrees: 0,
|
|
450
|
+
workspaceMoves: [],
|
|
451
|
+
};
|
|
452
|
+
// Collect all sessions across hash dirs
|
|
453
|
+
const allSessions = new Map();
|
|
454
|
+
for (const hashDir of hashDirs) {
|
|
455
|
+
const oldSessionsDir = join(hashDir.path, "sessions");
|
|
456
|
+
if (!existsSync(oldSessionsDir))
|
|
457
|
+
continue;
|
|
458
|
+
for (const file of readdirSync(oldSessionsDir)) {
|
|
459
|
+
if (file === "archive" || file.startsWith("."))
|
|
460
|
+
continue;
|
|
461
|
+
const filePath = join(oldSessionsDir, file);
|
|
462
|
+
try {
|
|
463
|
+
if (!statSync(filePath).isFile())
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// Strip .json extension if present
|
|
470
|
+
const sessionId = file.endsWith(".json") ? file.slice(0, -5) : file;
|
|
471
|
+
const metadata = readAndConvertMetadata(filePath);
|
|
472
|
+
if (!metadata) {
|
|
473
|
+
log(` Warning: could not read metadata for ${sessionId} in ${hashDir.path}`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
// Handle duplicate session IDs across hash dirs.
|
|
477
|
+
//
|
|
478
|
+
// The multi-hash bug this PR cleans up made it possible for two
|
|
479
|
+
// unrelated `{hash}-{projectId}/sessions/` dirs to each carry an
|
|
480
|
+
// independently-numbered `ao-N` for the same project. Silently
|
|
481
|
+
// dropping the loser would lose work the user never marked
|
|
482
|
+
// terminal. Instead, rename the loser to
|
|
483
|
+
// `${sessionId}__from-${hash}` so both records survive in V2.
|
|
484
|
+
// The renamed copy is still a valid V2 sessionId
|
|
485
|
+
// (alphanum/underscore/hyphen) and never collides because the
|
|
486
|
+
// hash prefix is unique per V1 dir. We pick a "winner" using
|
|
487
|
+
// createdAt (newest first), then mtime, then path tiebreaker, so
|
|
488
|
+
// the most recent record keeps the canonical id.
|
|
489
|
+
const existing = allSessions.get(sessionId);
|
|
490
|
+
if (existing) {
|
|
491
|
+
const existingCreated = new Date(String(existing.metadata["createdAt"] ?? "")).getTime() || 0;
|
|
492
|
+
const newCreated = new Date(String(metadata["createdAt"] ?? "")).getTime() || 0;
|
|
493
|
+
const newIsNewer = newCreated > existingCreated
|
|
494
|
+
|| (newCreated === existingCreated && fileMtime(filePath) > fileMtime(existing.sourcePath))
|
|
495
|
+
|| (newCreated === existingCreated && fileMtime(filePath) === fileMtime(existing.sourcePath) && filePath > existing.sourcePath);
|
|
496
|
+
if (newIsNewer) {
|
|
497
|
+
// The new record wins the canonical id. Park the previous
|
|
498
|
+
// entry under a hash-suffixed alias before replacing it.
|
|
499
|
+
const loserHash = existing.sourceHash;
|
|
500
|
+
const loserAlias = `${sessionId}__from-${loserHash}`;
|
|
501
|
+
if (!allSessions.has(loserAlias)) {
|
|
502
|
+
allSessions.set(loserAlias, {
|
|
503
|
+
metadata: existing.metadata,
|
|
504
|
+
sourcePath: existing.sourcePath,
|
|
505
|
+
sourceHash: existing.sourceHash,
|
|
506
|
+
renamedFrom: sessionId,
|
|
507
|
+
});
|
|
508
|
+
log?.(` [rename] duplicate session ${sessionId} from hash ${loserHash} → ${loserAlias}`);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
log?.(` [warn] could not park duplicate ${sessionId} under ${loserAlias}: alias already taken`);
|
|
512
|
+
}
|
|
513
|
+
allSessions.set(sessionId, { metadata, sourcePath: filePath, sourceHash: hashDir.hash });
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
// The existing entry wins. Park THIS record under the alias.
|
|
517
|
+
const loserAlias = `${sessionId}__from-${hashDir.hash}`;
|
|
518
|
+
if (!allSessions.has(loserAlias)) {
|
|
519
|
+
allSessions.set(loserAlias, {
|
|
520
|
+
metadata,
|
|
521
|
+
sourcePath: filePath,
|
|
522
|
+
sourceHash: hashDir.hash,
|
|
523
|
+
renamedFrom: sessionId,
|
|
524
|
+
});
|
|
525
|
+
log?.(` [rename] duplicate session ${sessionId} from hash ${hashDir.hash} → ${loserAlias}`);
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
log?.(` [warn] could not park duplicate ${sessionId} under ${loserAlias}: alias already taken`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
allSessions.set(sessionId, { metadata, sourcePath: filePath, sourceHash: hashDir.hash });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Flatten archives into sessions/ as terminated records
|
|
537
|
+
const oldArchiveDir = join(oldSessionsDir, "archive");
|
|
538
|
+
if (existsSync(oldArchiveDir)) {
|
|
539
|
+
for (const archiveFile of readdirSync(oldArchiveDir)) {
|
|
540
|
+
// Extract sessionId from archive filename: `{sessionId}_{timestamp}[.json]`.
|
|
541
|
+
// Anchor on the timestamp suffix instead of a lazy prefix match —
|
|
542
|
+
// a lazy `[a-zA-Z0-9_-]+?` stops at the first `_<digit>`, so a
|
|
543
|
+
// session like `team_1-7` would be captured as `team` and the
|
|
544
|
+
// archive would be flattened under the wrong sessionId, silently
|
|
545
|
+
// overwriting another session's record.
|
|
546
|
+
// Compact form: 20260420T143052Z. Legacy form: 2026-04-20T14:30:52.000Z
|
|
547
|
+
// (the colon-and-dot legacy format predates the compact rewrite).
|
|
548
|
+
const match = archiveFile.match(/^(.+)_(\d{8}T\d{6}Z|\d{4}-\d{2}-\d{2}T[\d:.-]+Z)(?:\.json)?$/);
|
|
549
|
+
if (!match?.[1])
|
|
550
|
+
continue;
|
|
551
|
+
const archivedSessionId = match[1];
|
|
552
|
+
// Skip if an active session with this ID already exists
|
|
553
|
+
const targetPath = join(sessionsDir, `${archivedSessionId}.json`);
|
|
554
|
+
if (existsSync(targetPath)) {
|
|
555
|
+
log?.(` [skip] archive ${archivedSessionId}: active session already exists`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (dryRun) {
|
|
559
|
+
result.sessions++;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const content = readFileSync(join(oldArchiveDir, archiveFile), "utf-8").trim();
|
|
564
|
+
if (!content)
|
|
565
|
+
continue;
|
|
566
|
+
let metadata;
|
|
567
|
+
try {
|
|
568
|
+
metadata = JSON.parse(content);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Legacy key=value format
|
|
572
|
+
metadata = convertKeyValueToJson(content);
|
|
573
|
+
}
|
|
574
|
+
// Ensure terminated lifecycle state
|
|
575
|
+
if (typeof metadata["lifecycle"] === "object" && metadata["lifecycle"] !== null) {
|
|
576
|
+
const lifecycle = metadata["lifecycle"];
|
|
577
|
+
if (typeof lifecycle["session"] === "object" && lifecycle["session"] !== null) {
|
|
578
|
+
const session = lifecycle["session"];
|
|
579
|
+
if (session["state"] !== "terminated" && session["state"] !== "done") {
|
|
580
|
+
session["state"] = "terminated";
|
|
581
|
+
session["reason"] = session["reason"] ?? "migrated_from_archive";
|
|
582
|
+
session["terminatedAt"] = session["terminatedAt"] ?? new Date().toISOString();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
// Flat metadata — set status directly
|
|
588
|
+
metadata["status"] = metadata["status"] ?? "terminated";
|
|
589
|
+
}
|
|
590
|
+
atomicWriteFileSync(targetPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
591
|
+
result.sessions++;
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
log?.(` [warn] failed to flatten archive ${archiveFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Migrate worktrees
|
|
599
|
+
const oldWorktreesDir = join(hashDir.path, "worktrees");
|
|
600
|
+
if (existsSync(oldWorktreesDir)) {
|
|
601
|
+
for (const worktreeName of readdirSync(oldWorktreesDir)) {
|
|
602
|
+
const srcWorktree = join(oldWorktreesDir, worktreeName);
|
|
603
|
+
try {
|
|
604
|
+
if (!statSync(srcWorktree).isDirectory())
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const destWorktree = join(worktreesDir, worktreeName);
|
|
611
|
+
if (!existsSync(destWorktree) && !dryRun) {
|
|
612
|
+
crossDeviceMove(srcWorktree, destWorktree, log);
|
|
613
|
+
}
|
|
614
|
+
result.worktrees++;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Write all sessions to sessions/ (including orchestrators — runtime reads from sessions/)
|
|
619
|
+
for (const [sessionId, { metadata, renamedFrom }] of allSessions) {
|
|
620
|
+
// Renamed (loser-of-conflict) entries are preserved for inspection
|
|
621
|
+
// only — their V1 worktree was clobbered by the canonical entry's
|
|
622
|
+
// move (workspace dirs are keyed on the un-aliased sessionId). Keep
|
|
623
|
+
// the metadata pointing at the V1 worktree path so the user can
|
|
624
|
+
// still locate the original directory under its `.migrated` parent.
|
|
625
|
+
if (!renamedFrom &&
|
|
626
|
+
typeof metadata["worktree"] === "string" &&
|
|
627
|
+
metadata["worktree"]) {
|
|
628
|
+
const oldWorktreePath = metadata["worktree"];
|
|
629
|
+
const newWorktreePath = join(worktreesDir, sessionId);
|
|
630
|
+
if (existsSync(newWorktreePath) || dryRun) {
|
|
631
|
+
metadata["worktree"] = newWorktreePath;
|
|
632
|
+
// Capture (old, new) so we can relink agent session storage later.
|
|
633
|
+
// No-op when oldWorktreePath === newWorktreePath (rare, happens if
|
|
634
|
+
// metadata was already pointing at the V2 path on a re-run).
|
|
635
|
+
if (oldWorktreePath !== newWorktreePath) {
|
|
636
|
+
result.workspaceMoves.push({
|
|
637
|
+
oldWorkspacePath: oldWorktreePath,
|
|
638
|
+
newWorkspacePath: newWorktreePath,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// Otherwise keep the original path — the worktree may be at ~/.worktrees/{projectId}/{sessionId}/
|
|
643
|
+
// and will be moved by moveStrayWorktrees() later
|
|
644
|
+
}
|
|
645
|
+
if (!dryRun) {
|
|
646
|
+
const destPath = join(sessionsDir, `${sessionId}.json`);
|
|
647
|
+
atomicWriteFileSync(destPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
648
|
+
}
|
|
649
|
+
result.sessions++;
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
// Git worktree repair — fix references broken by directory moves
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
/**
|
|
657
|
+
* After moving worktree directories, git's internal references
|
|
658
|
+
* (.git/worktrees/{id}/gitdir) still point to the old location.
|
|
659
|
+
* Run `git worktree repair` from each project's repo root to fix them.
|
|
660
|
+
*/
|
|
661
|
+
async function repairGitWorktrees(aoBaseDir, globalConfigPath, log) {
|
|
662
|
+
// Build projectId → repo path lookup from global config
|
|
663
|
+
const repoPathByProject = new Map();
|
|
664
|
+
try {
|
|
665
|
+
if (existsSync(globalConfigPath)) {
|
|
666
|
+
const content = readFileSync(globalConfigPath, "utf-8");
|
|
667
|
+
const parsed = parse(content);
|
|
668
|
+
const projects = parsed?.["projects"];
|
|
669
|
+
if (projects && typeof projects === "object") {
|
|
670
|
+
for (const [projectId, entry] of Object.entries(projects)) {
|
|
671
|
+
if (entry && typeof entry["path"] === "string") {
|
|
672
|
+
repoPathByProject.set(projectId, entry["path"]);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// Config unreadable — skip repair
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const projectsDir = join(aoBaseDir, "projects");
|
|
683
|
+
if (!existsSync(projectsDir))
|
|
684
|
+
return;
|
|
685
|
+
const { execSync } = await import('node:child_process');
|
|
686
|
+
for (const projectId of readdirSync(projectsDir)) {
|
|
687
|
+
const worktreesDir = join(projectsDir, projectId, "worktrees");
|
|
688
|
+
if (!existsSync(worktreesDir))
|
|
689
|
+
continue;
|
|
690
|
+
const repoPath = repoPathByProject.get(projectId);
|
|
691
|
+
if (!repoPath || !existsSync(repoPath))
|
|
692
|
+
continue;
|
|
693
|
+
try {
|
|
694
|
+
execSync(`git worktree repair`, { cwd: repoPath, timeout: 10_000, stdio: "ignore" });
|
|
695
|
+
log(` Repaired git worktree references for ${projectId}`);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
log(` Warning: git worktree repair failed for ${projectId} — run manually in ${repoPath}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Config update — strip storageKey
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
function stripStorageKeysFromConfig(configPath, dryRun, log) {
|
|
706
|
+
if (!existsSync(configPath))
|
|
707
|
+
return;
|
|
708
|
+
const content = readFileSync(configPath, "utf-8");
|
|
709
|
+
const parsed = parse(content);
|
|
710
|
+
if (!parsed || typeof parsed !== "object")
|
|
711
|
+
return;
|
|
712
|
+
const projects = parsed["projects"];
|
|
713
|
+
if (!projects || typeof projects !== "object")
|
|
714
|
+
return;
|
|
715
|
+
let stripped = 0;
|
|
716
|
+
for (const [, entry] of Object.entries(projects)) {
|
|
717
|
+
if (entry && typeof entry === "object" && "storageKey" in entry) {
|
|
718
|
+
delete entry["storageKey"];
|
|
719
|
+
stripped++;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (stripped > 0) {
|
|
723
|
+
log(` Stripped storageKey from ${stripped} project(s) in config.`);
|
|
724
|
+
if (!dryRun) {
|
|
725
|
+
withFileLockSync(`${configPath}.lock`, () => {
|
|
726
|
+
// Backup the config before modifying
|
|
727
|
+
const backupPath = `${configPath}.pre-migration`;
|
|
728
|
+
if (!existsSync(backupPath)) {
|
|
729
|
+
atomicWriteFileSync(backupPath, content);
|
|
730
|
+
log(` Config backed up to ${basename(backupPath)}`);
|
|
731
|
+
}
|
|
732
|
+
atomicWriteFileSync(configPath, stringify(parsed, { indent: 2 }));
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Agent session storage relinking (Mode A fix for PR #1466)
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
/**
|
|
741
|
+
* Encode a workspace path the way Claude Code does for `~/.claude/projects/`.
|
|
742
|
+
* Mirrors `toClaudeProjectPath` in `agent-claude-code/src/index.ts`. Kept
|
|
743
|
+
* in sync by hand — duplicating the function here avoids pulling the agent
|
|
744
|
+
* plugin into core/migration just for this string transformation.
|
|
745
|
+
*/
|
|
746
|
+
function encodeClaudeProjectPath(workspacePath) {
|
|
747
|
+
return workspacePath
|
|
748
|
+
.replace(/\\/g, "/")
|
|
749
|
+
.replace(/:/g, "")
|
|
750
|
+
.replace(/[/.]/g, "-");
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* After `migrate-storage` moves a session's worktree from V1 to V2, Claude
|
|
754
|
+
* Code's session JSONLs are still keyed by the encoded form of the OLD
|
|
755
|
+
* workspace path, so `getRestoreCommand` looks up the new encoded path,
|
|
756
|
+
* finds nothing, and the agent launches without chat history.
|
|
757
|
+
*
|
|
758
|
+
* Move each `~/.claude/projects/<encoded(old)>/` directory to
|
|
759
|
+
* `<encoded(new)>/`. Skip when the source doesn't exist (no Claude history)
|
|
760
|
+
* or the target already exists (manual reconciliation needed). Both paths
|
|
761
|
+
* resolving to the same encoded string is a no-op.
|
|
762
|
+
*
|
|
763
|
+
* Returns the number of directories actually relinked.
|
|
764
|
+
*
|
|
765
|
+
* Codex stores its sessions date-sharded with the cwd embedded inside each
|
|
766
|
+
* JSONL's `session_meta` line, so the same physical-rename trick doesn't
|
|
767
|
+
* apply. Codex relinking is a separate follow-up — see PR #1466 thread.
|
|
768
|
+
*/
|
|
769
|
+
function relinkClaudeSessionStorage(moves, dryRun, log) {
|
|
770
|
+
if (moves.length === 0)
|
|
771
|
+
return 0;
|
|
772
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
773
|
+
if (!existsSync(claudeProjectsDir))
|
|
774
|
+
return 0;
|
|
775
|
+
let relinked = 0;
|
|
776
|
+
for (const { oldWorkspacePath, newWorkspacePath } of moves) {
|
|
777
|
+
const oldEncoded = encodeClaudeProjectPath(oldWorkspacePath);
|
|
778
|
+
const newEncoded = encodeClaudeProjectPath(newWorkspacePath);
|
|
779
|
+
if (oldEncoded === newEncoded)
|
|
780
|
+
continue;
|
|
781
|
+
const oldDir = join(claudeProjectsDir, oldEncoded);
|
|
782
|
+
const newDir = join(claudeProjectsDir, newEncoded);
|
|
783
|
+
if (!existsSync(oldDir))
|
|
784
|
+
continue; // no Claude history for this session — nothing to do
|
|
785
|
+
if (existsSync(newDir)) {
|
|
786
|
+
log(` [skip] Claude session dir already exists at new path: ${newEncoded}`);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (dryRun) {
|
|
790
|
+
log(` [dry-run] Would relink Claude sessions: ${oldEncoded} → ${newEncoded}`);
|
|
791
|
+
relinked++;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
renameSync(oldDir, newDir);
|
|
796
|
+
log(` Relinked Claude sessions: ${oldEncoded} → ${newEncoded}`);
|
|
797
|
+
relinked++;
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
log(` [warn] failed to relink Claude session dir ${oldEncoded}: ${err instanceof Error ? err.message : String(err)}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return relinked;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Codex stores rollouts at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` and
|
|
807
|
+
* embeds the working directory inside the very first JSONL record's
|
|
808
|
+
* `session_meta` payload. The `agent-codex` plugin's restore lookup matches
|
|
809
|
+
* `session_meta.cwd === session.workspacePath` exactly, so once
|
|
810
|
+
* `migrate-storage` rewrites a session's `workspacePath` to the V2 layout,
|
|
811
|
+
* Codex restore stops finding the prior thread and `getRestoreCommand`
|
|
812
|
+
* returns null — the user loses chat history on `athene start` restore.
|
|
813
|
+
*
|
|
814
|
+
* For each (oldPath → newPath) move, scan rollout files, look at the first
|
|
815
|
+
* non-empty parsed line, and if it is a `session_meta` entry whose
|
|
816
|
+
* `payload.cwd` exactly matches `oldWorkspacePath`, rewrite that single line
|
|
817
|
+
* to point at `newWorkspacePath`. Other lines are copied byte-for-byte. The
|
|
818
|
+
* rewrite goes through an atomic temp-file rename so a crash mid-rewrite
|
|
819
|
+
* cannot corrupt the rollout.
|
|
820
|
+
*
|
|
821
|
+
* Returns the number of rollout files actually rewritten.
|
|
822
|
+
*/
|
|
823
|
+
function rewriteCodexSessionStorage(moves, dryRun, log) {
|
|
824
|
+
if (moves.length === 0)
|
|
825
|
+
return 0;
|
|
826
|
+
const codexSessionsDir = join(homedir(), ".codex", "sessions");
|
|
827
|
+
if (!existsSync(codexSessionsDir))
|
|
828
|
+
return 0;
|
|
829
|
+
// Index moves by old path for O(1) lookup.
|
|
830
|
+
const oldToNew = new Map();
|
|
831
|
+
for (const { oldWorkspacePath, newWorkspacePath } of moves) {
|
|
832
|
+
if (oldWorkspacePath !== newWorkspacePath) {
|
|
833
|
+
oldToNew.set(oldWorkspacePath, newWorkspacePath);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (oldToNew.size === 0)
|
|
837
|
+
return 0;
|
|
838
|
+
// Walk year/month/day shards collecting rollout-*.jsonl files.
|
|
839
|
+
const jsonlFiles = [];
|
|
840
|
+
function walk(dir) {
|
|
841
|
+
let entries;
|
|
842
|
+
try {
|
|
843
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
for (const entry of entries) {
|
|
849
|
+
const full = join(dir, entry.name);
|
|
850
|
+
if (entry.isDirectory())
|
|
851
|
+
walk(full);
|
|
852
|
+
else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
|
|
853
|
+
jsonlFiles.push(full);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
walk(codexSessionsDir);
|
|
858
|
+
let rewritten = 0;
|
|
859
|
+
for (const filePath of jsonlFiles) {
|
|
860
|
+
let content;
|
|
861
|
+
try {
|
|
862
|
+
content = readFileSync(filePath, "utf-8");
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
// Find the first parseable JSONL line. Codex writes session_meta as the
|
|
868
|
+
// very first record so this is cheap; bail out after a small bounded
|
|
869
|
+
// scan to avoid pathological cases.
|
|
870
|
+
const newlineIdx = content.indexOf("\n");
|
|
871
|
+
const firstLineEnd = newlineIdx === -1 ? content.length : newlineIdx;
|
|
872
|
+
const firstLine = content.slice(0, firstLineEnd);
|
|
873
|
+
if (!firstLine.trim())
|
|
874
|
+
continue;
|
|
875
|
+
let parsed;
|
|
876
|
+
try {
|
|
877
|
+
parsed = JSON.parse(firstLine);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
883
|
+
continue;
|
|
884
|
+
const entry = parsed;
|
|
885
|
+
if (entry.type !== "session_meta")
|
|
886
|
+
continue;
|
|
887
|
+
const cwd = entry.payload?.cwd;
|
|
888
|
+
if (typeof cwd !== "string")
|
|
889
|
+
continue;
|
|
890
|
+
const newCwd = oldToNew.get(cwd);
|
|
891
|
+
if (!newCwd)
|
|
892
|
+
continue;
|
|
893
|
+
if (dryRun) {
|
|
894
|
+
log(` [dry-run] Would rewrite Codex session_meta cwd: ${filePath}`);
|
|
895
|
+
log(` ${cwd} → ${newCwd}`);
|
|
896
|
+
rewritten++;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
// Mutate only the cwd field. Preserve insertion order and other payload
|
|
900
|
+
// fields by editing the parsed object then re-serialising.
|
|
901
|
+
entry.payload.cwd = newCwd;
|
|
902
|
+
const newFirstLine = JSON.stringify(entry);
|
|
903
|
+
const rest = newlineIdx === -1 ? "" : content.slice(newlineIdx);
|
|
904
|
+
try {
|
|
905
|
+
atomicWriteFileSync(filePath, newFirstLine + rest);
|
|
906
|
+
log(` Rewrote Codex session_meta cwd in ${filePath}`);
|
|
907
|
+
rewritten++;
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
log(` [warn] failed to rewrite Codex session ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return rewritten;
|
|
914
|
+
}
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
// Stray worktree detection
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
/**
|
|
919
|
+
* Try to move a single worktree directory to the matching project.
|
|
920
|
+
* Returns true if matched and moved (or would be moved in dry-run).
|
|
921
|
+
* Appends a (old, new) pair to `workspaceMoves` when a real move happens
|
|
922
|
+
* so the caller can relink agent session storage afterwards.
|
|
923
|
+
*/
|
|
924
|
+
function tryMoveWorktree(sessionId, srcPath, projectsDir, dryRun, log, workspaceMoves, skipProjects) {
|
|
925
|
+
for (const projectId of readdirSync(projectsDir)) {
|
|
926
|
+
if (skipProjects?.has(projectId))
|
|
927
|
+
continue;
|
|
928
|
+
const sessionsDir = join(projectsDir, projectId, "sessions");
|
|
929
|
+
if (!existsSync(sessionsDir))
|
|
930
|
+
continue;
|
|
931
|
+
const sessionFile = join(sessionsDir, `${sessionId}.json`);
|
|
932
|
+
if (existsSync(sessionFile)) {
|
|
933
|
+
const destPath = join(projectsDir, projectId, "worktrees", sessionId);
|
|
934
|
+
if (!existsSync(destPath)) {
|
|
935
|
+
log(` Moving stray worktree ${sessionId} → projects/${projectId}/worktrees/`);
|
|
936
|
+
if (srcPath !== destPath) {
|
|
937
|
+
workspaceMoves.push({
|
|
938
|
+
oldWorkspacePath: srcPath,
|
|
939
|
+
newWorkspacePath: destPath,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
if (!dryRun) {
|
|
943
|
+
mkdirSync(join(projectsDir, projectId, "worktrees"), { recursive: true });
|
|
944
|
+
crossDeviceMove(srcPath, destPath, log);
|
|
945
|
+
// Patch session JSON to point at the new worktree location
|
|
946
|
+
try {
|
|
947
|
+
const raw = readFileSync(sessionFile, "utf-8");
|
|
948
|
+
const meta = JSON.parse(raw);
|
|
949
|
+
if (typeof meta["worktree"] === "string") {
|
|
950
|
+
meta["worktree"] = destPath;
|
|
951
|
+
atomicWriteFileSync(sessionFile, JSON.stringify(meta, null, 2) + "\n");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
log(` Warning: could not patch worktree path in ${sessionId}.json`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
function moveStrayWorktrees(aoBaseDir, dryRun, log, workspaceMoves, skipProjects) {
|
|
965
|
+
const strayDir = join(homedir(), ".worktrees");
|
|
966
|
+
if (!existsSync(strayDir))
|
|
967
|
+
return 0;
|
|
968
|
+
const projectsDir = join(aoBaseDir, "projects");
|
|
969
|
+
if (!existsSync(projectsDir))
|
|
970
|
+
return 0;
|
|
971
|
+
let moved = 0;
|
|
972
|
+
for (const name of readdirSync(strayDir)) {
|
|
973
|
+
const srcPath = join(strayDir, name);
|
|
974
|
+
try {
|
|
975
|
+
if (!statSync(srcPath).isDirectory())
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
// The default workspace plugin stores worktrees at ~/.worktrees/{projectId}/{sessionId}/.
|
|
982
|
+
// Check if this entry is a projectId directory containing session worktrees.
|
|
983
|
+
const children = readdirSync(srcPath);
|
|
984
|
+
let isProjectDir = false;
|
|
985
|
+
for (const child of children) {
|
|
986
|
+
const childPath = join(srcPath, child);
|
|
987
|
+
try {
|
|
988
|
+
if (!statSync(childPath).isDirectory())
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
// If any child matches a session in any project, treat parent as a projectId dir
|
|
995
|
+
if (tryMoveWorktree(child, childPath, projectsDir, dryRun, log, workspaceMoves, skipProjects)) {
|
|
996
|
+
moved++;
|
|
997
|
+
isProjectDir = true;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (isProjectDir) {
|
|
1001
|
+
// Remove the now-empty projectId directory (if empty)
|
|
1002
|
+
if (!dryRun) {
|
|
1003
|
+
try {
|
|
1004
|
+
const remaining = readdirSync(srcPath);
|
|
1005
|
+
if (remaining.length === 0) {
|
|
1006
|
+
rmSync(srcPath, { recursive: true, force: true });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
// Ignore — non-critical
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
// Not a projectId directory — treat as a flat session worktree
|
|
1016
|
+
if (tryMoveWorktree(name, srcPath, projectsDir, dryRun, log, workspaceMoves, skipProjects)) {
|
|
1017
|
+
moved++;
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
log(` Warning: stray worktree ${name} in ~/.worktrees/ has no matching session — left in place.`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return moved;
|
|
1024
|
+
}
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
// Main migration entry point
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
async function migrateStorage(options = {}) {
|
|
1029
|
+
const aoBaseDir = options.aoBaseDir ?? join(homedir(), ".agent-orchestrator");
|
|
1030
|
+
const dryRun = options.dryRun ?? false;
|
|
1031
|
+
const log = options.log ?? console.log;
|
|
1032
|
+
const globalConfigPath = options.globalConfigPath ??
|
|
1033
|
+
join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "agent-orchestrator", "config.yaml");
|
|
1034
|
+
// Use the actual global config path if it exists at the standard location
|
|
1035
|
+
const effectiveConfigPath = existsSync(globalConfigPath)
|
|
1036
|
+
? globalConfigPath
|
|
1037
|
+
: existsSync(join(aoBaseDir, "config.yaml"))
|
|
1038
|
+
? join(aoBaseDir, "config.yaml")
|
|
1039
|
+
: globalConfigPath;
|
|
1040
|
+
if (dryRun) {
|
|
1041
|
+
log("DRY RUN — no changes will be made.\n");
|
|
1042
|
+
}
|
|
1043
|
+
// Crash-safety: detect incomplete previous migration
|
|
1044
|
+
const markerPath = join(aoBaseDir, MIGRATION_MARKER);
|
|
1045
|
+
if (existsSync(markerPath)) {
|
|
1046
|
+
log("WARNING: Previous migration was interrupted. Re-running — already-migrated directories will be skipped.\n");
|
|
1047
|
+
}
|
|
1048
|
+
// Pre-flight: detect active sessions (include V2 prefix patterns from config)
|
|
1049
|
+
if (!options.force && !dryRun) {
|
|
1050
|
+
const knownPrefixes = extractProjectPrefixes(effectiveConfigPath);
|
|
1051
|
+
const activeSessions = await detectActiveSessions(knownPrefixes);
|
|
1052
|
+
if (activeSessions.length > 0) {
|
|
1053
|
+
recordActivityEvent({
|
|
1054
|
+
source: "migration",
|
|
1055
|
+
kind: "migration.blocked",
|
|
1056
|
+
level: "warn",
|
|
1057
|
+
summary: `migration blocked by ${activeSessions.length} active session(s)`,
|
|
1058
|
+
data: {
|
|
1059
|
+
activeSessionCount: activeSessions.length,
|
|
1060
|
+
sample: activeSessions.slice(0, 5),
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
throw new Error(`Found ${activeSessions.length} active AO tmux session(s): ${activeSessions.slice(0, 5).join(", ")}${activeSessions.length > 5 ? "..." : ""}. ` +
|
|
1064
|
+
`Kill active sessions first (athene session kill --all) or use --force to migrate anyway.`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// Write marker file before making any changes (removed on success)
|
|
1068
|
+
if (!dryRun) {
|
|
1069
|
+
writeFileSync(markerPath, new Date().toISOString());
|
|
1070
|
+
}
|
|
1071
|
+
// Inventory hash directories (pass config path for bare-hash projectId lookup)
|
|
1072
|
+
const hashDirs = inventoryHashDirs(aoBaseDir, effectiveConfigPath);
|
|
1073
|
+
if (hashDirs.length === 0) {
|
|
1074
|
+
log("No legacy hash-based directories found. Nothing to migrate.");
|
|
1075
|
+
if (!dryRun && existsSync(markerPath)) {
|
|
1076
|
+
try {
|
|
1077
|
+
unlinkSync(markerPath);
|
|
1078
|
+
}
|
|
1079
|
+
catch { /* best-effort */ }
|
|
1080
|
+
}
|
|
1081
|
+
const totals = {
|
|
1082
|
+
projects: 0,
|
|
1083
|
+
sessions: 0,
|
|
1084
|
+
worktrees: 0,
|
|
1085
|
+
emptyDirsDeleted: 0,
|
|
1086
|
+
strayWorktreesMoved: 0,
|
|
1087
|
+
claudeSessionsRelinked: 0,
|
|
1088
|
+
codexSessionsRewritten: 0,
|
|
1089
|
+
};
|
|
1090
|
+
recordActivityEvent({
|
|
1091
|
+
source: "migration",
|
|
1092
|
+
kind: "migration.completed",
|
|
1093
|
+
level: "info",
|
|
1094
|
+
summary: "migration completed: 0 project(s), 0 session(s)",
|
|
1095
|
+
data: {
|
|
1096
|
+
dryRun,
|
|
1097
|
+
projectsMigrated: totals.projects,
|
|
1098
|
+
sessions: totals.sessions,
|
|
1099
|
+
worktrees: totals.worktrees,
|
|
1100
|
+
strayWorktreesMoved: totals.strayWorktreesMoved,
|
|
1101
|
+
claudeSessionsRelinked: totals.claudeSessionsRelinked,
|
|
1102
|
+
codexSessionsRewritten: totals.codexSessionsRewritten,
|
|
1103
|
+
emptyDirsDeleted: totals.emptyDirsDeleted,
|
|
1104
|
+
projectErrors: 0,
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
return totals;
|
|
1108
|
+
}
|
|
1109
|
+
log(`Found ${hashDirs.length} legacy director${hashDirs.length === 1 ? "y" : "ies"}.`);
|
|
1110
|
+
// Group by projectId
|
|
1111
|
+
const projectGroups = new Map();
|
|
1112
|
+
for (const entry of hashDirs) {
|
|
1113
|
+
const group = projectGroups.get(entry.projectId) ?? [];
|
|
1114
|
+
group.push(entry);
|
|
1115
|
+
projectGroups.set(entry.projectId, group);
|
|
1116
|
+
}
|
|
1117
|
+
// Detect case-insensitive projectId collisions (macOS HFS+/APFS is case-insensitive)
|
|
1118
|
+
const lowerCaseIndex = new Map();
|
|
1119
|
+
for (const projectId of projectGroups.keys()) {
|
|
1120
|
+
const lower = projectId.toLowerCase();
|
|
1121
|
+
const existing = lowerCaseIndex.get(lower) ?? [];
|
|
1122
|
+
existing.push(projectId);
|
|
1123
|
+
lowerCaseIndex.set(lower, existing);
|
|
1124
|
+
}
|
|
1125
|
+
for (const [lower, ids] of lowerCaseIndex) {
|
|
1126
|
+
if (ids.length > 1) {
|
|
1127
|
+
log(`\nWARNING: Case-insensitive collision detected for projectIds: ${ids.join(", ")} (resolve to "${lower}" on case-insensitive filesystems).`);
|
|
1128
|
+
log(` Skipping colliding projects — rename them manually before re-running migration.`);
|
|
1129
|
+
for (const id of ids) {
|
|
1130
|
+
projectGroups.delete(id);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// Create projects/ directory
|
|
1135
|
+
if (!dryRun) {
|
|
1136
|
+
mkdirSync(join(aoBaseDir, "projects"), { recursive: true });
|
|
1137
|
+
}
|
|
1138
|
+
const totals = {
|
|
1139
|
+
projects: 0,
|
|
1140
|
+
sessions: 0,
|
|
1141
|
+
worktrees: 0,
|
|
1142
|
+
emptyDirsDeleted: 0,
|
|
1143
|
+
strayWorktreesMoved: 0,
|
|
1144
|
+
claudeSessionsRelinked: 0,
|
|
1145
|
+
codexSessionsRewritten: 0,
|
|
1146
|
+
};
|
|
1147
|
+
// (oldWorkspacePath, newWorkspacePath) pairs collected across both
|
|
1148
|
+
// migration phases. Drives the agent-session-storage relink at the end.
|
|
1149
|
+
const allWorkspaceMoves = [];
|
|
1150
|
+
// Migrate each project
|
|
1151
|
+
const projectErrors = [];
|
|
1152
|
+
for (const [projectId, dirs] of projectGroups) {
|
|
1153
|
+
const nonEmpty = dirs.filter((d) => !d.empty);
|
|
1154
|
+
if (nonEmpty.length === 0) {
|
|
1155
|
+
// All dirs are empty — just delete them
|
|
1156
|
+
for (const dir of dirs) {
|
|
1157
|
+
log(` Deleting empty directory: ${basename(dir.path)}`);
|
|
1158
|
+
if (!dryRun) {
|
|
1159
|
+
rmSync(dir.path, { recursive: true, force: true });
|
|
1160
|
+
}
|
|
1161
|
+
totals.emptyDirsDeleted++;
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
log(`\nMigrating project: ${projectId} (${dirs.length} hash dir${dirs.length > 1 ? "s" : ""})`);
|
|
1166
|
+
try {
|
|
1167
|
+
const projectResult = migrateProject(projectId, dirs, aoBaseDir, dryRun, log);
|
|
1168
|
+
totals.projects++;
|
|
1169
|
+
totals.sessions += projectResult.sessions;
|
|
1170
|
+
totals.worktrees += projectResult.worktrees;
|
|
1171
|
+
allWorkspaceMoves.push(...projectResult.workspaceMoves);
|
|
1172
|
+
}
|
|
1173
|
+
catch (err) {
|
|
1174
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1175
|
+
log(` ERROR migrating project ${projectId}: ${msg}`);
|
|
1176
|
+
projectErrors.push({ projectId, error: msg });
|
|
1177
|
+
recordActivityEvent({
|
|
1178
|
+
projectId,
|
|
1179
|
+
source: "migration",
|
|
1180
|
+
kind: "migration.project_failed",
|
|
1181
|
+
level: "error",
|
|
1182
|
+
summary: `migration failed for project ${projectId}`,
|
|
1183
|
+
data: {
|
|
1184
|
+
dryRun,
|
|
1185
|
+
hashDirCount: dirs.length,
|
|
1186
|
+
error: msg,
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
// Rename old directories to .migrated
|
|
1192
|
+
for (const dir of dirs) {
|
|
1193
|
+
if (dir.empty) {
|
|
1194
|
+
log(` Deleting empty directory: ${basename(dir.path)}`);
|
|
1195
|
+
if (!dryRun) {
|
|
1196
|
+
rmSync(dir.path, { recursive: true, force: true });
|
|
1197
|
+
}
|
|
1198
|
+
totals.emptyDirsDeleted++;
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
const migratedPath = `${dir.path}.migrated`;
|
|
1202
|
+
log(` Renaming: ${basename(dir.path)} → ${basename(dir.path)}.migrated`);
|
|
1203
|
+
if (!dryRun) {
|
|
1204
|
+
try {
|
|
1205
|
+
renameSync(dir.path, migratedPath);
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
// .migrated target may already exist from a previous interrupted run
|
|
1209
|
+
if (err.code === "ENOTEMPTY" && existsSync(migratedPath)) {
|
|
1210
|
+
log(` WARNING: ${basename(migratedPath)} already exists — removing source directory`);
|
|
1211
|
+
rmSync(dir.path, { recursive: true, force: true });
|
|
1212
|
+
}
|
|
1213
|
+
else {
|
|
1214
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1215
|
+
log(` ERROR: Failed to rename ${basename(dir.path)}: ${msg}`);
|
|
1216
|
+
projectErrors.push({
|
|
1217
|
+
projectId,
|
|
1218
|
+
error: `Failed to rename ${basename(dir.path)} to ${basename(migratedPath)}: ${msg}`,
|
|
1219
|
+
});
|
|
1220
|
+
recordActivityEvent({
|
|
1221
|
+
projectId,
|
|
1222
|
+
source: "migration",
|
|
1223
|
+
kind: "migration.rename_failed",
|
|
1224
|
+
level: "error",
|
|
1225
|
+
summary: `failed to rename ${basename(dir.path)} to .migrated`,
|
|
1226
|
+
data: {
|
|
1227
|
+
from: basename(dir.path),
|
|
1228
|
+
to: basename(migratedPath),
|
|
1229
|
+
error: msg,
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// Move stray worktrees from ~/.worktrees/ (skip projects that failed migration)
|
|
1239
|
+
const failedProjects = new Set(projectErrors.map((e) => e.projectId));
|
|
1240
|
+
totals.strayWorktreesMoved = moveStrayWorktrees(aoBaseDir, dryRun, log, allWorkspaceMoves, failedProjects);
|
|
1241
|
+
// Repair git worktree references broken by directory moves
|
|
1242
|
+
if (!dryRun && (totals.worktrees > 0 || totals.strayWorktreesMoved > 0)) {
|
|
1243
|
+
await repairGitWorktrees(aoBaseDir, effectiveConfigPath, log);
|
|
1244
|
+
}
|
|
1245
|
+
// Relink Claude Code session storage so chat history survives the
|
|
1246
|
+
// worktree-path change. Without this, athene start → restore launches a
|
|
1247
|
+
// fresh `claude` instance and the prior conversation is lost.
|
|
1248
|
+
totals.claudeSessionsRelinked = relinkClaudeSessionStorage(allWorkspaceMoves, dryRun, log);
|
|
1249
|
+
totals.codexSessionsRewritten = rewriteCodexSessionStorage(allWorkspaceMoves, dryRun, log);
|
|
1250
|
+
// Only strip storageKey and remove marker when ALL projects succeeded.
|
|
1251
|
+
// Partial failure leaves the marker and config intact so the migration
|
|
1252
|
+
// can be retried after fixing the failing project(s).
|
|
1253
|
+
if (projectErrors.length === 0) {
|
|
1254
|
+
log("\nUpdating config...");
|
|
1255
|
+
stripStorageKeysFromConfig(effectiveConfigPath, dryRun, log);
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
log("\nSkipping config update — some projects failed migration.");
|
|
1259
|
+
}
|
|
1260
|
+
// Summary
|
|
1261
|
+
log("\n--- Migration Summary ---");
|
|
1262
|
+
log(`Migrated ${totals.projects} project${totals.projects !== 1 ? "s" : ""}, ` +
|
|
1263
|
+
`${totals.sessions} session${totals.sessions !== 1 ? "s" : ""}, ` +
|
|
1264
|
+
`${totals.worktrees} worktree${totals.worktrees !== 1 ? "s" : ""}.`);
|
|
1265
|
+
if (totals.strayWorktreesMoved > 0) {
|
|
1266
|
+
log(`Moved ${totals.strayWorktreesMoved} stray worktree${totals.strayWorktreesMoved !== 1 ? "s" : ""} from ~/.worktrees/.`);
|
|
1267
|
+
}
|
|
1268
|
+
if (totals.claudeSessionsRelinked > 0) {
|
|
1269
|
+
log(`Relinked ${totals.claudeSessionsRelinked} Claude session director${totals.claudeSessionsRelinked !== 1 ? "ies" : "y"} to new worktree paths.`);
|
|
1270
|
+
}
|
|
1271
|
+
if (totals.codexSessionsRewritten > 0) {
|
|
1272
|
+
log(`Rewrote ${totals.codexSessionsRewritten} Codex rollout file${totals.codexSessionsRewritten !== 1 ? "s" : ""} to new worktree paths.`);
|
|
1273
|
+
}
|
|
1274
|
+
if (totals.emptyDirsDeleted > 0) {
|
|
1275
|
+
log(`Deleted ${totals.emptyDirsDeleted} empty director${totals.emptyDirsDeleted !== 1 ? "ies" : "y"}.`);
|
|
1276
|
+
}
|
|
1277
|
+
if (projectErrors.length > 0) {
|
|
1278
|
+
log(`\nFailed to migrate ${projectErrors.length} project${projectErrors.length !== 1 ? "s" : ""}:`);
|
|
1279
|
+
for (const { projectId, error } of projectErrors) {
|
|
1280
|
+
log(` - ${projectId}: ${error}`);
|
|
1281
|
+
}
|
|
1282
|
+
log("Migration marker preserved — re-run after fixing the above errors.");
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
log("Old directories renamed to *.migrated — verify and rm -rf when ready.");
|
|
1286
|
+
}
|
|
1287
|
+
// Remove crash-safety marker only on full success
|
|
1288
|
+
if (!dryRun && existsSync(markerPath) && projectErrors.length === 0) {
|
|
1289
|
+
try {
|
|
1290
|
+
unlinkSync(markerPath);
|
|
1291
|
+
}
|
|
1292
|
+
catch { /* best-effort */ }
|
|
1293
|
+
}
|
|
1294
|
+
recordActivityEvent({
|
|
1295
|
+
source: "migration",
|
|
1296
|
+
kind: "migration.completed",
|
|
1297
|
+
level: projectErrors.length > 0 ? "warn" : "info",
|
|
1298
|
+
summary: projectErrors.length > 0
|
|
1299
|
+
? `migration finished with ${projectErrors.length} error(s)`
|
|
1300
|
+
: `migration completed: ${totals.projects} project(s), ${totals.sessions} session(s)`,
|
|
1301
|
+
data: {
|
|
1302
|
+
dryRun,
|
|
1303
|
+
projectsMigrated: totals.projects,
|
|
1304
|
+
sessions: totals.sessions,
|
|
1305
|
+
worktrees: totals.worktrees,
|
|
1306
|
+
strayWorktreesMoved: totals.strayWorktreesMoved,
|
|
1307
|
+
claudeSessionsRelinked: totals.claudeSessionsRelinked,
|
|
1308
|
+
codexSessionsRewritten: totals.codexSessionsRewritten,
|
|
1309
|
+
emptyDirsDeleted: totals.emptyDirsDeleted,
|
|
1310
|
+
projectErrors: projectErrors.length,
|
|
1311
|
+
},
|
|
1312
|
+
});
|
|
1313
|
+
return totals;
|
|
1314
|
+
}
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
// Rollback
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
/**
|
|
1319
|
+
* Count sessions in a V2 project dir that don't exist in any of the .migrated dirs.
|
|
1320
|
+
* These are sessions created after migration and would be lost by rollback.
|
|
1321
|
+
*/
|
|
1322
|
+
function countPostMigrationSessions(projectDir, migratedDirs) {
|
|
1323
|
+
const sessionsDir = join(projectDir, "sessions");
|
|
1324
|
+
if (!existsSync(sessionsDir))
|
|
1325
|
+
return 0;
|
|
1326
|
+
// Collect all session IDs from .migrated dirs
|
|
1327
|
+
const migratedSessionIds = new Set();
|
|
1328
|
+
for (const dir of migratedDirs) {
|
|
1329
|
+
const oldSessionsDir = join(dir.path, "sessions");
|
|
1330
|
+
if (!existsSync(oldSessionsDir))
|
|
1331
|
+
continue;
|
|
1332
|
+
for (const file of readdirSync(oldSessionsDir)) {
|
|
1333
|
+
if (file === "archive" || file.startsWith("."))
|
|
1334
|
+
continue;
|
|
1335
|
+
const sessionId = file.endsWith(".json") ? file.slice(0, -5) : file;
|
|
1336
|
+
migratedSessionIds.add(sessionId);
|
|
1337
|
+
}
|
|
1338
|
+
const oldArchiveDir = join(oldSessionsDir, "archive");
|
|
1339
|
+
if (!existsSync(oldArchiveDir))
|
|
1340
|
+
continue;
|
|
1341
|
+
for (const file of readdirSync(oldArchiveDir)) {
|
|
1342
|
+
if (file.startsWith("."))
|
|
1343
|
+
continue;
|
|
1344
|
+
// Same anchor-on-timestamp pattern as the archive flattening loop;
|
|
1345
|
+
// the lazy `[a-zA-Z0-9_-]+?_\d` mismatched any sessionId containing
|
|
1346
|
+
// `_<digit>` (e.g. `team_1-7`).
|
|
1347
|
+
const match = file.match(/^(.+)_(\d{8}T\d{6}Z|\d{4}-\d{2}-\d{2}T[\d:.-]+Z)(?:\.json)?$/);
|
|
1348
|
+
if (match?.[1]) {
|
|
1349
|
+
migratedSessionIds.add(match[1]);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// Count sessions in V2 dir that aren't in any .migrated dir
|
|
1354
|
+
let count = 0;
|
|
1355
|
+
for (const file of readdirSync(sessionsDir)) {
|
|
1356
|
+
if (file === "archive" || file.startsWith("."))
|
|
1357
|
+
continue;
|
|
1358
|
+
const sessionId = file.endsWith(".json") ? file.slice(0, -5) : file;
|
|
1359
|
+
if (!migratedSessionIds.has(sessionId)) {
|
|
1360
|
+
count++;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return count;
|
|
1364
|
+
}
|
|
1365
|
+
function collectSessionIds(dirPath) {
|
|
1366
|
+
const sessionIds = new Set();
|
|
1367
|
+
const sessionsDir = join(dirPath, "sessions");
|
|
1368
|
+
if (!existsSync(sessionsDir))
|
|
1369
|
+
return sessionIds;
|
|
1370
|
+
for (const file of readdirSync(sessionsDir)) {
|
|
1371
|
+
if (file === "archive" || file.startsWith("."))
|
|
1372
|
+
continue;
|
|
1373
|
+
sessionIds.add(file.endsWith(".json") ? file.slice(0, -5) : file);
|
|
1374
|
+
}
|
|
1375
|
+
return sessionIds;
|
|
1376
|
+
}
|
|
1377
|
+
function resolveRollbackProjectId(aoBaseDir, migratedDirPath, hash) {
|
|
1378
|
+
const derivedProjectId = deriveProjectIdFromDir(migratedDirPath);
|
|
1379
|
+
if (derivedProjectId)
|
|
1380
|
+
return derivedProjectId;
|
|
1381
|
+
const migratedSessionIds = collectSessionIds(migratedDirPath);
|
|
1382
|
+
if (migratedSessionIds.size === 0)
|
|
1383
|
+
return hash;
|
|
1384
|
+
const projectsDir = join(aoBaseDir, "projects");
|
|
1385
|
+
if (!existsSync(projectsDir))
|
|
1386
|
+
return hash;
|
|
1387
|
+
for (const projectId of readdirSync(projectsDir)) {
|
|
1388
|
+
const projectDir = join(projectsDir, projectId);
|
|
1389
|
+
try {
|
|
1390
|
+
if (!statSync(projectDir).isDirectory())
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
catch {
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
const projectSessionIds = collectSessionIds(projectDir);
|
|
1397
|
+
for (const sessionId of migratedSessionIds) {
|
|
1398
|
+
if (projectSessionIds.has(sessionId))
|
|
1399
|
+
return projectId;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return hash;
|
|
1403
|
+
}
|
|
1404
|
+
async function rollbackStorage(options = {}) {
|
|
1405
|
+
const aoBaseDir = options.aoBaseDir ?? join(homedir(), ".agent-orchestrator");
|
|
1406
|
+
const dryRun = options.dryRun ?? false;
|
|
1407
|
+
const log = options.log ?? console.log;
|
|
1408
|
+
const globalConfigPath = options.globalConfigPath ??
|
|
1409
|
+
join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "agent-orchestrator", "config.yaml");
|
|
1410
|
+
const effectiveConfigPath = existsSync(globalConfigPath)
|
|
1411
|
+
? globalConfigPath
|
|
1412
|
+
: existsSync(join(aoBaseDir, "config.yaml"))
|
|
1413
|
+
? join(aoBaseDir, "config.yaml")
|
|
1414
|
+
: globalConfigPath;
|
|
1415
|
+
if (dryRun) {
|
|
1416
|
+
log("DRY RUN — no changes will be made.\n");
|
|
1417
|
+
}
|
|
1418
|
+
if (!existsSync(aoBaseDir)) {
|
|
1419
|
+
log("No AO base directory found. Nothing to rollback.");
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
// Find .migrated directories (both {hash}-{name}.migrated and {hash}.migrated)
|
|
1423
|
+
const migratedDirs = [];
|
|
1424
|
+
for (const name of readdirSync(aoBaseDir)) {
|
|
1425
|
+
const hashNameMatch = MIGRATED_DIR_PATTERN.exec(name);
|
|
1426
|
+
const bareHashMatch = BARE_MIGRATED_DIR_PATTERN.exec(name);
|
|
1427
|
+
if (!hashNameMatch && !bareHashMatch)
|
|
1428
|
+
continue;
|
|
1429
|
+
const dirPath = join(aoBaseDir, name);
|
|
1430
|
+
try {
|
|
1431
|
+
if (!statSync(dirPath).isDirectory())
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
if (hashNameMatch) {
|
|
1438
|
+
migratedDirs.push({
|
|
1439
|
+
path: dirPath,
|
|
1440
|
+
hash: hashNameMatch[1],
|
|
1441
|
+
projectId: hashNameMatch[2],
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
else if (bareHashMatch) {
|
|
1445
|
+
migratedDirs.push({
|
|
1446
|
+
path: dirPath,
|
|
1447
|
+
hash: bareHashMatch[1],
|
|
1448
|
+
projectId: resolveRollbackProjectId(aoBaseDir, dirPath, bareHashMatch[1]),
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (migratedDirs.length === 0) {
|
|
1453
|
+
log("No .migrated directories found. Nothing to rollback.");
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
log(`Found ${migratedDirs.length} .migrated director${migratedDirs.length === 1 ? "y" : "ies"}.`);
|
|
1457
|
+
// Check for post-migration sessions BEFORE renaming .migrated dirs
|
|
1458
|
+
// (we need to read the .migrated dir contents to compare).
|
|
1459
|
+
const projectsDir = join(aoBaseDir, "projects");
|
|
1460
|
+
const safeToDeleteProjects = new Set();
|
|
1461
|
+
const restoredProjects = new Set();
|
|
1462
|
+
const migratedProjectIds = new Set(migratedDirs.map((d) => d.projectId));
|
|
1463
|
+
if (existsSync(projectsDir)) {
|
|
1464
|
+
for (const projectId of migratedProjectIds) {
|
|
1465
|
+
const projectDir = join(projectsDir, projectId);
|
|
1466
|
+
if (!existsSync(projectDir))
|
|
1467
|
+
continue;
|
|
1468
|
+
const postMigrationSessions = countPostMigrationSessions(projectDir, migratedDirs.filter((d) => d.projectId === projectId));
|
|
1469
|
+
if (postMigrationSessions > 0) {
|
|
1470
|
+
log(` Warning: projects/${projectId} has ${postMigrationSessions} session(s) created after migration — skipping deletion.`);
|
|
1471
|
+
log(` These sessions exist only in projects/${projectId}/ and would be lost. Remove manually after verifying.`);
|
|
1472
|
+
recordActivityEvent({
|
|
1473
|
+
projectId,
|
|
1474
|
+
source: "migration",
|
|
1475
|
+
kind: "migration.rollback_skipped",
|
|
1476
|
+
level: "warn",
|
|
1477
|
+
summary: `rollback skipped projects/${projectId} — ${postMigrationSessions} post-migration session(s)`,
|
|
1478
|
+
data: {
|
|
1479
|
+
postMigrationSessions,
|
|
1480
|
+
},
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
else {
|
|
1484
|
+
safeToDeleteProjects.add(projectId);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
// Rename .migrated back to original
|
|
1489
|
+
for (const dir of migratedDirs) {
|
|
1490
|
+
const originalPath = dir.path.replace(/\.migrated$/, "");
|
|
1491
|
+
if (existsSync(originalPath)) {
|
|
1492
|
+
log(` Warning: ${basename(originalPath)} already exists — skipping restore of ${basename(dir.path)}. Resolve manually.`);
|
|
1493
|
+
safeToDeleteProjects.delete(dir.projectId);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
log(` Restoring: ${basename(dir.path)} → ${basename(originalPath)}`);
|
|
1497
|
+
if (!dryRun) {
|
|
1498
|
+
renameSync(dir.path, originalPath);
|
|
1499
|
+
}
|
|
1500
|
+
restoredProjects.add(dir.projectId);
|
|
1501
|
+
}
|
|
1502
|
+
// Move worktrees back to restored hash dirs, then remove project directories
|
|
1503
|
+
let rollbackWorktreesMoved = false;
|
|
1504
|
+
// (V2 → V1) pairs collected so we can reverse the Claude session-storage
|
|
1505
|
+
// relink that the forward migration performed.
|
|
1506
|
+
const rollbackWorkspaceMoves = [];
|
|
1507
|
+
if (existsSync(projectsDir)) {
|
|
1508
|
+
for (const projectId of safeToDeleteProjects) {
|
|
1509
|
+
if (!restoredProjects.has(projectId))
|
|
1510
|
+
continue;
|
|
1511
|
+
const projectDir = join(projectsDir, projectId);
|
|
1512
|
+
if (!existsSync(projectDir))
|
|
1513
|
+
continue;
|
|
1514
|
+
// Move worktrees back before deleting the project directory.
|
|
1515
|
+
// If multiple hash dirs existed for this project, consolidate worktrees into the
|
|
1516
|
+
// first restored hash dir. The original hash→worktree mapping is lost after
|
|
1517
|
+
// forward migration (worktrees were merged), so this is best-effort.
|
|
1518
|
+
const v2WorktreesDir = join(projectDir, "worktrees");
|
|
1519
|
+
if (existsSync(v2WorktreesDir)) {
|
|
1520
|
+
const projectMigratedDirs = migratedDirs.filter((d) => d.projectId === projectId);
|
|
1521
|
+
const targetHashDir = projectMigratedDirs[0]
|
|
1522
|
+
? projectMigratedDirs[0].path.replace(/\.migrated$/, "")
|
|
1523
|
+
: null;
|
|
1524
|
+
if (targetHashDir && existsSync(targetHashDir)) {
|
|
1525
|
+
const oldWorktreesDir = join(targetHashDir, "worktrees");
|
|
1526
|
+
if (!dryRun)
|
|
1527
|
+
mkdirSync(oldWorktreesDir, { recursive: true });
|
|
1528
|
+
for (const wt of readdirSync(v2WorktreesDir)) {
|
|
1529
|
+
const src = join(v2WorktreesDir, wt);
|
|
1530
|
+
const dest = join(oldWorktreesDir, wt);
|
|
1531
|
+
if (!existsSync(dest)) {
|
|
1532
|
+
log(` Moving worktree back: projects/${projectId}/worktrees/${wt} → ${basename(targetHashDir)}/worktrees/${wt}`);
|
|
1533
|
+
if (!dryRun)
|
|
1534
|
+
crossDeviceMove(src, dest, log);
|
|
1535
|
+
rollbackWorktreesMoved = true;
|
|
1536
|
+
// For Claude relink-reverse: source dir's encoded path moves
|
|
1537
|
+
// back to the destination's encoded path.
|
|
1538
|
+
rollbackWorkspaceMoves.push({
|
|
1539
|
+
oldWorkspacePath: src,
|
|
1540
|
+
newWorkspacePath: dest,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// Repair git worktree references broken by moving worktrees back
|
|
1548
|
+
if (!dryRun && rollbackWorktreesMoved) {
|
|
1549
|
+
await repairGitWorktrees(aoBaseDir, effectiveConfigPath, log);
|
|
1550
|
+
}
|
|
1551
|
+
// Reverse the Claude session-storage relink so chat history follows
|
|
1552
|
+
// the worktree back to its V1 encoded path.
|
|
1553
|
+
relinkClaudeSessionStorage(rollbackWorkspaceMoves, dryRun, log);
|
|
1554
|
+
// Reverse the Codex session_meta cwd rewrite so Codex restore lookup
|
|
1555
|
+
// continues to find threads after rollback.
|
|
1556
|
+
rewriteCodexSessionStorage(rollbackWorkspaceMoves, dryRun, log);
|
|
1557
|
+
// Remove project directories that are safe to delete
|
|
1558
|
+
for (const projectId of safeToDeleteProjects) {
|
|
1559
|
+
if (!restoredProjects.has(projectId))
|
|
1560
|
+
continue;
|
|
1561
|
+
const projectDir = join(projectsDir, projectId);
|
|
1562
|
+
if (!existsSync(projectDir))
|
|
1563
|
+
continue;
|
|
1564
|
+
log(` Removing migrated project directory: projects/${projectId}`);
|
|
1565
|
+
if (!dryRun) {
|
|
1566
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
// Remove projects/ only if it's now empty
|
|
1570
|
+
if (!dryRun) {
|
|
1571
|
+
try {
|
|
1572
|
+
const remaining = readdirSync(projectsDir);
|
|
1573
|
+
if (remaining.length === 0) {
|
|
1574
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
log(` Note: projects/ retained — contains ${remaining.length} non-migrated project(s).`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
// Ignore
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
// Re-add storageKey to config.
|
|
1586
|
+
if (existsSync(effectiveConfigPath)) {
|
|
1587
|
+
const content = readFileSync(effectiveConfigPath, "utf-8");
|
|
1588
|
+
const parsed = parse(content);
|
|
1589
|
+
if (parsed && typeof parsed === "object") {
|
|
1590
|
+
const projects = parsed["projects"];
|
|
1591
|
+
if (projects && typeof projects === "object") {
|
|
1592
|
+
let restored = 0;
|
|
1593
|
+
for (const dir of migratedDirs) {
|
|
1594
|
+
const entry = projects[dir.projectId];
|
|
1595
|
+
if (entry && typeof entry === "object") {
|
|
1596
|
+
const originalDirName = basename(dir.path).replace(/\.migrated$/, "");
|
|
1597
|
+
entry["storageKey"] = originalDirName;
|
|
1598
|
+
restored++;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (restored > 0) {
|
|
1602
|
+
log(` Restored storageKey for ${restored} project(s) in config.`);
|
|
1603
|
+
if (!dryRun) {
|
|
1604
|
+
writeFileSync(effectiveConfigPath, stringify(parsed, { indent: 2 }));
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
log("\nRollback complete. Old hash-based directories restored.");
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
export { convertKeyValueToJson, detectActiveSessions, inventoryHashDirs, migrateStorage, rollbackStorage };
|
|
1614
|
+
//# sourceMappingURL=storage-v2.js.map
|