@lmzhen/dsh-evolution-merge 0.29.0 → 0.30.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/lib/index.js +1125 -568
- package/lib/types/adapters/channel.js +29 -0
- package/lib/types/adapters/engine-screen.d.ts +1 -1
- package/lib/types/adapters/engine-screen.js +45 -0
- package/lib/types/adapters/library.js +14 -0
- package/lib/types/adapters/model-runner.js +34 -0
- package/lib/types/adapters/select.d.ts +3 -0
- package/lib/types/adapters/select.js +35 -0
- package/lib/types/adapters/undo.js +35 -0
- package/lib/types/approve-command.js +42 -0
- package/lib/types/authoring-flow.js +48 -0
- package/lib/types/channel-port.js +74 -0
- package/lib/types/commands.d.ts +9 -0
- package/lib/types/commands.js +79 -0
- package/lib/types/commit-assembly.js +48 -0
- package/lib/types/commit-execute.js +52 -0
- package/lib/types/commit-plan.js +56 -0
- package/lib/types/commit-receipt.d.ts +2 -0
- package/lib/types/commit-receipt.js +85 -0
- package/lib/types/contracts.d.ts +14 -5
- package/lib/types/contracts.js +57 -0
- package/lib/types/coordinator.d.ts +3 -0
- package/lib/types/coordinator.js +131 -0
- package/lib/types/decision.js +61 -0
- package/lib/types/doctor.js +50 -0
- package/lib/types/domain/claim.d.ts +10 -0
- package/lib/types/domain/claim.js +12 -0
- package/lib/types/domain/commit-journal-store.d.ts +6 -0
- package/lib/types/domain/commit-journal-store.js +22 -0
- package/lib/types/domain/commit-journal.d.ts +16 -0
- package/lib/types/domain/commit-journal.js +25 -0
- package/lib/types/domain/errors.d.ts +10 -0
- package/lib/types/domain/errors.js +7 -0
- package/lib/types/domain/failure-route.d.ts +25 -0
- package/lib/types/domain/failure-route.js +22 -0
- package/lib/types/domain/indexes.d.ts +21 -0
- package/lib/types/domain/indexes.js +15 -0
- package/lib/types/domain/ledger.d.ts +17 -0
- package/lib/types/domain/ledger.js +65 -0
- package/lib/types/domain/markdown.d.ts +2 -0
- package/lib/types/domain/markdown.js +57 -0
- package/lib/types/domain/migrate-v1.d.ts +41 -0
- package/lib/types/domain/migrate-v1.js +23 -0
- package/lib/types/domain/pair-index-store.d.ts +6 -0
- package/lib/types/domain/pair-index-store.js +25 -0
- package/lib/types/domain/pair-index.d.ts +9 -0
- package/lib/types/domain/pair-index.js +28 -0
- package/lib/types/domain/resolve-pair.d.ts +11 -0
- package/lib/types/domain/resolve-pair.js +17 -0
- package/lib/types/domain/row.d.ts +113 -0
- package/lib/types/domain/row.js +111 -0
- package/lib/types/domain/run-record.d.ts +45 -0
- package/lib/types/domain/run-record.js +24 -0
- package/lib/types/domain/state.d.ts +57 -0
- package/lib/types/domain/state.js +65 -0
- package/lib/types/gate-cache.js +36 -0
- package/lib/types/index.js +241 -0
- package/lib/types/ladder.js +162 -0
- package/lib/types/merge-command.js +43 -0
- package/lib/types/merge-routes.d.ts +14 -4
- package/lib/types/merge-routes.js +99 -0
- package/lib/types/merge-tool.js +69 -0
- package/lib/types/new-child-authoring.js +83 -0
- package/lib/types/pair-command.js +18 -0
- package/lib/types/params.js +49 -0
- package/lib/types/pending-actions.js +28 -0
- package/lib/types/prompts.js +60 -0
- package/lib/types/read-commands.js +32 -0
- package/lib/types/report.js +70 -0
- package/lib/types/resume-command.js +57 -0
- package/lib/types/round2-outcome.js +50 -0
- package/lib/types/routes.d.ts +5 -2
- package/lib/types/routes.js +75 -0
- package/lib/types/rules.js +55 -0
- package/lib/types/run-shell.d.ts +7 -0
- package/lib/types/run-shell.js +69 -0
- package/lib/types/runtime.d.ts +2 -0
- package/lib/types/runtime.js +269 -0
- package/lib/types/scan-command.d.ts +1 -0
- package/lib/types/scan-command.js +30 -0
- package/lib/types/scan-ports.js +93 -0
- package/lib/types/scan-run.d.ts +2 -0
- package/lib/types/scan-run.js +186 -0
- package/lib/types/skill-card.js +2 -0
- package/lib/types/skipped.js +24 -0
- package/lib/types/stage-tool-registry.js +32 -0
- package/lib/types/stage-tool.js +31 -0
- package/lib/types/staged-tree.js +75 -0
- package/lib/types/staging-files.js +86 -0
- package/lib/types/staging.d.ts +7 -0
- package/lib/types/staging.js +149 -0
- package/lib/types/state.js +103 -0
- package/lib/types/status.d.ts +2 -1
- package/lib/types/status.js +28 -0
- package/lib/types/supersede.js +14 -0
- package/lib/types/undo-merge.js +23 -0
- package/package.json +4 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { queueOf, stageOf } from "./contracts.js";
|
|
2
|
+
import { countRows } from "./status.js";
|
|
3
|
+
export function toRowView(row) {
|
|
4
|
+
return {
|
|
5
|
+
id: row.id,
|
|
6
|
+
pairKey: row.pairKey,
|
|
7
|
+
a: row.a,
|
|
8
|
+
b: row.b,
|
|
9
|
+
state: row.state,
|
|
10
|
+
queue: queueOf(row.state),
|
|
11
|
+
stage: stageOf(row.state),
|
|
12
|
+
label: row.a + ' + ' + row.b,
|
|
13
|
+
updatedAt: row.updatedAt,
|
|
14
|
+
createdAt: row.createdAt,
|
|
15
|
+
...(row.decision?.target === undefined ? {} : { target: row.decision.target }),
|
|
16
|
+
...(row.decision?.confidence === undefined ? {} : { confidence: row.decision.confidence }),
|
|
17
|
+
...(row.failure === undefined ? {} : {
|
|
18
|
+
failure: { stage: row.failure.stage, code: row.failure.code, message: row.failure.message, retryable: row.failure.retryable },
|
|
19
|
+
}),
|
|
20
|
+
...(row.attempts === undefined ? {} : { attempts: row.attempts }),
|
|
21
|
+
...(row.origin === undefined ? {} : { origin: row.origin }),
|
|
22
|
+
...(row.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: row.stagingExpiresAt }),
|
|
23
|
+
...(row.expired === undefined ? {} : { expired: row.expired }),
|
|
24
|
+
...(row.reason === undefined ? {} : { reason: row.reason }),
|
|
25
|
+
summary: [
|
|
26
|
+
row.a + ' + ' + row.b,
|
|
27
|
+
row.decision?.rationale ?? row.reason ?? '',
|
|
28
|
+
row.failure?.message ?? '',
|
|
29
|
+
],
|
|
30
|
+
actions: row.state === 'pending'
|
|
31
|
+
? ['/evolution merge approve ' + row.id, '/evolution merge reject ' + row.id]
|
|
32
|
+
: row.state === 'skipped'
|
|
33
|
+
? ['/evolution merge skipped --restore ' + row.id]
|
|
34
|
+
: ['/evolution merge resume ' + row.id],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function buildMergeView(rows, commands, latestRun, approval) {
|
|
38
|
+
const counts = countRows(rows);
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 2,
|
|
41
|
+
counts,
|
|
42
|
+
tabs: [
|
|
43
|
+
{ queue: 'verify', count: counts.verify },
|
|
44
|
+
{ queue: 'pending', count: counts.pending },
|
|
45
|
+
{ queue: 'fuse', count: counts.fuse },
|
|
46
|
+
{ queue: 'skipped', count: counts.skipped },
|
|
47
|
+
],
|
|
48
|
+
commands,
|
|
49
|
+
...(latestRun === undefined ? {} : { latestRun }),
|
|
50
|
+
...(approval === undefined ? {} : { approval }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function pageQueue(rows, queue, cursor = 0, limit = 50) {
|
|
54
|
+
const filtered = rows.filter(row => queueOf(row.state) === queue);
|
|
55
|
+
const size = Math.max(1, Math.min(200, limit));
|
|
56
|
+
const start = Math.max(0, cursor);
|
|
57
|
+
const items = filtered.slice(start, start + size).map(toRowView);
|
|
58
|
+
const next = start + size < filtered.length ? String(start + size) : undefined;
|
|
59
|
+
return { queue, items, ...(next === undefined ? {} : { nextCursor: next }) };
|
|
60
|
+
}
|
|
61
|
+
export function toRunFact(run) {
|
|
62
|
+
return {
|
|
63
|
+
id: run.id,
|
|
64
|
+
state: run.state,
|
|
65
|
+
...(run.command === undefined ? {} : { command: run.command }),
|
|
66
|
+
...(run.stage === undefined ? {} : { stage: run.stage }),
|
|
67
|
+
...(run.progress === undefined ? {} : { progress: run.progress }),
|
|
68
|
+
startedAt: run.startedAt,
|
|
69
|
+
...(run.endedAt === undefined ? {} : { endedAt: run.endedAt }),
|
|
70
|
+
...(run.resultRef === undefined ? {} : { resultRef: run.resultRef }),
|
|
71
|
+
...(run.failure === undefined ? {} : { failure: run.failure }),
|
|
72
|
+
...(run.supersededAt === undefined ? {} : { supersededAt: run.supersededAt }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic gate for a staged merge tree. No io, no model.
|
|
3
|
+
*
|
|
4
|
+
* block = commit must not proceed; warn = report only. The path and reference checks reuse
|
|
5
|
+
* the skill-store's own `validateSupportPath` / `supportRefs`, so there is one whitelist.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_SKILL_LIMITS, parseFrontmatter, scanContentThreats, supportRefs, validateFrontmatter, validateSupportPath, } from '@lmzhen/dsh-evolution-core';
|
|
8
|
+
const PLACEHOLDER = /\bTODO\b|\bxxx\b|\{\{[^}]*\}\}/i;
|
|
9
|
+
const DESC_WARN_LENGTH = 60;
|
|
10
|
+
export function validateStagedTree(tree, limits = DEFAULT_SKILL_LIMITS) {
|
|
11
|
+
const findings = [];
|
|
12
|
+
const block = (rule, path, message) => { findings.push({ rule, path, severity: 'block', message }); };
|
|
13
|
+
const warn = (rule, path, message) => { findings.push({ rule, path, severity: 'warn', message }); };
|
|
14
|
+
const parsed = parseFrontmatter(tree.body);
|
|
15
|
+
if (parsed === null)
|
|
16
|
+
block('frontmatter', 'SKILL.md', 'SKILL.md has no valid frontmatter');
|
|
17
|
+
else {
|
|
18
|
+
const invalid = validateFrontmatter(tree.body, tree.target, limits);
|
|
19
|
+
if (invalid !== null)
|
|
20
|
+
block('frontmatter', 'SKILL.md', invalid);
|
|
21
|
+
const description = parsed.frontmatter.description ?? '';
|
|
22
|
+
if (description.length > DESC_WARN_LENGTH)
|
|
23
|
+
warn('desc-length', 'SKILL.md', 'description is longer than ' + String(DESC_WARN_LENGTH) + ' characters');
|
|
24
|
+
}
|
|
25
|
+
if (Buffer.byteLength(tree.body, 'utf8') > limits.maxSkillFileBytes)
|
|
26
|
+
block('bytes', 'SKILL.md', 'SKILL.md exceeds maxSkillFileBytes');
|
|
27
|
+
if (scanContentThreats(tree.body) !== null)
|
|
28
|
+
block('threat', 'SKILL.md', 'SKILL.md tripped the content threat scanner');
|
|
29
|
+
if (PLACEHOLDER.test(tree.body))
|
|
30
|
+
warn('placeholder', 'SKILL.md', 'SKILL.md contains a TODO/xxx/{{...}} placeholder');
|
|
31
|
+
const paths = new Set();
|
|
32
|
+
for (const file of tree.files) {
|
|
33
|
+
const badPath = validateSupportPath(file.path);
|
|
34
|
+
if (badPath !== null) {
|
|
35
|
+
block('path', file.path, badPath);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
paths.add(file.path);
|
|
39
|
+
if (Buffer.byteLength(file.content, 'utf8') > limits.maxSkillFileBytes)
|
|
40
|
+
block('bytes', file.path, 'file exceeds maxSkillFileBytes');
|
|
41
|
+
if (scanContentThreats(file.content) !== null)
|
|
42
|
+
block('threat', file.path, 'file tripped the content threat scanner');
|
|
43
|
+
if (PLACEHOLDER.test(file.content))
|
|
44
|
+
warn('placeholder', file.path, 'file contains a TODO/xxx/{{...}} placeholder');
|
|
45
|
+
}
|
|
46
|
+
for (const ref of supportRefs(tree.body)) {
|
|
47
|
+
if (!paths.has(ref))
|
|
48
|
+
block('refs-pair', ref, 'body references a support file that is not staged');
|
|
49
|
+
}
|
|
50
|
+
return findings;
|
|
51
|
+
}
|
|
52
|
+
export function hasBlockingFinding(findings) {
|
|
53
|
+
return findings.some(finding => finding.severity === 'block');
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=rules.js.map
|
package/lib/types/run-shell.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface MergeRegistryPort {
|
|
|
12
12
|
command?: MergeRunCommand;
|
|
13
13
|
total?: number;
|
|
14
14
|
parentRunId?: string;
|
|
15
|
+
mergeStatus?: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'awaiting-approval' | 'undone' | 'superseded' | 'interrupted' | 'orphan';
|
|
15
16
|
}): Promise<Pick<RunHandle, 'id' | 'signal' | 'startedAt'>>;
|
|
16
17
|
stage(id: string, stage: RunStage, progress?: {
|
|
17
18
|
done: number;
|
|
@@ -21,6 +22,7 @@ export interface MergeRegistryPort {
|
|
|
21
22
|
state: 'succeeded' | 'failed' | 'cancelled';
|
|
22
23
|
resultRef?: string;
|
|
23
24
|
failure?: string;
|
|
25
|
+
mergeStatus?: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'awaiting-approval' | 'undone' | 'superseded' | 'interrupted' | 'orphan';
|
|
24
26
|
}): Promise<void>;
|
|
25
27
|
}
|
|
26
28
|
export interface MergeRunBodyContext {
|
|
@@ -48,6 +50,7 @@ export interface WithMergeRunOptions {
|
|
|
48
50
|
readonly approval: MergeReport['approval'];
|
|
49
51
|
readonly now: () => number;
|
|
50
52
|
readonly total?: number;
|
|
53
|
+
readonly detached?: boolean;
|
|
51
54
|
readonly body: (ctx: MergeRunBodyContext) => Promise<MergeRunReportDraft>;
|
|
52
55
|
}
|
|
53
56
|
export type WithMergeRunResult = {
|
|
@@ -55,6 +58,10 @@ export type WithMergeRunResult = {
|
|
|
55
58
|
readonly runId: string;
|
|
56
59
|
readonly reportPath: string;
|
|
57
60
|
readonly report: MergeReport;
|
|
61
|
+
} | {
|
|
62
|
+
readonly ok: true;
|
|
63
|
+
readonly accepted: true;
|
|
64
|
+
readonly runId: string;
|
|
58
65
|
} | {
|
|
59
66
|
readonly ok: false;
|
|
60
67
|
readonly runId?: string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { writeMergeReport } from "./report.js";
|
|
2
|
+
function emptyDraft(reason) {
|
|
3
|
+
return {
|
|
4
|
+
stopReason: 'failed',
|
|
5
|
+
scope: { candidatePairs: 0, queueBefore: { verify: 0, pending: 0, fuse: 0, skipped: 0 } },
|
|
6
|
+
pairs: [],
|
|
7
|
+
totals: { merged: 0, kept: 0, pending: 0, failed: 0 },
|
|
8
|
+
notes: [reason],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export async function withMergeRun(options) {
|
|
12
|
+
const handle = await options.registry.begin('merge', {
|
|
13
|
+
command: options.command,
|
|
14
|
+
mergeStatus: 'running',
|
|
15
|
+
...(options.total === undefined ? {} : { total: options.total }),
|
|
16
|
+
});
|
|
17
|
+
const startedAt = handle.startedAt;
|
|
18
|
+
const stage = (name, progress) => options.registry.stage(handle.id, name, progress);
|
|
19
|
+
await stage('screening', options.total === undefined ? undefined : { done: 0, total: options.total });
|
|
20
|
+
const finish = async () => {
|
|
21
|
+
let draft;
|
|
22
|
+
try {
|
|
23
|
+
draft = await options.body({ runId: handle.id, startedAt, signal: handle.signal, stage });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
draft = emptyDraft('run body failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
27
|
+
}
|
|
28
|
+
const report = {
|
|
29
|
+
schemaVersion: 2,
|
|
30
|
+
runId: handle.id,
|
|
31
|
+
kind: 'merge',
|
|
32
|
+
command: options.command,
|
|
33
|
+
startedAt,
|
|
34
|
+
endedAt: options.now(),
|
|
35
|
+
stopReason: draft.stopReason,
|
|
36
|
+
approval: options.approval,
|
|
37
|
+
scope: draft.scope,
|
|
38
|
+
pairs: draft.pairs,
|
|
39
|
+
totals: draft.totals,
|
|
40
|
+
notes: draft.notes,
|
|
41
|
+
...(draft.remaining === undefined ? {} : { remaining: draft.remaining }),
|
|
42
|
+
};
|
|
43
|
+
let reportPath;
|
|
44
|
+
try {
|
|
45
|
+
reportPath = await writeMergeReport(options.io, options.home, report);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
await options.registry.settle(handle.id, { state: 'failed', failure: 'report write failed: ' + (error instanceof Error ? error.message : String(error)) });
|
|
49
|
+
return { ok: false, runId: handle.id, message: 'report write failed' };
|
|
50
|
+
}
|
|
51
|
+
const runState = report.stopReason === 'completed'
|
|
52
|
+
? 'succeeded'
|
|
53
|
+
: report.stopReason === 'cancelled'
|
|
54
|
+
? 'cancelled'
|
|
55
|
+
: 'failed';
|
|
56
|
+
const awaiting = report.pairs.some(pair => pair.outcome === 'pending');
|
|
57
|
+
const mergeStatus = awaiting ? 'awaiting-approval' : runState;
|
|
58
|
+
await options.registry.settle(handle.id, { state: runState, resultRef: reportPath, mergeStatus, ...(runState === 'failed' ? { failure: 'merge ' + report.stopReason } : {}) });
|
|
59
|
+
if (report.stopReason === 'completed')
|
|
60
|
+
return { ok: true, runId: handle.id, reportPath, report };
|
|
61
|
+
return { ok: false, runId: handle.id, message: 'merge ' + report.stopReason, reportPath };
|
|
62
|
+
};
|
|
63
|
+
if (options.detached === true) {
|
|
64
|
+
void finish().catch(() => { });
|
|
65
|
+
return { ok: true, accepted: true, runId: handle.id };
|
|
66
|
+
}
|
|
67
|
+
return await finish();
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=run-shell.js.map
|
package/lib/types/runtime.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface MergeRuntimeDeps {
|
|
|
28
28
|
readonly allow: readonly string[];
|
|
29
29
|
readonly cwd?: string;
|
|
30
30
|
readonly timeoutMinutes: number;
|
|
31
|
+
/** Start scan/pair in the background and return the run id immediately. */
|
|
32
|
+
readonly detached?: boolean;
|
|
31
33
|
readonly cards: (signal: AbortSignal) => Promise<readonly SkillCard[]>;
|
|
32
34
|
/** Run-scoped engine screening. */
|
|
33
35
|
readonly screeningForRun: (cards: readonly SkillCard[], runId: string, signal: AbortSignal) => Promise<readonly MergeCandidate[]>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge command runtime: cards -> screening -> channel ports -> scan/approve.
|
|
3
|
+
* Plugin wiring supplies the channel, model and library; this module keeps the orchestration
|
|
4
|
+
* out of apply() and makes the mutating verbs testable with fakes.
|
|
5
|
+
*/
|
|
6
|
+
import { probeText } from '@lmzhen/dsh-evolution-core';
|
|
7
|
+
import { canRetry, pairKey } from "./contracts.js";
|
|
8
|
+
import { skippedPairKeys } from "./skipped.js";
|
|
9
|
+
import { createReadOnlyMergeService } from "./merge-command.js";
|
|
10
|
+
import { handleReadOnlyMergeCommand } from "./read-commands.js";
|
|
11
|
+
import { runScanCommand } from "./scan-command.js";
|
|
12
|
+
import { runApproveCommand } from "./approve-command.js";
|
|
13
|
+
import { runResumeCommand } from "./resume-command.js";
|
|
14
|
+
import { dropRow, loadMergeState, upsertRow } from "./state.js";
|
|
15
|
+
import { transition } from "./coordinator.js";
|
|
16
|
+
import { readCommitReceipt } from "./commit-receipt.js";
|
|
17
|
+
import { undoMergeReceipt } from "./undo-merge.js";
|
|
18
|
+
import { recordPair } from "./domain/pair-index-store.js";
|
|
19
|
+
import { skillLibraryUndoPort } from "./adapters/undo.js";
|
|
20
|
+
import { mergeDoctor } from "./doctor.js";
|
|
21
|
+
import { createChannelScanPorts } from "./scan-ports.js";
|
|
22
|
+
import { createNewChildRound2 } from "./new-child-authoring.js";
|
|
23
|
+
import { skillLibraryPort } from "./adapters/library.js";
|
|
24
|
+
export function createMergeRuntime(deps) {
|
|
25
|
+
const readOnly = createReadOnlyMergeService(deps.io, deps.home);
|
|
26
|
+
const timeout = deps.timeoutMinutes > 0 ? deps.timeoutMinutes : undefined;
|
|
27
|
+
const common = {
|
|
28
|
+
io: deps.io, home: deps.home, registry: deps.registry,
|
|
29
|
+
platformWrite: deps.platformWrite, now: deps.now,
|
|
30
|
+
...(timeout === undefined ? {} : { timeoutMinutes: timeout }),
|
|
31
|
+
};
|
|
32
|
+
const stage = deps.stage;
|
|
33
|
+
const stageHooks = stage === undefined ? {} : {
|
|
34
|
+
onChild: (context) => { stage.bind(context); },
|
|
35
|
+
onChildClosed: (childId) => { stage.unbind(childId); },
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
async execute(command, context) {
|
|
39
|
+
const parent = context?.parent ?? deps.parent;
|
|
40
|
+
const detached = deps.detached !== false;
|
|
41
|
+
if (command.verb === 'pair') {
|
|
42
|
+
const signal = new AbortController().signal;
|
|
43
|
+
const cards = await deps.cards(signal);
|
|
44
|
+
const pair = [{ a: command.a, b: command.b }];
|
|
45
|
+
let forced;
|
|
46
|
+
if (command.force === true) {
|
|
47
|
+
const key = pairKey(command.a, command.b);
|
|
48
|
+
forced = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === key);
|
|
49
|
+
if (forced === undefined || forced.decision === undefined) {
|
|
50
|
+
return { ok: false, text: 'pair --force 需要已有决策,找不到该 pair 的 decision: ' + key };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const approval = command.force === true ? false : deps.params.mergeApproval;
|
|
54
|
+
const result = await runScanCommand({
|
|
55
|
+
...common,
|
|
56
|
+
approval,
|
|
57
|
+
...(detached ? { detached: true } : {}),
|
|
58
|
+
makePorts: (runId) => {
|
|
59
|
+
const ports = createChannelScanPorts({
|
|
60
|
+
io: deps.io, home: deps.home, runId, cards,
|
|
61
|
+
screening: () => Promise.resolve(pair),
|
|
62
|
+
channel: deps.channel, parent, allow: deps.allow,
|
|
63
|
+
...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
|
|
64
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
|
|
65
|
+
approval,
|
|
66
|
+
commitPort: skillLibraryPort(deps.skills), now: deps.now,
|
|
67
|
+
...stageHooks,
|
|
68
|
+
});
|
|
69
|
+
return forced === undefined ? ports : { ...ports, round1: () => Promise.resolve({ kind: 'merge', decision: forced.decision }) };
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!result.ok)
|
|
73
|
+
return { ok: false, text: result.message };
|
|
74
|
+
if ('accepted' in result)
|
|
75
|
+
return { ok: true, text: 'merge pair 已启动:' + result.runId };
|
|
76
|
+
if (!('report' in result))
|
|
77
|
+
return { ok: false, text: 'merge pair started without a report' };
|
|
78
|
+
return { ok: true, text: 'merge pair 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
|
|
79
|
+
}
|
|
80
|
+
if (command.verb === 'scan') {
|
|
81
|
+
const signal = new AbortController().signal;
|
|
82
|
+
const cards = await deps.cards(signal);
|
|
83
|
+
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
84
|
+
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
85
|
+
const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || !canRetry(row)).map(row => row.pairKey));
|
|
86
|
+
const result = await runScanCommand({
|
|
87
|
+
...common,
|
|
88
|
+
approval: deps.params.mergeApproval,
|
|
89
|
+
...(detached ? { detached: true } : {}),
|
|
90
|
+
makePorts: runId => createChannelScanPorts({
|
|
91
|
+
io: deps.io, home: deps.home, runId, cards,
|
|
92
|
+
screening: async (turnSignal) => {
|
|
93
|
+
const raw = await deps.screeningForRun(cards, runId, turnSignal);
|
|
94
|
+
const filtered = raw.filter((candidate) => {
|
|
95
|
+
const key = pairKey(candidate.a, candidate.b);
|
|
96
|
+
return !skipped.has(key) && !held.has(key);
|
|
97
|
+
});
|
|
98
|
+
const candidatePairs = raw.candidatePairs;
|
|
99
|
+
return candidatePairs === undefined ? filtered : Object.assign(filtered, { candidatePairs });
|
|
100
|
+
},
|
|
101
|
+
channel: deps.channel, parent, allow: deps.allow,
|
|
102
|
+
...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
|
|
103
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
|
|
104
|
+
approval: deps.params.mergeApproval,
|
|
105
|
+
commitPort: skillLibraryPort(deps.skills), now: deps.now,
|
|
106
|
+
...stageHooks,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
if (!result.ok)
|
|
110
|
+
return { ok: false, text: result.message };
|
|
111
|
+
if ('accepted' in result)
|
|
112
|
+
return { ok: true, text: 'merge scan 已启动:' + result.runId };
|
|
113
|
+
if (!('report' in result))
|
|
114
|
+
return { ok: false, text: 'merge scan started without a report' };
|
|
115
|
+
return { ok: true, text: 'merge scan 完成:merged ' + String(result.report.totals.merged) + ' / pending ' + String(result.report.totals.pending) + ' / failed ' + String(result.report.totals.failed) };
|
|
116
|
+
}
|
|
117
|
+
if (command.verb === 'reject') {
|
|
118
|
+
const loaded = await loadMergeState(deps.io, deps.home);
|
|
119
|
+
const row = loaded.state.rows.find(item => item.id === command.id);
|
|
120
|
+
if (row === undefined)
|
|
121
|
+
return { ok: false, text: 'merge row not found: ' + command.id };
|
|
122
|
+
const next = transition(row, { type: 'reject', at: new Date(deps.now()).toISOString() });
|
|
123
|
+
if (!next.ok || next.row === null)
|
|
124
|
+
return { ok: false, text: 'merge row is not pending: ' + command.id };
|
|
125
|
+
await upsertRow(deps.io, deps.home, deps.now, next.row);
|
|
126
|
+
return { ok: true, text: '已拒绝合并:' + command.id };
|
|
127
|
+
}
|
|
128
|
+
if (command.verb === 'doctor') {
|
|
129
|
+
const rows = await mergeDoctor(deps.io, deps.home, deps.params.mergeModelsRoot);
|
|
130
|
+
return { ok: true, text: rows.map(row => row.key + ' [' + row.status + '] ' + row.label + ':' + row.detail).join('\n') };
|
|
131
|
+
}
|
|
132
|
+
if (command.verb === 'report' || command.verb === 'runs' || command.verb === 'cancel') {
|
|
133
|
+
const registry = deps.registry;
|
|
134
|
+
if (command.verb === 'runs') {
|
|
135
|
+
const mergeRuns = registry.runs('merge');
|
|
136
|
+
return { ok: true, text: mergeRuns.length === 0 ? 'merge runs: empty' : ['merge runs: ' + String(mergeRuns.length)].concat(mergeRuns.map(run => '- ' + run.id + ' [' + run.state + ']' + (run.resultRef === undefined ? '' : ' ' + run.resultRef))).join('\n') };
|
|
137
|
+
}
|
|
138
|
+
if (command.verb === 'cancel') {
|
|
139
|
+
if (command.id !== undefined) {
|
|
140
|
+
const cancelled = registry.cancel(command.id);
|
|
141
|
+
return { ok: cancelled, text: cancelled ? '已请求取消 ' + command.id : '没有可取消的 run: ' + command.id };
|
|
142
|
+
}
|
|
143
|
+
const count = registry.cancelAll();
|
|
144
|
+
return { ok: true, text: '已请求取消 ' + String(count) + ' 个运行' };
|
|
145
|
+
}
|
|
146
|
+
const run = command.id === undefined ? registry.runs('merge')[0] : registry.find(command.id);
|
|
147
|
+
if (run === undefined)
|
|
148
|
+
return { ok: false, text: 'merge run not found: ' + (command.id ?? '(latest)') };
|
|
149
|
+
if (run.resultRef === undefined)
|
|
150
|
+
return { ok: false, text: 'merge run has no report yet: ' + run.id };
|
|
151
|
+
const report = await probeText(deps.io, run.resultRef);
|
|
152
|
+
if (report.kind === 'unknown')
|
|
153
|
+
return { ok: false, text: 'report is unreadable: ' + run.resultRef + ':' + report.reason };
|
|
154
|
+
return report.kind === 'absent'
|
|
155
|
+
? { ok: false, text: 'report is missing: ' + run.resultRef }
|
|
156
|
+
: { ok: true, text: report.value.slice(0, 12000) };
|
|
157
|
+
}
|
|
158
|
+
if (command.verb === 'discard') {
|
|
159
|
+
const loaded = await loadMergeState(deps.io, deps.home);
|
|
160
|
+
const row = loaded.state.rows.find(item => item.id === command.id);
|
|
161
|
+
if (row === undefined)
|
|
162
|
+
return { ok: false, text: 'merge row not found: ' + command.id };
|
|
163
|
+
if (row.state !== 'pending' && row.state !== 'approved' && row.state !== 'fuse')
|
|
164
|
+
return { ok: false, text: 'merge row is not discardable: ' + command.id };
|
|
165
|
+
await dropRow(deps.io, deps.home, deps.now, row.id);
|
|
166
|
+
return { ok: true, text: '已丢弃合并行:' + command.id };
|
|
167
|
+
}
|
|
168
|
+
if (command.verb === 'undo') {
|
|
169
|
+
const receipt = await readCommitReceipt(deps.io, deps.home, command.run, command.target);
|
|
170
|
+
if (receipt === null)
|
|
171
|
+
return { ok: false, text: 'merge run has no commit receipt: ' + command.run };
|
|
172
|
+
if (receipt.target !== command.target)
|
|
173
|
+
return { ok: false, text: 'receipt target is ' + receipt.target + ', not ' + command.target };
|
|
174
|
+
const result = await undoMergeReceipt(receipt, skillLibraryUndoPort(deps.skills));
|
|
175
|
+
if (result.ok && receipt.pairKey !== undefined) {
|
|
176
|
+
await recordPair(deps.io, deps.home, { pairKey: receipt.pairKey, runId: receipt.parentRunId ?? receipt.runId, result: 'undone', target: receipt.target });
|
|
177
|
+
}
|
|
178
|
+
return { ok: result.ok, text: result.message };
|
|
179
|
+
}
|
|
180
|
+
if (command.verb === 'skipped' && command.restore !== undefined) {
|
|
181
|
+
const loaded = await loadMergeState(deps.io, deps.home);
|
|
182
|
+
const row = loaded.state.rows.find(item => item.id === command.restore);
|
|
183
|
+
if (row === undefined || row.state !== 'skipped')
|
|
184
|
+
return { ok: false, text: 'skipped row not found: ' + command.restore };
|
|
185
|
+
await dropRow(deps.io, deps.home, deps.now, row.id);
|
|
186
|
+
return { ok: true, text: '已恢复参与筛选:' + row.id };
|
|
187
|
+
}
|
|
188
|
+
if (command.verb === 'resume') {
|
|
189
|
+
if (command.clear !== undefined) {
|
|
190
|
+
const cleared = (await loadMergeState(deps.io, deps.home)).state.rows.find(item => item.id === command.clear);
|
|
191
|
+
if (cleared === undefined)
|
|
192
|
+
return { ok: false, text: 'merge row not found: ' + command.clear };
|
|
193
|
+
await dropRow(deps.io, deps.home, deps.now, cleared.id);
|
|
194
|
+
return { ok: true, text: '已清除合并行:' + cleared.id };
|
|
195
|
+
}
|
|
196
|
+
const loaded = await loadMergeState(deps.io, deps.home);
|
|
197
|
+
const row = command.id === undefined
|
|
198
|
+
? loaded.state.rows.find(item => item.state === 'fuse' || item.state === 'verify' || (command.retry === true && item.state === 'approved'))
|
|
199
|
+
: loaded.state.rows.find(item => item.id === command.id);
|
|
200
|
+
if (row === undefined)
|
|
201
|
+
return { ok: false, text: 'merge row not found: ' + (command.id ?? '(auto)') };
|
|
202
|
+
const signal = new AbortController().signal;
|
|
203
|
+
const cards = await deps.cards(signal);
|
|
204
|
+
const pair = () => Promise.resolve([{ a: row.a, b: row.b }]);
|
|
205
|
+
const failed = (stage, code, message) => Promise.resolve({
|
|
206
|
+
kind: 'failed',
|
|
207
|
+
failure: { stage, code, message, retryable: false, retryFrom: 'none' },
|
|
208
|
+
});
|
|
209
|
+
const basePorts = {
|
|
210
|
+
screening: pair,
|
|
211
|
+
round1: () => failed('decide', 'resume-needs-ports', 'verify resume needs makePorts'),
|
|
212
|
+
round2: () => failed('author', 'resume-needs-child', 'fuse resume needs a new child'),
|
|
213
|
+
};
|
|
214
|
+
const result = await runResumeCommand({
|
|
215
|
+
...common, rowId: row.id, ports: basePorts,
|
|
216
|
+
makePorts: runId => createChannelScanPorts({
|
|
217
|
+
io: deps.io, home: deps.home, runId, cards, screening: pair,
|
|
218
|
+
channel: deps.channel, parent, allow: deps.allow,
|
|
219
|
+
...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
|
|
220
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
|
|
221
|
+
approval: deps.params.mergeApproval, commitPort: skillLibraryPort(deps.skills), now: deps.now,
|
|
222
|
+
}),
|
|
223
|
+
round2WithRun: createNewChildRound2({
|
|
224
|
+
io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
|
|
225
|
+
allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
|
|
226
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
|
|
227
|
+
commitPort: skillLibraryPort(deps.skills), now: deps.now,
|
|
228
|
+
...stageHooks,
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
if (!result.ok)
|
|
232
|
+
return { ok: false, text: result.message };
|
|
233
|
+
if ('accepted' in result)
|
|
234
|
+
return { ok: true, text: 'merge resume 已启动:' + result.runId };
|
|
235
|
+
if (!('report' in result))
|
|
236
|
+
return { ok: false, text: 'merge resume started without a report' };
|
|
237
|
+
return { ok: true, text: 'merge resume 完成:merged ' + String(result.report.totals.merged) };
|
|
238
|
+
}
|
|
239
|
+
if (command.verb === 'approve') {
|
|
240
|
+
const signal = new AbortController().signal;
|
|
241
|
+
const cards = await deps.cards(signal);
|
|
242
|
+
const result = await runApproveCommand({
|
|
243
|
+
...common,
|
|
244
|
+
rowId: command.id,
|
|
245
|
+
ports: {
|
|
246
|
+
round2: createNewChildRound2({
|
|
247
|
+
io: deps.io, home: deps.home, cards, channel: deps.channel, parent,
|
|
248
|
+
allow: deps.allow, ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
|
|
249
|
+
timeoutMs: deps.params.mergePairTimeoutMinutes * 60_000,
|
|
250
|
+
commitPort: skillLibraryPort(deps.skills), now: deps.now,
|
|
251
|
+
}),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
if (!result.ok)
|
|
255
|
+
return { ok: false, text: result.message };
|
|
256
|
+
if ('accepted' in result)
|
|
257
|
+
return { ok: true, text: 'merge approve 已启动:' + result.runId };
|
|
258
|
+
if (!('report' in result))
|
|
259
|
+
return { ok: false, text: 'merge approve started without a report' };
|
|
260
|
+
return { ok: true, text: 'merge approve 完成:merged ' + String(result.report.totals.merged) };
|
|
261
|
+
}
|
|
262
|
+
const text = await handleReadOnlyMergeCommand(command, deps.io, deps.home);
|
|
263
|
+
if (text !== null)
|
|
264
|
+
return { ok: true, text };
|
|
265
|
+
return readOnly.execute(command);
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -16,6 +16,7 @@ export interface RunScanCommandOptions {
|
|
|
16
16
|
readonly platformWrite: boolean;
|
|
17
17
|
readonly timeoutMinutes?: number;
|
|
18
18
|
readonly now: () => number;
|
|
19
|
+
readonly detached?: boolean;
|
|
19
20
|
}
|
|
20
21
|
export declare function runScanCommand(options: RunScanCommandOptions): Promise<WithMergeRunResult>;
|
|
21
22
|
//# sourceMappingURL=scan-command.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { runScan } from "./scan-run.js";
|
|
2
|
+
import { withMergeRun } from "./run-shell.js";
|
|
3
|
+
export async function runScanCommand(options) {
|
|
4
|
+
return await withMergeRun({
|
|
5
|
+
io: options.io,
|
|
6
|
+
home: options.home,
|
|
7
|
+
registry: options.registry,
|
|
8
|
+
command: 'scan',
|
|
9
|
+
approval: { platformWrite: options.platformWrite, mergeApproval: options.approval },
|
|
10
|
+
now: options.now,
|
|
11
|
+
...(options.detached === undefined ? {} : { detached: options.detached }),
|
|
12
|
+
async body(ctx) {
|
|
13
|
+
const ports = options.makePorts === undefined ? options.ports : options.makePorts(ctx.runId);
|
|
14
|
+
if (ports === undefined)
|
|
15
|
+
throw new Error('scan requires ports or makePorts');
|
|
16
|
+
return await runScan({
|
|
17
|
+
io: options.io,
|
|
18
|
+
home: options.home,
|
|
19
|
+
now: options.now,
|
|
20
|
+
signal: ctx.signal,
|
|
21
|
+
approval: options.approval,
|
|
22
|
+
ports,
|
|
23
|
+
stage: ctx.stage,
|
|
24
|
+
startedAt: ctx.startedAt,
|
|
25
|
+
...(options.timeoutMinutes === undefined ? {} : { timeoutMinutes: options.timeoutMinutes }),
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=scan-command.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { pairKey } from "./contracts.js";
|
|
2
|
+
import { runRound1OverChannel } from "./channel-port.js";
|
|
3
|
+
import { runAuthoring } from "./authoring-flow.js";
|
|
4
|
+
import { dropStaging, ensureStaging, stageKeyOf, stagingRoot } from "./staging.js";
|
|
5
|
+
export function createChannelScanPorts(options) {
|
|
6
|
+
const sessions = new Map();
|
|
7
|
+
return {
|
|
8
|
+
screening: options.screening,
|
|
9
|
+
async round1(candidate, signal) {
|
|
10
|
+
const stageKey = stageKeyOf(options.runId, pairKey(candidate.a, candidate.b));
|
|
11
|
+
const staged = await ensureStaging(options.io, options.home, {
|
|
12
|
+
runId: stageKey,
|
|
13
|
+
target: candidate.a + '-' + candidate.b,
|
|
14
|
+
sources: options.cards
|
|
15
|
+
.filter(card => card.name === candidate.a || card.name === candidate.b)
|
|
16
|
+
.map(card => ({ name: card.name, body: card.body, files: card.files ?? [] })),
|
|
17
|
+
promptVersion: 'merge-b/v1',
|
|
18
|
+
ttlDays: 7,
|
|
19
|
+
now: options.now,
|
|
20
|
+
});
|
|
21
|
+
if (!staged.ok)
|
|
22
|
+
return { kind: 'failed', failure: { stage: 'decide', code: 'staging-create', message: staged.message, retryable: true, retryFrom: 'agent1' } };
|
|
23
|
+
const result = await runRound1OverChannel({
|
|
24
|
+
channel: options.channel,
|
|
25
|
+
parent: options.parent,
|
|
26
|
+
cards: options.cards,
|
|
27
|
+
allow: options.allow,
|
|
28
|
+
cwd: stagingRoot(options.home, stageKey),
|
|
29
|
+
timeoutMs: options.timeoutMs,
|
|
30
|
+
onChildOpened: childId => options.onChild?.({ runId: stageKey, childId, expectedChildId: childId, phase: 'decide' }),
|
|
31
|
+
}, candidate, signal);
|
|
32
|
+
if (result.outcome.kind !== 'merge') {
|
|
33
|
+
await dropStaging(options.io, options.home, stageKey);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (options.approval)
|
|
37
|
+
await result.dispose();
|
|
38
|
+
else {
|
|
39
|
+
sessions.set(pairKey(candidate.a, candidate.b), { result, stageKey });
|
|
40
|
+
if (result.childId !== undefined) {
|
|
41
|
+
options.onChild?.({ runId: stageKey, childId: result.childId, expectedChildId: result.childId, phase: 'author' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result.outcome.kind === 'merge'
|
|
46
|
+
? { ...result.outcome, stagingRef: stageKey, stagingExpiresAt: options.now() + 7 * 86_400_000 }
|
|
47
|
+
: result.outcome;
|
|
48
|
+
},
|
|
49
|
+
async round2(row, decision, signal) {
|
|
50
|
+
const key = pairKey(row.a, row.b);
|
|
51
|
+
const entry = sessions.get(key);
|
|
52
|
+
if (entry === undefined) {
|
|
53
|
+
return { kind: 'failed', failure: { stage: 'author', code: 'no-session', message: 'no same-child round2 session; approve must open a new child', retryable: false, retryFrom: 'none' } };
|
|
54
|
+
}
|
|
55
|
+
sessions.delete(key);
|
|
56
|
+
try {
|
|
57
|
+
const authored = await runAuthoring({
|
|
58
|
+
io: options.io,
|
|
59
|
+
home: options.home,
|
|
60
|
+
runId: options.runId,
|
|
61
|
+
stageKey: entry.stageKey,
|
|
62
|
+
reuseStaging: true,
|
|
63
|
+
target: decision.target,
|
|
64
|
+
mode: decision.mode,
|
|
65
|
+
cards: options.cards,
|
|
66
|
+
commitPort: options.commitPort,
|
|
67
|
+
now: options.now,
|
|
68
|
+
summary: 'merge ' + row.a + ' + ' + row.b,
|
|
69
|
+
author: async () => {
|
|
70
|
+
await entry.result.author?.(row, decision, signal);
|
|
71
|
+
return 'authored';
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
if (!authored.ok) {
|
|
75
|
+
return { kind: 'failed', failure: { stage: 'author', code: 'authoring-failed', message: authored.message, retryable: true, retryFrom: 'agent2' } };
|
|
76
|
+
}
|
|
77
|
+
const receipt = authored.commit.receipt;
|
|
78
|
+
return {
|
|
79
|
+
kind: 'merged',
|
|
80
|
+
...(receipt?.unitId === undefined ? {} : { unitId: receipt.unitId }),
|
|
81
|
+
archived: receipt?.archived ?? [],
|
|
82
|
+
target: { requested: decision.target, actual: authored.commit.plan.target, created: receipt?.create ?? false },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await entry.result.dispose();
|
|
87
|
+
if (entry.result.childId !== undefined)
|
|
88
|
+
options.onChildClosed?.(entry.result.childId);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=scan-ports.js.map
|
package/lib/types/scan-run.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ScanRound1Outcome {
|
|
|
11
11
|
readonly failure?: MergeFailure;
|
|
12
12
|
readonly stagingRef?: string;
|
|
13
13
|
readonly stagingExpiresAt?: number;
|
|
14
|
+
readonly sourceRevision?: string;
|
|
15
|
+
readonly targetRevision?: string;
|
|
14
16
|
}
|
|
15
17
|
export interface ScanRound2Outcome {
|
|
16
18
|
readonly kind: 'merged' | 'failed';
|