@deksden-com/dd-flow-cli 0.5.0 → 0.7.0
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/CHANGELOG.md +12 -0
- package/README.md +10 -3
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +24 -11
- package/dist/cli/run-cli.js +117 -28
- package/dist/domain/flow-contract.js +15 -4
- package/dist/domain/session-coverage.js +88 -0
- package/dist/runtime/context.js +8 -2
- package/dist/schemas/code-stage-report.schema.json +7 -2
- package/dist/schemas/engine-manifest.schema.json +22 -0
- package/dist/schemas/flow-contract.schema.json +15 -13
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
- package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
- package/dist/schemas/run-engine-binding.schema.json +37 -0
- package/dist/schemas/stage-prompt.schema.json +9 -5
- package/dist/schemas/stage-start-response.schema.json +6 -5
- package/dist/services/canon.js +15 -1
- package/dist/services/cleanup.js +77 -0
- package/dist/services/cli-operation-classifier.js +52 -8
- package/dist/services/compatibility-preflight.js +1 -1
- package/dist/services/dashboard.js +2 -2
- package/dist/services/engines.js +408 -30
- package/dist/services/hooks.js +28 -15
- package/dist/services/lanes.js +0 -4
- package/dist/services/merge-queue.js +48 -0
- package/dist/services/merge-worker.js +3 -4
- package/dist/services/migrations.js +307 -44
- package/dist/services/plan-runtime.js +4 -4
- package/dist/services/plans.js +5 -3
- package/dist/services/protocols.js +23 -2
- package/dist/services/run-engine-bindings.js +157 -0
- package/dist/services/run-projection.js +18 -4
- package/dist/services/runs.js +54 -7
- package/dist/services/schema-validation.js +96 -0
- package/dist/services/sessions.js +33 -73
- package/dist/services/stage-lifecycle.js +298 -45
- package/dist/services/status.js +8 -3
- package/dist/storage/database.js +32 -11
- package/package.json +1 -1
package/dist/services/hooks.js
CHANGED
|
@@ -107,15 +107,11 @@ export function getCodexHomeStatus(context, input) {
|
|
|
107
107
|
const configStatus = managedConfigStatus(record);
|
|
108
108
|
const hooksStatus = hookFileStatus(record.hooks_path, project.root);
|
|
109
109
|
const drift = configStatus.drift_status === "none" && hooksStatus.drift_status === "none" ? "none" : "drifted";
|
|
110
|
-
const now = context.now();
|
|
111
|
-
context.db.run(`UPDATE codex_home_profiles
|
|
112
|
-
SET last_checked_at = ?, last_drift_status = ?, updated_at = ?
|
|
113
|
-
WHERE project_id = ? AND profile = ?`, [now, drift, now, project.id, profile]);
|
|
114
110
|
return {
|
|
115
111
|
ok: true,
|
|
116
112
|
installed: fs.existsSync(record.target_home),
|
|
117
113
|
profile,
|
|
118
|
-
home:
|
|
114
|
+
home: { ...record, last_drift_status: drift },
|
|
119
115
|
config: configStatus,
|
|
120
116
|
hooks: hooksStatus,
|
|
121
117
|
shared_entries: sharedEntryStatus(record.source_home, record.target_home)
|
|
@@ -196,7 +192,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
196
192
|
};
|
|
197
193
|
}
|
|
198
194
|
const status = hookFileStatus(location.hooksPath, project.root);
|
|
199
|
-
const recorded = hookInstallation(context, project.id, installationScope(target, location.profile));
|
|
195
|
+
const recorded = hookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath));
|
|
200
196
|
const driftStatus = status.installed ? "none" : recorded?.installed === 1 ? "drifted" : status.drift_status;
|
|
201
197
|
return {
|
|
202
198
|
ok: true,
|
|
@@ -224,7 +220,7 @@ export function installCodexHooks(context, input) {
|
|
|
224
220
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
225
221
|
ensureDir(path.dirname(location.hooksPath));
|
|
226
222
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
227
|
-
recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, true, "none");
|
|
223
|
+
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
228
224
|
appendAudit(context, {
|
|
229
225
|
projectId: project.id,
|
|
230
226
|
eventType: "codex_hooks.installed",
|
|
@@ -256,7 +252,7 @@ export function removeCodexHooks(context, input) {
|
|
|
256
252
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
257
253
|
ensureDir(path.dirname(location.hooksPath));
|
|
258
254
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
259
|
-
recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, false, "not_installed");
|
|
255
|
+
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
260
256
|
appendAudit(context, {
|
|
261
257
|
projectId: project.id,
|
|
262
258
|
eventType: "codex_hooks.removed",
|
|
@@ -277,13 +273,16 @@ export function handleCodexHook(context, input) {
|
|
|
277
273
|
if (!command)
|
|
278
274
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
279
275
|
const flowPayload = flowSessionPayloadFromRegisterCommand(command);
|
|
280
|
-
|
|
276
|
+
const bootstrapStageStart = /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
277
|
+
if (!flowPayload && !bootstrapStageStart)
|
|
281
278
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
282
279
|
const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
|
|
283
280
|
if (!project)
|
|
284
281
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
285
282
|
const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
|
|
286
|
-
const observedSession =
|
|
283
|
+
const observedSession = flowPayload
|
|
284
|
+
? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
|
|
285
|
+
: undefined;
|
|
287
286
|
const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
|
|
288
287
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
289
288
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
@@ -315,9 +314,23 @@ export function handleCodexHook(context, input) {
|
|
|
315
314
|
duplicate: !inserted,
|
|
316
315
|
event_key: eventKey,
|
|
317
316
|
session_id: effectiveSessionId,
|
|
318
|
-
protocol_id: protocolId
|
|
317
|
+
protocol_id: protocolId,
|
|
318
|
+
...(sessionId
|
|
319
|
+
? {
|
|
320
|
+
hookSpecificOutput: {
|
|
321
|
+
hookEventName: "PreToolUse",
|
|
322
|
+
permissionDecision: "allow",
|
|
323
|
+
updatedInput: { command: commandWithSessionId(command, sessionId) }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
: {})
|
|
319
327
|
};
|
|
320
328
|
}
|
|
329
|
+
function commandWithSessionId(command, sessionId) {
|
|
330
|
+
if (/(?:^|\s)--session-id(?:=|\s)/.test(command))
|
|
331
|
+
return command;
|
|
332
|
+
return `${command} --session-id ${JSON.stringify(sessionId)}`;
|
|
333
|
+
}
|
|
321
334
|
export function hookStatusForProject(context, projectId) {
|
|
322
335
|
return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
|
|
323
336
|
FROM hook_installations WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
@@ -460,7 +473,7 @@ function commandHook(command, statusMessage) {
|
|
|
460
473
|
return { type: "command", command, timeout: 5, statusMessage };
|
|
461
474
|
}
|
|
462
475
|
function hookCommand(event) {
|
|
463
|
-
return `dd-flow codex hook handle --event ${event} --json`;
|
|
476
|
+
return `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
|
|
464
477
|
}
|
|
465
478
|
function resolveHookTarget(target) {
|
|
466
479
|
if (!target || target === "isolated") {
|
|
@@ -632,8 +645,8 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
632
645
|
drift_status = excluded.drift_status,
|
|
633
646
|
updated_at = excluded.updated_at`, [projectId, scope, configPath, hashObject(readJsonIfExists(configPath) ?? {}), installed ? 1 : 0, driftStatus, now, now]);
|
|
634
647
|
}
|
|
635
|
-
function installationScope(target, profile) {
|
|
636
|
-
return target === "isolated" ? `isolated:${profile}` :
|
|
648
|
+
function installationScope(target, profile, hooksPath) {
|
|
649
|
+
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
637
650
|
}
|
|
638
651
|
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
639
652
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
@@ -688,7 +701,7 @@ function projectForHook(context, explicitRoot, cwd) {
|
|
|
688
701
|
.sort((left, right) => right.root.length - left.root.length)[0];
|
|
689
702
|
}
|
|
690
703
|
function hookEventKey(payload, eventName, toolName, command) {
|
|
691
|
-
const explicit = stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
704
|
+
const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
692
705
|
if (explicit)
|
|
693
706
|
return explicit;
|
|
694
707
|
return crypto.createHash("sha256").update(JSON.stringify({
|
package/dist/services/lanes.js
CHANGED
|
@@ -10,8 +10,6 @@ const defaultTtlSeconds = 300;
|
|
|
10
10
|
const defaultPollIntervalSeconds = 10;
|
|
11
11
|
export function getLaneStatus(context, input) {
|
|
12
12
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
|
-
expireStaleLocks(context, project.id);
|
|
14
|
-
expireStaleWaiters(context, project.id);
|
|
15
13
|
return {
|
|
16
14
|
ok: true,
|
|
17
15
|
lanes: lanesForProject(context, project.id, input.lane),
|
|
@@ -179,8 +177,6 @@ export async function waitForLaneLock(context, input) {
|
|
|
179
177
|
}
|
|
180
178
|
export function getLaneWaiters(context, input) {
|
|
181
179
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
182
|
-
expireStaleLocks(context, project.id);
|
|
183
|
-
expireStaleWaiters(context, project.id);
|
|
184
180
|
return {
|
|
185
181
|
ok: true,
|
|
186
182
|
lane: input.lane ? normalizeLane(input.lane) : null,
|
|
@@ -73,6 +73,54 @@ export function claimNextMergeJob(context, input) {
|
|
|
73
73
|
claimed: Boolean(claimedProtocolId)
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
|
+
export function claimMergeJob(context, input) {
|
|
77
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
78
|
+
const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
79
|
+
if (stopState.stopped) {
|
|
80
|
+
throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim another merge job", 1, {
|
|
81
|
+
worker_id: input.workerId,
|
|
82
|
+
status: stopState.status,
|
|
83
|
+
reason: stopState.reason
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
requireLaneLockOwner(context, {
|
|
87
|
+
projectRoot: project.root,
|
|
88
|
+
lane: "merge",
|
|
89
|
+
workerId: input.workerId,
|
|
90
|
+
workspacePath: input.workspacePath
|
|
91
|
+
});
|
|
92
|
+
const now = context.now();
|
|
93
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
94
|
+
try {
|
|
95
|
+
const job = queueJobByProtocol(context, project.id, input.protocolId);
|
|
96
|
+
if (!job || !["ready", "requeued"].includes(job.status)) {
|
|
97
|
+
throw new AppError("merge_job_not_claimable", "Requested merge job is not ready to claim", 1, {
|
|
98
|
+
protocol_id: input.protocolId,
|
|
99
|
+
status: job?.status ?? "missing",
|
|
100
|
+
allowed: ["ready", "requeued"]
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const update = context.db.run(`UPDATE merge_queue
|
|
104
|
+
SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
|
|
105
|
+
WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
|
|
106
|
+
if (update.changes !== 1) {
|
|
107
|
+
throw new AppError("merge_job_claim_race", "Requested merge job changed while claiming", 1, { protocol_id: input.protocolId });
|
|
108
|
+
}
|
|
109
|
+
appendAudit(context, {
|
|
110
|
+
protocolId: job.protocol_id,
|
|
111
|
+
projectId: project.id,
|
|
112
|
+
eventType: "merge_queue.claimed",
|
|
113
|
+
payload: { protocol_id: job.protocol_id, worker_id: input.workerId, targeted: true }
|
|
114
|
+
});
|
|
115
|
+
transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
|
|
116
|
+
context.db.exec("COMMIT");
|
|
117
|
+
return mergeQueueResult(queueJobByProtocol(context, project.id, job.protocol_id), { ok: true, outcome: "claimed", claimed: true, targeted: true });
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
context.db.exec("ROLLBACK");
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
76
124
|
export function claimMergeBundle(context, input) {
|
|
77
125
|
const { project, protocolIds } = requireClaimableBranchBundle(context, {
|
|
78
126
|
projectRoot: input.projectRoot,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AppError } from "../shared/errors.js";
|
|
2
2
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
3
3
|
import { appendAudit } from "./audit.js";
|
|
4
|
-
import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock
|
|
4
|
+
import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock } from "./lanes.js";
|
|
5
5
|
import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
|
|
6
6
|
import { requireProjectByRoot } from "./projects.js";
|
|
7
7
|
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
@@ -175,15 +175,14 @@ function withJobGuidance(context, job) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
function detectMergeWorkerState(context, projectId) {
|
|
178
|
-
expireProjectLaneLocks(context, projectId);
|
|
179
178
|
const workers = activeMergeWorkerSessions(context, projectId);
|
|
180
179
|
const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
|
|
181
180
|
const claimed = context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
|
|
182
181
|
WHERE project_id = ? AND status = 'claimed'
|
|
183
182
|
ORDER BY updated_at DESC, id DESC`, [projectId]);
|
|
184
183
|
const lock = context.db.get(`SELECT worker_id, status, expires_at, reason FROM lane_locks
|
|
185
|
-
WHERE project_id = ? AND lane = 'merge' AND status = 'active'
|
|
186
|
-
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId]);
|
|
184
|
+
WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND expires_at > ?
|
|
185
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, context.now()]);
|
|
187
186
|
if (workerIds.length > 1) {
|
|
188
187
|
return { state: "blocked", reason: "multiple_active_merge_workers", workers };
|
|
189
188
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import { AppError } from "../shared/errors.js";
|
|
4
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
5
6
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
6
7
|
import { resolveCanonRoot } from "./canon.js";
|
|
8
|
+
import { getDatabase } from "../storage/database.js";
|
|
9
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
10
|
+
import { classifyCliOperation } from "./cli-operation-classifier.js";
|
|
11
|
+
import { compatibilityContexts, selectEngine } from "./engines.js";
|
|
12
|
+
import { getCliBuildInfo } from "./build-info.js";
|
|
7
13
|
export function assessMigrationImpact(context, input) {
|
|
8
14
|
const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
9
15
|
if (!root.root)
|
|
@@ -78,14 +84,19 @@ export function planMigration(context, input) {
|
|
|
78
84
|
rootSource: rootResolution.root_source
|
|
79
85
|
});
|
|
80
86
|
const sourceVersion = input.sourceVersion ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
81
|
-
const targetVersion = input.targetVersion ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
82
|
-
const
|
|
87
|
+
const targetVersion = input.targetVersion ?? upgradeCanon(context).canon?.version ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
88
|
+
const canon = upgradeCanon(context);
|
|
89
|
+
const impacts = canon.canon ? readImpacts(path.join(canon.canon.memorybank_root, "release-impact")) : [];
|
|
90
|
+
const chain = adjacentMigrationChain(sourceVersion, targetVersion, impacts);
|
|
83
91
|
const activeState = activeStateSummary(context, project.id);
|
|
84
92
|
const backup = backupEvidence(input);
|
|
93
|
+
const engine = migrationEngineEvidence(context, rootResolution.root, targetVersion, input.classification);
|
|
85
94
|
const blockers = [
|
|
86
95
|
...(chain.status === "unsupported" ? [`unsupported migration path: ${chain.reason}`] : []),
|
|
87
|
-
...(backup.status
|
|
88
|
-
...(
|
|
96
|
+
...(backup.status !== "present" ? ["verified backup evidence is required before runtime/home migration"] : []),
|
|
97
|
+
...(engine.target_canon_version !== targetVersion ? ["migration target version does not match the resolved target canon version"] : []),
|
|
98
|
+
...(engine.target_engine_available !== "selected" ? ["target upgrade engine is not selected"] : []),
|
|
99
|
+
...activeStateBlockers(activeState)
|
|
89
100
|
];
|
|
90
101
|
const status = blockers.length > 0 ? "blocked" : chain.steps.length === 0 ? "noop" : "ready";
|
|
91
102
|
return {
|
|
@@ -110,12 +121,7 @@ export function planMigration(context, input) {
|
|
|
110
121
|
chain: chain.steps,
|
|
111
122
|
blocked_reasons: blockers
|
|
112
123
|
},
|
|
113
|
-
engine
|
|
114
|
-
router_version: null,
|
|
115
|
-
selected_engine_version: null,
|
|
116
|
-
source_engine_available: "unknown",
|
|
117
|
-
target_engine_available: "unknown"
|
|
118
|
-
},
|
|
124
|
+
engine,
|
|
119
125
|
backup,
|
|
120
126
|
active_state: activeState,
|
|
121
127
|
primary_data: [
|
|
@@ -141,35 +147,134 @@ export function planMigration(context, input) {
|
|
|
141
147
|
}
|
|
142
148
|
};
|
|
143
149
|
}
|
|
150
|
+
export function applyMigration(context, input) {
|
|
151
|
+
const classification = input.classification ?? classifyCliOperation(["migration", "apply"], context.env);
|
|
152
|
+
if (classification.mode !== "mb_upgrade") {
|
|
153
|
+
throw new AppError("upgrade_authorization_required", "Migration apply requires explicit mb-upgrade authorization", 1, {
|
|
154
|
+
required_marker: "DD_FLOW_COMPATIBILITY_MODE=mb-upgrade"
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const report = input.reportFile
|
|
158
|
+
? readJsonObject(path.resolve(input.reportFile))
|
|
159
|
+
: planMigration(context, input);
|
|
160
|
+
const reportFile = input.reportFile ? path.resolve(input.reportFile) : null;
|
|
161
|
+
const reportValidation = validateMigrationReportObject(report);
|
|
162
|
+
if (reportValidation.errors.length > 0) {
|
|
163
|
+
throw new AppError("migration_report_invalid", "Migration report is not acceptable for apply", 2, { errors: reportValidation.errors, file: reportFile });
|
|
164
|
+
}
|
|
165
|
+
const migration = recordValue(report.migration);
|
|
166
|
+
const backup = recordValue(report.backup);
|
|
167
|
+
const activeState = recordValue(report.active_state);
|
|
168
|
+
const blockers = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
169
|
+
if (blockers.length > 0 || stringValue(migration?.status) === "blocked") {
|
|
170
|
+
throw new AppError("migration_blocked", "Migration apply is blocked by its verified report", 1, { blockers, file: reportFile });
|
|
171
|
+
}
|
|
172
|
+
const backupPath = stringValue(backup?.path);
|
|
173
|
+
const verifiedBackup = backupPath ? verifyBackupFile(backupPath) : null;
|
|
174
|
+
if (!verifiedBackup) {
|
|
175
|
+
throw new AppError("backup_invalid", "Migration apply requires a readable, non-empty backup file", 1, { path: backupPath });
|
|
176
|
+
}
|
|
177
|
+
if (activeState && hasActiveState(activeState)) {
|
|
178
|
+
throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: activeState });
|
|
179
|
+
}
|
|
180
|
+
const sourceVersion = stringValue(migration?.source_memory_bank_version);
|
|
181
|
+
const targetVersion = stringValue(migration?.target_memory_bank_version);
|
|
182
|
+
if (!sourceVersion || !targetVersion)
|
|
183
|
+
throw new AppError("migration_contract_invalid", "Migration source and target versions are required", 1);
|
|
184
|
+
const canon = upgradeCanon(context);
|
|
185
|
+
const impacts = canon.canon ? readImpacts(path.join(canon.canon.memorybank_root, "release-impact")) : [];
|
|
186
|
+
const chain = adjacentMigrationChain(sourceVersion, targetVersion, impacts);
|
|
187
|
+
if (chain.status === "unsupported")
|
|
188
|
+
throw new AppError("migration_chain_invalid", chain.reason ?? "Unsupported migration chain", 1);
|
|
189
|
+
const expectedEngine = migrationEngineEvidence(context, resolveProjectRoot(input.projectRoot), targetVersion, classification);
|
|
190
|
+
const reportEngine = recordValue(report.engine);
|
|
191
|
+
const engineErrors = validateEngineEvidence(reportEngine, expectedEngine, targetVersion);
|
|
192
|
+
if (engineErrors.length > 0) {
|
|
193
|
+
throw new AppError("migration_contract_invalid", "Migration report engine contract does not match the pinned upgrade target", 1, {
|
|
194
|
+
errors: engineErrors,
|
|
195
|
+
expected: expectedEngine,
|
|
196
|
+
actual: reportEngine
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
200
|
+
const writeDb = getDatabase(context.ddFlowHome, "migrate");
|
|
201
|
+
const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
|
|
202
|
+
if (already) {
|
|
203
|
+
return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "verified_noop", changed: false, backup: verifiedBackup, recovery: JSON.parse(already.value_json) };
|
|
204
|
+
}
|
|
205
|
+
const result = {
|
|
206
|
+
status: "applied",
|
|
207
|
+
source_version: sourceVersion,
|
|
208
|
+
target_version: targetVersion,
|
|
209
|
+
contract: {
|
|
210
|
+
flow_kind: "mb-upgrade",
|
|
211
|
+
mode: "mb-upgrade-only",
|
|
212
|
+
adjacent_only: true
|
|
213
|
+
},
|
|
214
|
+
engine: reportEngine,
|
|
215
|
+
applied_at: context.now(),
|
|
216
|
+
backup: verifiedBackup,
|
|
217
|
+
chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
writeDb.exec("BEGIN IMMEDIATE");
|
|
221
|
+
writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
|
|
222
|
+
writeDb.exec("COMMIT");
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
try {
|
|
226
|
+
writeDb.exec("ROLLBACK");
|
|
227
|
+
}
|
|
228
|
+
catch { /* preserve the original failure */ }
|
|
229
|
+
throw new AppError("migration_apply_failed", "Migration apply failed before upgrade RUN creation or canon sync", 1, {
|
|
230
|
+
recovery: {
|
|
231
|
+
status: "required",
|
|
232
|
+
source_version: sourceVersion,
|
|
233
|
+
target_version: targetVersion,
|
|
234
|
+
backup: verifiedBackup,
|
|
235
|
+
rollback_route: "restore the verified backup before retrying mb-upgrade apply",
|
|
236
|
+
next_action: "inspect the failed apply and retry only with the same pinned target contract"
|
|
237
|
+
},
|
|
238
|
+
cause: String(error)
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "applied", changed: true, backup: verifiedBackup, recovery: result };
|
|
242
|
+
}
|
|
243
|
+
export function assertUpgradeRunStorageReady(context, projectRoot) {
|
|
244
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(projectRoot)]);
|
|
245
|
+
if (!project)
|
|
246
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN requires a registered project with explicit migration apply", 1);
|
|
247
|
+
const status = getProjectVersionStatus({ projectRoot: resolveProjectRoot(projectRoot), rootSource: "explicit" });
|
|
248
|
+
const target = upgradeCanon(context).canon?.version;
|
|
249
|
+
const source = status?.memory_bank.version;
|
|
250
|
+
if (!source || !target)
|
|
251
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN requires source and target Memory Bank versions", 1);
|
|
252
|
+
const applied = context.db.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, source, target)]);
|
|
253
|
+
if (!applied)
|
|
254
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN requires explicit migration apply before RUN creation", 1, {
|
|
255
|
+
source_version: source,
|
|
256
|
+
target_version: target,
|
|
257
|
+
next_action: "run migration plan, verify and explicit migration apply first"
|
|
258
|
+
});
|
|
259
|
+
const value = recordValue(parseJsonValue(applied.value_json));
|
|
260
|
+
if (!value || !["applied", "verified_noop"].includes(stringValue(value.status) ?? "")) {
|
|
261
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN has no successful explicit migration apply evidence", 1, {
|
|
262
|
+
source_version: source,
|
|
263
|
+
target_version: target
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
144
267
|
export function verifyMigrationReport(_context, input) {
|
|
145
268
|
const filePath = path.resolve(input.file);
|
|
146
269
|
const report = readJsonObject(filePath);
|
|
270
|
+
const validation = validateMigrationReportObject(report);
|
|
147
271
|
const schemaId = stringValue(report.schema_id);
|
|
148
|
-
if (schemaId !== "dd-flow/mb-upgrade-migration-report@1") {
|
|
149
|
-
throw new AppError("validation", "Migration report schema_id is not dd-flow/mb-upgrade-migration-report@1", 2, {
|
|
150
|
-
file: filePath,
|
|
151
|
-
schema_id: schemaId
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
272
|
const migration = recordValue(report.migration);
|
|
155
273
|
const backup = recordValue(report.backup);
|
|
156
274
|
const chain = Array.isArray(migration?.chain) ? migration.chain : [];
|
|
157
275
|
const backupStatus = stringValue(backup?.status);
|
|
158
276
|
const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
159
|
-
const errors =
|
|
160
|
-
if (!migration)
|
|
161
|
-
errors.push("migration block is required");
|
|
162
|
-
if (!backup)
|
|
163
|
-
errors.push("backup block is required");
|
|
164
|
-
if (backupStatus !== "present" && backupStatus !== "planned")
|
|
165
|
-
errors.push("backup.status must be present or planned");
|
|
166
|
-
if (migration?.adjacent_only !== true)
|
|
167
|
-
errors.push("migration.adjacent_only must be true");
|
|
168
|
-
if (chain.some((step) => !isAdjacentStep(step)))
|
|
169
|
-
errors.push("all migration.chain entries must be adjacent steps with from/to/status");
|
|
170
|
-
if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0) {
|
|
171
|
-
errors.push("ready migration report must not contain blocked reasons");
|
|
172
|
-
}
|
|
277
|
+
const errors = validation.errors;
|
|
173
278
|
if (errors.length > 0) {
|
|
174
279
|
throw new AppError("migration_report_invalid", "Migration report is not acceptable", 2, { file: filePath, errors });
|
|
175
280
|
}
|
|
@@ -180,6 +285,7 @@ export function verifyMigrationReport(_context, input) {
|
|
|
180
285
|
verdict: blockedReasons.length > 0 ? "blocked_report_valid" : "accepted",
|
|
181
286
|
checks: {
|
|
182
287
|
backup: backupStatus,
|
|
288
|
+
backup_integrity: backupStatus === "present" && stringValue(backup?.path) ? verifyBackupFile(stringValue(backup?.path)) : null,
|
|
183
289
|
adjacent_chain_steps: chain.length,
|
|
184
290
|
blocked_reasons: blockedReasons.length
|
|
185
291
|
}
|
|
@@ -191,17 +297,135 @@ function backupEvidence(input) {
|
|
|
191
297
|
status: "missing",
|
|
192
298
|
path: null,
|
|
193
299
|
created_at: null,
|
|
300
|
+
size_bytes: null,
|
|
301
|
+
sha256: null,
|
|
194
302
|
rollback_route: "required before applying runtime/home migration"
|
|
195
303
|
};
|
|
196
304
|
}
|
|
305
|
+
const file = path.resolve(input.backupPath);
|
|
306
|
+
const verified = verifyBackupFile(file);
|
|
197
307
|
return {
|
|
198
|
-
status:
|
|
199
|
-
path:
|
|
200
|
-
created_at: input.backupCreatedAt ?? null,
|
|
308
|
+
status: verified ? "present" : "missing",
|
|
309
|
+
path: file,
|
|
310
|
+
created_at: input.backupCreatedAt ?? (verified ? new Date(fs.statSync(file).birthtimeMs).toISOString() : null),
|
|
311
|
+
size_bytes: verified?.size_bytes ?? null,
|
|
312
|
+
sha256: verified?.sha256 ?? null,
|
|
201
313
|
rollback_route: "restore backup before retrying migration"
|
|
202
314
|
};
|
|
203
315
|
}
|
|
204
|
-
function
|
|
316
|
+
function verifyBackupFile(file) {
|
|
317
|
+
try {
|
|
318
|
+
const stat = fs.statSync(file);
|
|
319
|
+
if (!stat.isFile() || stat.size <= 0)
|
|
320
|
+
return null;
|
|
321
|
+
return {
|
|
322
|
+
path: file,
|
|
323
|
+
size_bytes: stat.size,
|
|
324
|
+
sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
|
|
325
|
+
created_at: stat.birthtime.toISOString()
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function validateMigrationReportObject(report) {
|
|
333
|
+
const migration = recordValue(report.migration);
|
|
334
|
+
const backup = recordValue(report.backup);
|
|
335
|
+
const chain = Array.isArray(migration?.chain) ? migration.chain : [];
|
|
336
|
+
const backupStatus = stringValue(backup?.status);
|
|
337
|
+
const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
338
|
+
const errors = [];
|
|
339
|
+
if (report.schema_id !== "dd-flow/mb-upgrade-migration-report@1")
|
|
340
|
+
errors.push("schema_id must be dd-flow/mb-upgrade-migration-report@1");
|
|
341
|
+
if (!migration)
|
|
342
|
+
errors.push("migration block is required");
|
|
343
|
+
if (!recordValue(report.engine))
|
|
344
|
+
errors.push("engine block is required");
|
|
345
|
+
if (!backup)
|
|
346
|
+
errors.push("backup block is required");
|
|
347
|
+
if (backupStatus !== "present")
|
|
348
|
+
errors.push("backup.status must be present");
|
|
349
|
+
if (backupStatus === "present") {
|
|
350
|
+
const backupPath = stringValue(backup?.path);
|
|
351
|
+
const verified = backupPath ? verifyBackupFile(backupPath) : null;
|
|
352
|
+
if (!verified)
|
|
353
|
+
errors.push("backup file must be readable and non-empty");
|
|
354
|
+
else {
|
|
355
|
+
if (backup?.size_bytes !== verified.size_bytes)
|
|
356
|
+
errors.push("backup.size_bytes does not match the verified backup");
|
|
357
|
+
if (backup?.sha256 !== verified.sha256)
|
|
358
|
+
errors.push("backup.sha256 does not match the verified backup");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (migration?.adjacent_only !== true)
|
|
362
|
+
errors.push("migration.adjacent_only must be true");
|
|
363
|
+
if (chain.some((step) => !isAdjacentStep(step)))
|
|
364
|
+
errors.push("all migration.chain entries must be adjacent steps with from/to/status");
|
|
365
|
+
const engine = recordValue(report.engine);
|
|
366
|
+
if (engine && stringValue(migration?.target_memory_bank_version) !== engine.target_canon_version) {
|
|
367
|
+
errors.push("migration.target_memory_bank_version must match engine.target_canon_version");
|
|
368
|
+
}
|
|
369
|
+
if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0)
|
|
370
|
+
errors.push("ready migration report must not contain blocked reasons");
|
|
371
|
+
return { errors };
|
|
372
|
+
}
|
|
373
|
+
function migrationEngineEvidence(context, projectRoot, targetVersion, classification) {
|
|
374
|
+
const env = context.env;
|
|
375
|
+
const targetClassification = classification ?? classifyCliOperation(["migration", "plan"], env);
|
|
376
|
+
const current = selectEngine(context, { projectRoot, env }, {}, {
|
|
377
|
+
mode: "normal_write", operation: "project.current", family: "project", command: "current", action: null
|
|
378
|
+
});
|
|
379
|
+
const target = selectEngine(context, { projectRoot, env }, { allowCurrentInProcess: true }, targetClassification);
|
|
380
|
+
const contexts = compatibilityContexts(context, { projectRoot, env, classification: targetClassification });
|
|
381
|
+
const targetContext = recordValue(contexts.upgrade_target);
|
|
382
|
+
const canon = upgradeCanon(context).canon;
|
|
383
|
+
const targetCanonVersion = canon?.version ?? null;
|
|
384
|
+
return {
|
|
385
|
+
router_version: getCliBuildInfo().version,
|
|
386
|
+
source_engine_version: current.selected?.package_version ?? null,
|
|
387
|
+
target_engine_version: target.selected?.package_version ?? null,
|
|
388
|
+
selected_engine_version: target.selected?.package_version ?? null,
|
|
389
|
+
source_engine_available: current.status,
|
|
390
|
+
target_engine_available: target.status,
|
|
391
|
+
target_canon_version: targetCanonVersion,
|
|
392
|
+
target_canon_commit: canon?.commit ?? null,
|
|
393
|
+
target_compatibility_sha256: stringValue(targetContext?.canon && recordValue(targetContext.canon)?.compatibility_sha256),
|
|
394
|
+
effective_engine_version: recordValue(contexts.effective_execution)?.engine_version ?? null
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function validateEngineEvidence(actual, expected, targetVersion) {
|
|
398
|
+
const errors = [];
|
|
399
|
+
if (!actual)
|
|
400
|
+
return ["engine block is required"];
|
|
401
|
+
for (const key of ["target_engine_version", "selected_engine_version", "target_canon_version", "target_canon_commit", "target_compatibility_sha256"]) {
|
|
402
|
+
if (actual[key] !== expected[key])
|
|
403
|
+
errors.push(`engine.${key} does not match current target assessment`);
|
|
404
|
+
}
|
|
405
|
+
if (actual.target_canon_version !== targetVersion)
|
|
406
|
+
errors.push("engine.target_canon_version must match migration target version");
|
|
407
|
+
if (actual.target_engine_available !== "selected")
|
|
408
|
+
errors.push("engine.target_engine_available must be selected");
|
|
409
|
+
return errors;
|
|
410
|
+
}
|
|
411
|
+
function parseJsonValue(value) {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(value);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function upgradeCanon(context) {
|
|
420
|
+
return resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
|
|
421
|
+
}
|
|
422
|
+
function hasActiveState(active) {
|
|
423
|
+
return ["protocols", "runs", "merge_queue", "lane_locks", "lane_waiters", "merge_sessions", "sessions"].some((key) => Array.isArray(active[key]) && active[key].length > 0);
|
|
424
|
+
}
|
|
425
|
+
function applyKey(projectId, source, target) {
|
|
426
|
+
return `mb-upgrade.apply.${projectId}.${source}.${target}`;
|
|
427
|
+
}
|
|
428
|
+
function adjacentMigrationChain(source, target, impacts = []) {
|
|
205
429
|
if (source === target)
|
|
206
430
|
return { status: "ok", steps: [] };
|
|
207
431
|
const sourceParts = parseSemver(source);
|
|
@@ -209,8 +433,38 @@ function adjacentMigrationChain(source, target) {
|
|
|
209
433
|
if (!sourceParts || !targetParts) {
|
|
210
434
|
return { status: "unsupported", reason: "source or target version is not semver", steps: [] };
|
|
211
435
|
}
|
|
436
|
+
if (impacts.length > 0) {
|
|
437
|
+
const steps = [];
|
|
438
|
+
const visited = new Set();
|
|
439
|
+
let current = source;
|
|
440
|
+
while (current !== target) {
|
|
441
|
+
if (visited.has(current))
|
|
442
|
+
return { status: "unsupported", reason: "release impact chain contains a cycle", steps: [] };
|
|
443
|
+
visited.add(current);
|
|
444
|
+
const impact = impacts.find((item) => item.from_version === current);
|
|
445
|
+
if (!impact)
|
|
446
|
+
return { status: "unsupported", reason: `missing adjacent release impact from ${current}`, steps: [] };
|
|
447
|
+
steps.push({
|
|
448
|
+
id: `${impact.from_version}-to-${impact.to_version}`,
|
|
449
|
+
from: impact.from_version,
|
|
450
|
+
to: impact.to_version,
|
|
451
|
+
status: "planned",
|
|
452
|
+
minimum_upgrade_mode: impact.minimum_upgrade_mode,
|
|
453
|
+
domains: impact.domains,
|
|
454
|
+
path_migration: impact.path_migration,
|
|
455
|
+
runtime_migration: impact.runtime_migration,
|
|
456
|
+
required_checks: impact.required_checks
|
|
457
|
+
});
|
|
458
|
+
current = impact.to_version;
|
|
459
|
+
const next = parseSemver(current);
|
|
460
|
+
if (!next || compareParts(next, targetParts) > 0) {
|
|
461
|
+
return { status: "unsupported", reason: `release impact chain overshoots target ${target}`, steps: [] };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { status: "ok", steps };
|
|
465
|
+
}
|
|
212
466
|
if (sourceParts.major !== targetParts.major) {
|
|
213
|
-
return { status: "unsupported", reason: "major version migration requires
|
|
467
|
+
return { status: "unsupported", reason: "major version migration requires canonical release-impact units", steps: [] };
|
|
214
468
|
}
|
|
215
469
|
if (targetParts.minor < sourceParts.minor || (targetParts.minor === sourceParts.minor && targetParts.patch < sourceParts.patch)) {
|
|
216
470
|
return { status: "unsupported", reason: "downgrade migrations are not supported", steps: [] };
|
|
@@ -229,24 +483,33 @@ function adjacentMigrationChain(source, target) {
|
|
|
229
483
|
}
|
|
230
484
|
return { status: "ok", steps };
|
|
231
485
|
}
|
|
232
|
-
function activeStateSummary(context,
|
|
486
|
+
function activeStateSummary(context, _projectId) {
|
|
233
487
|
return {
|
|
234
|
-
protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE
|
|
235
|
-
runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE
|
|
236
|
-
merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE
|
|
237
|
-
lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE
|
|
488
|
+
protocols: context.db.all("SELECT id, project_id, status, stage FROM protocols WHERE status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC"),
|
|
489
|
+
runs: context.db.all("SELECT id, project_id, status, verdict FROM flow_runs WHERE status = 'running' ORDER BY updated_at DESC"),
|
|
490
|
+
merge_queue: context.db.all("SELECT protocol_id, project_id, status FROM merge_queue WHERE status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC"),
|
|
491
|
+
lane_locks: context.db.all("SELECT lane, project_id, worker_id, status FROM lane_locks WHERE status = 'active' ORDER BY updated_at DESC"),
|
|
492
|
+
lane_waiters: context.db.all("SELECT id, lane, project_id, worker_id, status FROM lane_waiters WHERE status = 'queued' ORDER BY queued_at ASC, id ASC"),
|
|
493
|
+
merge_sessions: context.db.all("SELECT session_id, project_id, status, current_protocol_id FROM merge_sessions WHERE status IN ('starting', 'active', 'stopping') ORDER BY updated_at DESC"),
|
|
494
|
+
sessions: context.db.all("SELECT session_id, project_id, status FROM flow_sessions WHERE status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping') ORDER BY updated_at DESC")
|
|
238
495
|
};
|
|
239
496
|
}
|
|
240
497
|
function activeStateBlockers(active) {
|
|
241
498
|
const blockers = [];
|
|
242
499
|
if (active.protocols.length > 0)
|
|
243
|
-
blockers.push(`active protocols: ${active.protocols.map((item) => item.id).join(", ")}`);
|
|
500
|
+
blockers.push(`active protocols: ${active.protocols.map((item) => `${item.project_id}:${item.id}`).join(", ")}`);
|
|
244
501
|
if (active.runs.length > 0)
|
|
245
|
-
blockers.push(`active runs: ${active.runs.map((item) => item.id).join(", ")}`);
|
|
502
|
+
blockers.push(`active runs: ${active.runs.map((item) => `${item.project_id}:${item.id}`).join(", ")}`);
|
|
246
503
|
if (active.merge_queue.length > 0)
|
|
247
|
-
blockers.push(`active merge queue items: ${active.merge_queue.map((item) => item.protocol_id).join(", ")}`);
|
|
504
|
+
blockers.push(`active merge queue items: ${active.merge_queue.map((item) => `${item.project_id}:${item.protocol_id}`).join(", ")}`);
|
|
248
505
|
if (active.lane_locks.length > 0)
|
|
249
|
-
blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
506
|
+
blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.project_id}:${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
507
|
+
if (active.lane_waiters.length > 0)
|
|
508
|
+
blockers.push(`queued lane waiters: ${active.lane_waiters.map((item) => `${item.project_id}:${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
509
|
+
if (active.merge_sessions.length > 0)
|
|
510
|
+
blockers.push(`active merge sessions: ${active.merge_sessions.map((item) => `${item.project_id}:${item.session_id}`).join(", ")}`);
|
|
511
|
+
if (active.sessions.length > 0)
|
|
512
|
+
blockers.push(`active sessions: ${active.sessions.map((item) => `${item.project_id}:${item.session_id}`).join(", ")}`);
|
|
250
513
|
return blockers;
|
|
251
514
|
}
|
|
252
515
|
function readJsonObject(filePath) {
|