@yugabytedb/meko-mcp 1.0.3
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/README.md +505 -0
- package/assets/hooks-handlers/lib/capture.js +863 -0
- package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
- package/assets/hooks-handlers/pre-compact.sh +3 -0
- package/assets/hooks-handlers/session-end.sh +27 -0
- package/assets/hooks-handlers/session-start.sh +24 -0
- package/assets/hooks-handlers/test_single_conversation.sh +169 -0
- package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
- package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
- package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
- package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
- package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
- package/bin/create-meko-setup.mjs +499 -0
- package/lib/claude-code.mjs +14 -0
- package/lib/clients/claude-code.mjs +265 -0
- package/lib/clients/claude-desktop.mjs +256 -0
- package/lib/clients/common.mjs +107 -0
- package/lib/clients/cursor.mjs +332 -0
- package/lib/clients/index.mjs +14 -0
- package/lib/config.mjs +369 -0
- package/lib/detect.mjs +125 -0
- package/lib/installer.mjs +255 -0
- package/lib/logger.mjs +97 -0
- package/lib/mcp-transport.mjs +204 -0
- package/lib/migrate/adapters/claude-code.mjs +165 -0
- package/lib/migrate/adapters/cursor.mjs +221 -0
- package/lib/migrate/adapters/memory-md.mjs +230 -0
- package/lib/migrate/claude-parser.mjs +306 -0
- package/lib/migrate/daemon.mjs +67 -0
- package/lib/migrate/id.mjs +83 -0
- package/lib/migrate/lockfile.mjs +121 -0
- package/lib/migrate/logger.mjs +79 -0
- package/lib/migrate/mcp-client.mjs +224 -0
- package/lib/migrate/orchestrator.mjs +443 -0
- package/lib/migrate/progress.mjs +76 -0
- package/lib/migrate/state.mjs +177 -0
- package/lib/migrate.mjs +328 -0
- package/lib/prompt.mjs +212 -0
- package/lib/validate.mjs +259 -0
- package/package.json +49 -0
- package/scripts/bundle-assets.mjs +49 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* progress.mjs — Lightweight progress snapshots for `--migrate-status`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} Progress
|
|
10
|
+
* @property {string} runId
|
|
11
|
+
* @property {string} scope
|
|
12
|
+
* @property {string} startedAt
|
|
13
|
+
* @property {string|null} finishedAt
|
|
14
|
+
* @property {"running"|"done"|"failed"|"interrupted"} status
|
|
15
|
+
* @property {string|null} currentAgent
|
|
16
|
+
* @property {string|null} currentSession
|
|
17
|
+
* @property {Counts} counts
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} Counts
|
|
20
|
+
* @property {number} sessionsPlanned
|
|
21
|
+
* @property {number} sessionsDone
|
|
22
|
+
* @property {number} messagesSent
|
|
23
|
+
* @property {number} memoryPairsSent
|
|
24
|
+
* @property {number} memoriesFromMD
|
|
25
|
+
* @property {number} warnings
|
|
26
|
+
* @property {number} memoryErrors
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @returns {Progress}
|
|
31
|
+
*/
|
|
32
|
+
export function initial(runId, scope) {
|
|
33
|
+
return {
|
|
34
|
+
runId,
|
|
35
|
+
scope,
|
|
36
|
+
startedAt: new Date().toISOString(),
|
|
37
|
+
finishedAt: null,
|
|
38
|
+
status: "running",
|
|
39
|
+
currentAgent: null,
|
|
40
|
+
currentSession: null,
|
|
41
|
+
counts: {
|
|
42
|
+
sessionsPlanned: 0,
|
|
43
|
+
sessionsDone: 0,
|
|
44
|
+
messagesSent: 0,
|
|
45
|
+
memoryPairsSent: 0,
|
|
46
|
+
memoriesFromMD: 0,
|
|
47
|
+
warnings: 0,
|
|
48
|
+
memoryErrors: 0,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Atomic write.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} progressPath
|
|
57
|
+
* @param {Progress} progress
|
|
58
|
+
*/
|
|
59
|
+
export function write(progressPath, progress) {
|
|
60
|
+
fs.mkdirSync(path.dirname(progressPath), { recursive: true });
|
|
61
|
+
const tmp = `${progressPath}.tmp.${process.pid}.${Date.now()}`;
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(progress, null, 2));
|
|
63
|
+
fs.renameSync(tmp, progressPath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} progressPath
|
|
68
|
+
* @returns {Progress | null}
|
|
69
|
+
*/
|
|
70
|
+
export function read(progressPath) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(fs.readFileSync(progressPath, "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state.mjs — Durable cross-run state for the migrator.
|
|
3
|
+
*
|
|
4
|
+
* File layout documented in the feature plan. Writes are atomic
|
|
5
|
+
* (tmp + rename) and debounced by the caller via `flushNow()` / periodic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export const STATE_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} State
|
|
15
|
+
* @property {number} version
|
|
16
|
+
* @property {string} updatedAt
|
|
17
|
+
* @property {Array<object>} runs
|
|
18
|
+
* @property {Record<string, AgentState>} agents
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} AgentState
|
|
21
|
+
* @property {string|null} memoryConversationId
|
|
22
|
+
* @property {Record<string, ConversationState>} conversations
|
|
23
|
+
* @property {Record<string, MemoryState>} memories
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} ConversationState
|
|
26
|
+
* @property {string} conversationId
|
|
27
|
+
* @property {"pending"|"partial"|"done"|"failed"} status
|
|
28
|
+
* @property {number} messagesSent
|
|
29
|
+
* @property {number} memoryPairsSent
|
|
30
|
+
* @property {string|null} lastUuid
|
|
31
|
+
* @property {string|null} completedAt
|
|
32
|
+
* @property {string|null} lastError
|
|
33
|
+
*
|
|
34
|
+
* @typedef {Object} MemoryState
|
|
35
|
+
* @property {string} memoryId
|
|
36
|
+
* @property {string} name
|
|
37
|
+
* @property {string} addedAt
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load state from disk. Returns an empty shell if the file is missing.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} statePath
|
|
44
|
+
* @returns {State}
|
|
45
|
+
*/
|
|
46
|
+
export function load(statePath) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(statePath, "utf8");
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (parsed && typeof parsed === "object") {
|
|
51
|
+
return {
|
|
52
|
+
version: parsed.version ?? STATE_VERSION,
|
|
53
|
+
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
|
54
|
+
runs: Array.isArray(parsed.runs) ? parsed.runs : [],
|
|
55
|
+
agents: parsed.agents && typeof parsed.agents === "object" ? parsed.agents : {},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
/* fall through to empty */
|
|
60
|
+
}
|
|
61
|
+
return empty();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @returns {State}
|
|
66
|
+
*/
|
|
67
|
+
export function empty() {
|
|
68
|
+
return {
|
|
69
|
+
version: STATE_VERSION,
|
|
70
|
+
updatedAt: new Date().toISOString(),
|
|
71
|
+
runs: [],
|
|
72
|
+
agents: {},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Atomic write: writes to `${statePath}.tmp` then renames.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} statePath
|
|
80
|
+
* @param {State} state
|
|
81
|
+
*/
|
|
82
|
+
export function save(statePath, state) {
|
|
83
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
84
|
+
state.updatedAt = new Date().toISOString();
|
|
85
|
+
const tmp = `${statePath}.tmp.${process.pid}.${Date.now()}`;
|
|
86
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
87
|
+
fs.renameSync(tmp, statePath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Ensure an agent bucket exists and return it (mutable reference).
|
|
92
|
+
*
|
|
93
|
+
* @param {State} state
|
|
94
|
+
* @param {string} agentId
|
|
95
|
+
* @returns {AgentState}
|
|
96
|
+
*/
|
|
97
|
+
export function ensureAgent(state, agentId) {
|
|
98
|
+
if (!state.agents[agentId]) {
|
|
99
|
+
state.agents[agentId] = {
|
|
100
|
+
memoryConversationId: null,
|
|
101
|
+
conversations: {},
|
|
102
|
+
memories: {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return state.agents[agentId];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Has this session already been fully ingested?
|
|
110
|
+
*
|
|
111
|
+
* @param {State} state
|
|
112
|
+
* @param {string} agentId
|
|
113
|
+
* @param {string} sessionUuid
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function isSessionDone(state, agentId, sessionUuid) {
|
|
117
|
+
return state.agents[agentId]?.conversations?.[sessionUuid]?.status === "done";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Return the stored conversation record for (agentId, sessionUuid), or null.
|
|
122
|
+
* Callers use this to resume a `partial` ingestion.
|
|
123
|
+
*
|
|
124
|
+
* @param {State} state
|
|
125
|
+
* @param {string} agentId
|
|
126
|
+
* @param {string} sessionUuid
|
|
127
|
+
* @returns {ConversationState | null}
|
|
128
|
+
*/
|
|
129
|
+
export function getSession(state, agentId, sessionUuid) {
|
|
130
|
+
return state.agents[agentId]?.conversations?.[sessionUuid] ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Upsert a session record. Returns the mutated record.
|
|
135
|
+
*
|
|
136
|
+
* @param {State} state
|
|
137
|
+
* @param {string} agentId
|
|
138
|
+
* @param {string} sessionUuid
|
|
139
|
+
* @param {Partial<ConversationState>} patch
|
|
140
|
+
* @returns {ConversationState}
|
|
141
|
+
*/
|
|
142
|
+
export function markSession(state, agentId, sessionUuid, patch) {
|
|
143
|
+
const agent = ensureAgent(state, agentId);
|
|
144
|
+
const prev = agent.conversations[sessionUuid] ?? {
|
|
145
|
+
conversationId: "",
|
|
146
|
+
status: "pending",
|
|
147
|
+
messagesSent: 0,
|
|
148
|
+
memoryPairsSent: 0,
|
|
149
|
+
lastUuid: null,
|
|
150
|
+
completedAt: null,
|
|
151
|
+
lastError: null,
|
|
152
|
+
};
|
|
153
|
+
const next = { ...prev, ...patch };
|
|
154
|
+
agent.conversations[sessionUuid] = next;
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {State} state
|
|
160
|
+
* @param {string} agentId
|
|
161
|
+
* @param {string} hash
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
export function isMemoryDone(state, agentId, hash) {
|
|
165
|
+
return Boolean(state.agents[agentId]?.memories?.[hash]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {State} state
|
|
170
|
+
* @param {string} agentId
|
|
171
|
+
* @param {string} hash
|
|
172
|
+
* @param {MemoryState} record
|
|
173
|
+
*/
|
|
174
|
+
export function markMemory(state, agentId, hash, record) {
|
|
175
|
+
const agent = ensureAgent(state, agentId);
|
|
176
|
+
agent.memories[hash] = record;
|
|
177
|
+
}
|
package/lib/migrate.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate.mjs — CLI-facing entry points for the `migrate` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Three externally-visible actions:
|
|
5
|
+
* - startMigration(...) — foreground wrapper that forks a detached child
|
|
6
|
+
* - readStatus(...) — print latest run's progress
|
|
7
|
+
* - stopMigration(...) — SIGTERM the running child, wait briefly, SIGKILL
|
|
8
|
+
*
|
|
9
|
+
* The detached child re-enters this module via `runChildMigration()`
|
|
10
|
+
* (invoked from bin/create-meko-setup.mjs when --__migrate-child is set).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { runMigration } from "./migrate/orchestrator.mjs";
|
|
16
|
+
import { createLogger } from "./migrate/logger.mjs";
|
|
17
|
+
import { generateRunId, forkDetached, migrateRoot } from "./migrate/daemon.mjs";
|
|
18
|
+
import * as lockfile from "./migrate/lockfile.mjs";
|
|
19
|
+
import * as progress from "./migrate/progress.mjs";
|
|
20
|
+
|
|
21
|
+
function home() {
|
|
22
|
+
return process.env.HOME || "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function paths(opts = {}) {
|
|
26
|
+
const root = migrateRoot({ home: opts.home ?? home() });
|
|
27
|
+
return {
|
|
28
|
+
root,
|
|
29
|
+
statePath: path.join(root, "state.json"),
|
|
30
|
+
lockPath: path.join(root, "lock"),
|
|
31
|
+
runDir: (runId) => path.join(root, runId),
|
|
32
|
+
logFile: (runId) => path.join(root, runId, "stdout.log"),
|
|
33
|
+
progressFile: (runId) => path.join(root, runId, "progress.json"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findLatestRunId(root) {
|
|
38
|
+
try {
|
|
39
|
+
const dirs = fs.readdirSync(root, { withFileTypes: true })
|
|
40
|
+
.filter((d) => d.isDirectory())
|
|
41
|
+
.map((d) => d.name)
|
|
42
|
+
.filter((n) => /^\d{8}-\d{6}-[0-9a-f]{6}$/.test(n))
|
|
43
|
+
.sort();
|
|
44
|
+
return dirs.at(-1) ?? null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Foreground wrapper: acquire lock, fork detached child, print run-id.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {string} opts.url
|
|
55
|
+
* @param {string|null} opts.apiKey
|
|
56
|
+
* @param {"user"|"project"} opts.scope
|
|
57
|
+
* @param {string} opts.projectRoot
|
|
58
|
+
* @param {string} opts.scriptPath Absolute path to create-meko-setup.mjs.
|
|
59
|
+
* @param {boolean} [opts.dryRun]
|
|
60
|
+
* @param {boolean} [opts.noExtract]
|
|
61
|
+
* @param {boolean} [opts.force]
|
|
62
|
+
* @param {number} [opts.rate]
|
|
63
|
+
* @returns {Promise<{ok: boolean, runId?: string, message: string}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function startMigration(opts) {
|
|
66
|
+
const P = paths();
|
|
67
|
+
fs.mkdirSync(P.root, { recursive: true });
|
|
68
|
+
|
|
69
|
+
// Dry-run is a local-only planning pass: no HTTP, no lock, no fork.
|
|
70
|
+
// Don't gate it on URL/apiKey.
|
|
71
|
+
if (opts.dryRun) {
|
|
72
|
+
const runId = generateRunId();
|
|
73
|
+
const runDir = P.runDir(runId);
|
|
74
|
+
const summary = await runMigration({
|
|
75
|
+
url: opts.url ?? "",
|
|
76
|
+
apiKey: opts.apiKey ?? null,
|
|
77
|
+
runId,
|
|
78
|
+
scope: opts.scope,
|
|
79
|
+
projectCwd: opts.projectRoot,
|
|
80
|
+
runDir,
|
|
81
|
+
statePath: P.statePath,
|
|
82
|
+
lockPath: P.lockPath,
|
|
83
|
+
dryRun: true,
|
|
84
|
+
noExtract: opts.noExtract,
|
|
85
|
+
concurrency: opts.rate,
|
|
86
|
+
});
|
|
87
|
+
const s = summary.summary;
|
|
88
|
+
const planned = s.plannedCalls ?? {};
|
|
89
|
+
return {
|
|
90
|
+
ok: summary.ok,
|
|
91
|
+
runId,
|
|
92
|
+
message:
|
|
93
|
+
(opts.url ? "" : "(no --url provided — planning only)\n") +
|
|
94
|
+
`Dry run:\n` +
|
|
95
|
+
` Claude Code sessions: ${s.claudeSessions}\n` +
|
|
96
|
+
` Cursor sessions: ${s.cursorSessions}\n` +
|
|
97
|
+
` Paired user↔asst turns: ${s.pairedTurns}\n` +
|
|
98
|
+
` Memory items (MD): ${s.memoryItems}\n` +
|
|
99
|
+
` Planned MCP calls:\n` +
|
|
100
|
+
` conversation_create: ${planned.conversation_create ?? 0}\n` +
|
|
101
|
+
` conversation_add_message: ${planned.conversation_add_message ?? 0}\n` +
|
|
102
|
+
` memory_add (messages=): ${planned.memory_add_messages ?? 0}\n` +
|
|
103
|
+
` memory_add (text=): ${planned.memory_add_text ?? 0}\n` +
|
|
104
|
+
` total: ${planned.total ?? 0}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Real run — now URL/apiKey are required (dry-run branch already returned).
|
|
109
|
+
if (!opts.url) {
|
|
110
|
+
return { ok: false, message: "--url (or MEKO_MCP_URL) is required for --migrate." };
|
|
111
|
+
}
|
|
112
|
+
if (!opts.apiKey && !isLocalUrl(opts.url)) {
|
|
113
|
+
return { ok: false, message: "--api-key (or MEKO_API_KEY) is required for cloud --migrate." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Reserve the lock slot in the foreground so a concurrent --migrate fails
|
|
117
|
+
// fast. We use a two-step dance: first, O_EXCL-create the lock with the
|
|
118
|
+
// foreground PID (just to claim the slot), then — after spawning the
|
|
119
|
+
// child — rewrite it in place with the child's PID. The orchestrator in
|
|
120
|
+
// the child is told the lock is already held so it doesn't try to
|
|
121
|
+
// re-acquire (which would self-deadlock, since it would see its own PID
|
|
122
|
+
// as a live holder).
|
|
123
|
+
const runId = generateRunId();
|
|
124
|
+
const acq = lockfile.acquire(P.lockPath, { runId }, { force: opts.force });
|
|
125
|
+
if (!acq.acquired) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message:
|
|
129
|
+
`Another migration is running (pid=${acq.holder?.pid}, run-id=${acq.holder?.runId}). ` +
|
|
130
|
+
`Use --migrate-status, --migrate-stop, or --force to override.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fs.mkdirSync(P.runDir(runId), { recursive: true });
|
|
135
|
+
fs.writeFileSync(
|
|
136
|
+
path.join(P.runDir(runId), "cmd.json"),
|
|
137
|
+
JSON.stringify(
|
|
138
|
+
{
|
|
139
|
+
runId,
|
|
140
|
+
scope: opts.scope,
|
|
141
|
+
url: opts.url,
|
|
142
|
+
apiKeyPresent: !!opts.apiKey,
|
|
143
|
+
projectRoot: opts.projectRoot,
|
|
144
|
+
noExtract: !!opts.noExtract,
|
|
145
|
+
force: !!opts.force,
|
|
146
|
+
rate: opts.rate ?? null,
|
|
147
|
+
startedAt: new Date().toISOString(),
|
|
148
|
+
},
|
|
149
|
+
null,
|
|
150
|
+
2,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const passthrough = ["--scope", opts.scope, "--url", opts.url];
|
|
155
|
+
if (opts.noExtract) passthrough.push("--no-extract");
|
|
156
|
+
if (opts.force) passthrough.push("--force");
|
|
157
|
+
if (opts.rate) passthrough.push("--rate", String(opts.rate));
|
|
158
|
+
|
|
159
|
+
const env = { ...process.env };
|
|
160
|
+
if (opts.apiKey) env.MEKO_API_KEY = opts.apiKey;
|
|
161
|
+
|
|
162
|
+
const pid = forkDetached({
|
|
163
|
+
scriptPath: opts.scriptPath,
|
|
164
|
+
runId,
|
|
165
|
+
args: passthrough,
|
|
166
|
+
logFilePath: P.logFile(runId),
|
|
167
|
+
env,
|
|
168
|
+
cwd: opts.projectRoot,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Hand lock ownership to the child. The rewrite is the same file that
|
|
172
|
+
// was O_EXCL-created above, so no race with a competing --migrate.
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(
|
|
175
|
+
P.lockPath,
|
|
176
|
+
JSON.stringify({ pid, runId, startedAt: new Date().toISOString() }),
|
|
177
|
+
);
|
|
178
|
+
} catch { /* best effort */ }
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
runId,
|
|
183
|
+
message:
|
|
184
|
+
`Migration started. run-id=${runId}, pid=${pid}\n` +
|
|
185
|
+
`Logs: ${P.logFile(runId)}\n` +
|
|
186
|
+
`Status: create-meko-setup --migrate-status`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Invoked by the detached child via bin/create-meko-setup.mjs.
|
|
192
|
+
*
|
|
193
|
+
* The foreground wrapper has already taken the lock and written the
|
|
194
|
+
* child's PID into it; we MUST NOT re-acquire (that would self-deadlock
|
|
195
|
+
* because lockfile.isStale(our-own-pid) returns false).
|
|
196
|
+
*
|
|
197
|
+
* @param {object} opts
|
|
198
|
+
* @param {string} opts.runId
|
|
199
|
+
* @param {"user"|"project"} opts.scope
|
|
200
|
+
* @param {string} opts.url
|
|
201
|
+
* @param {string|null} opts.apiKey
|
|
202
|
+
* @param {string} opts.projectRoot
|
|
203
|
+
* @param {boolean} [opts.noExtract]
|
|
204
|
+
* @param {boolean} [opts.force]
|
|
205
|
+
* @param {number} [opts.rate]
|
|
206
|
+
* @returns {Promise<number>} Exit code.
|
|
207
|
+
*/
|
|
208
|
+
export async function runChildMigration(opts) {
|
|
209
|
+
const P = paths();
|
|
210
|
+
const runDir = P.runDir(opts.runId);
|
|
211
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
212
|
+
|
|
213
|
+
const log = createLogger(path.join(runDir, "log.ndjson"), { runId: opts.runId });
|
|
214
|
+
|
|
215
|
+
let shuttingDown = false;
|
|
216
|
+
const onSignal = (sig) => {
|
|
217
|
+
if (shuttingDown) return;
|
|
218
|
+
shuttingDown = true;
|
|
219
|
+
log.warn("interrupted", { signal: sig });
|
|
220
|
+
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
221
|
+
log.close();
|
|
222
|
+
process.exit(130);
|
|
223
|
+
};
|
|
224
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
225
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const result = await runMigration({
|
|
229
|
+
url: opts.url,
|
|
230
|
+
apiKey: opts.apiKey,
|
|
231
|
+
runId: opts.runId,
|
|
232
|
+
scope: opts.scope,
|
|
233
|
+
projectCwd: opts.projectRoot,
|
|
234
|
+
runDir,
|
|
235
|
+
statePath: P.statePath,
|
|
236
|
+
lockPath: P.lockPath,
|
|
237
|
+
noExtract: opts.noExtract,
|
|
238
|
+
force: opts.force,
|
|
239
|
+
lockAlreadyHeld: true,
|
|
240
|
+
concurrency: opts.rate,
|
|
241
|
+
});
|
|
242
|
+
log.info("child_done", result.summary);
|
|
243
|
+
log.close();
|
|
244
|
+
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
245
|
+
return result.ok ? 0 : 1;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log.error("child_crashed", { error: err.message, stack: err.stack });
|
|
248
|
+
log.close();
|
|
249
|
+
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Print and return the status of the latest (or requested) migration run.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} opts
|
|
258
|
+
* @param {string} [opts.runId]
|
|
259
|
+
* @returns {{ok: boolean, runId: string|null, progress: object|null, message: string}}
|
|
260
|
+
*/
|
|
261
|
+
export function readStatus(opts = {}) {
|
|
262
|
+
const P = paths();
|
|
263
|
+
const runId = opts.runId ?? findLatestRunId(P.root);
|
|
264
|
+
if (!runId) {
|
|
265
|
+
return { ok: false, runId: null, progress: null, message: "No migration runs found." };
|
|
266
|
+
}
|
|
267
|
+
const prog = progress.read(P.progressFile(runId));
|
|
268
|
+
if (!prog) {
|
|
269
|
+
return { ok: false, runId, progress: null, message: `No progress file for run-id=${runId}.` };
|
|
270
|
+
}
|
|
271
|
+
const lines = [
|
|
272
|
+
`run-id: ${prog.runId}`,
|
|
273
|
+
`status: ${prog.status}`,
|
|
274
|
+
`scope: ${prog.scope}`,
|
|
275
|
+
`started: ${prog.startedAt}`,
|
|
276
|
+
`finished: ${prog.finishedAt ?? "(in progress)"}`,
|
|
277
|
+
`current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"}`,
|
|
278
|
+
`sessions: ${prog.counts.sessionsDone} / ${prog.counts.sessionsPlanned}`,
|
|
279
|
+
`messages: ${prog.counts.messagesSent}`,
|
|
280
|
+
`memory pairs: ${prog.counts.memoryPairsSent}`,
|
|
281
|
+
`memory (MD): ${prog.counts.memoriesFromMD}`,
|
|
282
|
+
`warnings: ${prog.counts.warnings}`,
|
|
283
|
+
`memory errs: ${prog.counts.memoryErrors}`,
|
|
284
|
+
];
|
|
285
|
+
return { ok: true, runId, progress: prog, message: lines.join("\n") };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* SIGTERM the holder, wait briefly, SIGKILL. Best-effort.
|
|
290
|
+
*
|
|
291
|
+
* @returns {{ok: boolean, message: string}}
|
|
292
|
+
*/
|
|
293
|
+
export async function stopMigration() {
|
|
294
|
+
const P = paths();
|
|
295
|
+
const holder = lockfile.read(P.lockPath);
|
|
296
|
+
if (!holder) return { ok: false, message: "No migration lock found." };
|
|
297
|
+
if (lockfile.isStale(holder)) {
|
|
298
|
+
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
299
|
+
return { ok: true, message: `Stale lock removed (pid=${holder.pid}).` };
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
process.kill(holder.pid, "SIGTERM");
|
|
303
|
+
} catch (err) {
|
|
304
|
+
return { ok: false, message: `Failed to signal pid=${holder.pid}: ${err.message}` };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Wait up to 2s.
|
|
308
|
+
const deadline = Date.now() + 2000;
|
|
309
|
+
while (Date.now() < deadline) {
|
|
310
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
311
|
+
if (lockfile.isStale(lockfile.read(P.lockPath) ?? holder)) {
|
|
312
|
+
return { ok: true, message: `Stopped pid=${holder.pid} (run-id=${holder.runId}).` };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
process.kill(holder.pid, "SIGKILL");
|
|
318
|
+
} catch { /* ignore */ }
|
|
319
|
+
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
message: `Force-killed pid=${holder.pid} (run-id=${holder.runId}).`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isLocalUrl(url) {
|
|
327
|
+
return /localhost|127\.0\.0\.1/.test(url ?? "");
|
|
328
|
+
}
|