@zergai/cyberdeck 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMMERCIAL-LICENSING.md +27 -0
- package/LICENSE.md +375 -0
- package/NOTICE +17 -0
- package/README.md +284 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/dist/automation.js +161 -0
- package/dist/client.js +493 -0
- package/dist/commands/auth.js +193 -0
- package/dist/commands/automation.js +164 -0
- package/dist/commands/decks.js +310 -0
- package/dist/commands/fleet.js +393 -0
- package/dist/commands/oracle.js +143 -0
- package/dist/commands/portfolio.js +19 -0
- package/dist/commands/scenarios.js +207 -0
- package/dist/commands/sdk.js +229 -0
- package/dist/commands/workbench.js +225 -0
- package/dist/config.js +48 -0
- package/dist/fleet.js +88 -0
- package/dist/index.js +43 -0
- package/dist/portfolio.js +148 -0
- package/dist/runtime.js +165 -0
- package/dist/vendors.js +65 -0
- package/package.json +51 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { DeckClient, } from '../client.js';
|
|
4
|
+
const DEFINITION_KINDS = new Set([
|
|
5
|
+
'scenario',
|
|
6
|
+
'drill-template',
|
|
7
|
+
'company-template',
|
|
8
|
+
]);
|
|
9
|
+
function definitionKind(value) {
|
|
10
|
+
if (!DEFINITION_KINDS.has(value)) {
|
|
11
|
+
throw new Error('--kind must be scenario, drill-template, or company-template.');
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function positiveVersion(value) {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
18
|
+
throw new Error('--expected-version must be a positive integer.');
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
function output(value, json) {
|
|
23
|
+
if (json) {
|
|
24
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const definition = value;
|
|
28
|
+
console.log(`${definition.id} ${definition.kind} v${definition.draft.version} ${definition.name}`);
|
|
29
|
+
}
|
|
30
|
+
function outputList(envelope, json) {
|
|
31
|
+
if (json) {
|
|
32
|
+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const definition of envelope.definitions)
|
|
36
|
+
output(definition, false);
|
|
37
|
+
}
|
|
38
|
+
async function scenarioYaml(path) {
|
|
39
|
+
const yaml = await readFile(resolve(path), 'utf8');
|
|
40
|
+
if (!yaml.trim())
|
|
41
|
+
throw new Error('Scenario YAML file is empty.');
|
|
42
|
+
return yaml;
|
|
43
|
+
}
|
|
44
|
+
export function registerScenarioCommands(program) {
|
|
45
|
+
const scenario = program
|
|
46
|
+
.command('scenario')
|
|
47
|
+
.description('Create, read, version, publish, and manage workspace scenarios and templates');
|
|
48
|
+
scenario.command('run')
|
|
49
|
+
.description('Launch a catalog drill or a published scenario; simulation-only unless evidence is requested')
|
|
50
|
+
.requiredOption('--deck <id>', 'target deck id')
|
|
51
|
+
.requiredOption('--scenario <id>', 'catalog id or published scenario definition id')
|
|
52
|
+
.option('--capture-evidence', 'capture dataset and sealed evaluator archives (requires target reset)')
|
|
53
|
+
.option('--reset-targets', 'authorize clearing existing data on all running scenario targets before this run')
|
|
54
|
+
.option('--seed <seed>', 'deterministic simulation seed')
|
|
55
|
+
.option('--tick-delay <ms>', 'real-time delay per simulation tick, 0–5000 ms', '200')
|
|
56
|
+
.option('--json', 'emit stable JSON with the exact run URL')
|
|
57
|
+
.action(async (options) => {
|
|
58
|
+
const tickDelayMs = Number(options.tickDelay);
|
|
59
|
+
if (!Number.isSafeInteger(tickDelayMs) || tickDelayMs < 0 || tickDelayMs > 5000) {
|
|
60
|
+
throw new Error('--tick-delay must be an integer between 0 and 5000.');
|
|
61
|
+
}
|
|
62
|
+
if (options.captureEvidence && !options.resetTargets) {
|
|
63
|
+
throw new Error('Evidence capture clears existing target data. Pass --reset-targets to explicitly authorize it.');
|
|
64
|
+
}
|
|
65
|
+
const client = DeckClient.fromConfig();
|
|
66
|
+
const run = await client.launchScenarioRun(options.deck, {
|
|
67
|
+
scenarioId: options.scenario,
|
|
68
|
+
artifactMode: options.captureEvidence ? 'split' : 'none',
|
|
69
|
+
resetFirst: options.resetTargets === true,
|
|
70
|
+
tickDelayMs,
|
|
71
|
+
...(options.seed !== undefined ? { seed: options.seed } : {}),
|
|
72
|
+
});
|
|
73
|
+
const url = `${client.baseUrl}/app/decks/${encodeURIComponent(options.deck)}/runs/${encodeURIComponent(run.runId)}`;
|
|
74
|
+
if (options.json)
|
|
75
|
+
process.stdout.write(`${JSON.stringify({ ...run, url }, null, 2)}\n`);
|
|
76
|
+
else
|
|
77
|
+
console.log(`${run.runId} ${run.status} ${run.artifactMode === 'split' ? 'evidence requested; not yet ready' : 'simulation only'}\n${url}`);
|
|
78
|
+
});
|
|
79
|
+
scenario.command('runs')
|
|
80
|
+
.description('List recent runs and their execution/evidence status')
|
|
81
|
+
.requiredOption('--deck <id>', 'target deck id')
|
|
82
|
+
.option('--json', 'emit the complete API response including artifact status')
|
|
83
|
+
.action(async (options) => {
|
|
84
|
+
const result = await DeckClient.fromConfig().listScenarioRuns(options.deck);
|
|
85
|
+
if (options.json)
|
|
86
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
87
|
+
else
|
|
88
|
+
for (const run of result.runs)
|
|
89
|
+
console.log(`${run.id} ${run.status} ${run.scenario_name}`);
|
|
90
|
+
});
|
|
91
|
+
scenario.command('run-show')
|
|
92
|
+
.description('Read one exact run, replay metadata, and authorized evidence availability')
|
|
93
|
+
.requiredOption('--deck <id>', 'target deck id')
|
|
94
|
+
.requiredOption('--run <id>', 'run id returned by scenario run')
|
|
95
|
+
.option('--json', 'emit stable JSON')
|
|
96
|
+
.action(async (options) => {
|
|
97
|
+
const result = await DeckClient.fromConfig().getScenarioRun(options.deck, options.run);
|
|
98
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
99
|
+
});
|
|
100
|
+
scenario.command('run-control')
|
|
101
|
+
.description('Request pause, resume, or stop for one exact active run; does not imply runner acknowledgment')
|
|
102
|
+
.requiredOption('--deck <id>', 'target deck id')
|
|
103
|
+
.requiredOption('--run <id>', 'run id')
|
|
104
|
+
.requiredOption('--action <action>', 'pause, resume, or stop')
|
|
105
|
+
.option('--json', 'emit stable JSON')
|
|
106
|
+
.action(async (options) => {
|
|
107
|
+
if (!['pause', 'resume', 'stop'].includes(options.action))
|
|
108
|
+
throw new Error('--action must be pause, resume, or stop.');
|
|
109
|
+
const result = await DeckClient.fromConfig().controlScenarioRun(options.deck, options.run, options.action);
|
|
110
|
+
if (options.json)
|
|
111
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
112
|
+
else
|
|
113
|
+
console.log(`${options.action} requested for ${options.run}. Use scenario run-show to observe reconciliation.`);
|
|
114
|
+
});
|
|
115
|
+
scenario
|
|
116
|
+
.command('list')
|
|
117
|
+
.description('List one workspace scenario or template library')
|
|
118
|
+
.option('--kind <kind>', 'scenario, drill-template, or company-template', 'scenario')
|
|
119
|
+
.option('--include-deleted', 'include soft-deleted definitions (admin only)')
|
|
120
|
+
.option('--json', 'emit stable JSON')
|
|
121
|
+
.action(async (options) => {
|
|
122
|
+
outputList(await DeckClient.fromConfig().listScenarioDefinitions(definitionKind(options.kind), options.includeDeleted === true), options.json);
|
|
123
|
+
});
|
|
124
|
+
scenario
|
|
125
|
+
.command('show')
|
|
126
|
+
.description('Read a scenario or template, including its current YAML draft')
|
|
127
|
+
.requiredOption('--id <id>', 'definition id')
|
|
128
|
+
.option('--include-deleted', 'read a soft-deleted definition (admin only)')
|
|
129
|
+
.option('--json', 'emit stable JSON')
|
|
130
|
+
.action(async (options) => {
|
|
131
|
+
output(await DeckClient.fromConfig().getScenarioDefinition(options.id, options.includeDeleted === true), options.json);
|
|
132
|
+
});
|
|
133
|
+
scenario
|
|
134
|
+
.command('create')
|
|
135
|
+
.description('Create a versioned scenario or reusable template from YAML')
|
|
136
|
+
.requiredOption('--file <path>', 'scenario YAML file')
|
|
137
|
+
.option('--kind <kind>', 'scenario, drill-template, or company-template', 'scenario')
|
|
138
|
+
.option('--name <name>', 'display name (defaults to YAML name)')
|
|
139
|
+
.option('--description <description>', 'optional description')
|
|
140
|
+
.option('--json', 'emit stable JSON')
|
|
141
|
+
.action(async (options) => {
|
|
142
|
+
output(await DeckClient.fromConfig().createScenarioDefinition({
|
|
143
|
+
kind: definitionKind(options.kind),
|
|
144
|
+
yaml: await scenarioYaml(options.file),
|
|
145
|
+
...(options.name ? { name: options.name } : {}),
|
|
146
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
147
|
+
}), options.json);
|
|
148
|
+
});
|
|
149
|
+
scenario
|
|
150
|
+
.command('update')
|
|
151
|
+
.description('Optimistically update a scenario or template draft')
|
|
152
|
+
.requiredOption('--id <id>', 'definition id')
|
|
153
|
+
.requiredOption('--expected-version <version>', 'current draft version')
|
|
154
|
+
.option('--file <path>', 'replacement scenario YAML file')
|
|
155
|
+
.option('--name <name>', 'replacement display name')
|
|
156
|
+
.option('--description <description>', 'replacement description')
|
|
157
|
+
.option('--json', 'emit stable JSON')
|
|
158
|
+
.action(async (options) => {
|
|
159
|
+
if (!options.file && options.name === undefined && options.description === undefined) {
|
|
160
|
+
throw new Error('Pass --file, --name, or --description to update the draft.');
|
|
161
|
+
}
|
|
162
|
+
output(await DeckClient.fromConfig().updateScenarioDefinition(options.id, {
|
|
163
|
+
expectedVersion: positiveVersion(options.expectedVersion),
|
|
164
|
+
...(options.file ? { yaml: await scenarioYaml(options.file) } : {}),
|
|
165
|
+
...(options.name !== undefined ? { name: options.name } : {}),
|
|
166
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
167
|
+
}), options.json);
|
|
168
|
+
});
|
|
169
|
+
scenario
|
|
170
|
+
.command('publish')
|
|
171
|
+
.description('Publish the current draft as an immutable runnable revision')
|
|
172
|
+
.requiredOption('--id <id>', 'definition id')
|
|
173
|
+
.requiredOption('--expected-version <version>', 'current draft version')
|
|
174
|
+
.option('--json', 'emit stable JSON')
|
|
175
|
+
.action(async (options) => {
|
|
176
|
+
const result = await DeckClient.fromConfig().publishScenarioDefinition(options.id, positiveVersion(options.expectedVersion));
|
|
177
|
+
if (options.json)
|
|
178
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
179
|
+
else
|
|
180
|
+
output(result.definition, false);
|
|
181
|
+
});
|
|
182
|
+
scenario
|
|
183
|
+
.command('delete')
|
|
184
|
+
.description('Soft-delete a scenario or template (admin only)')
|
|
185
|
+
.requiredOption('--id <id>', 'definition id')
|
|
186
|
+
.option('--yes', 'confirm deletion')
|
|
187
|
+
.option('--json', 'emit stable JSON')
|
|
188
|
+
.action(async (options) => {
|
|
189
|
+
if (!options.yes)
|
|
190
|
+
throw new Error('Pass --yes to confirm scenario deletion.');
|
|
191
|
+
const result = await DeckClient.fromConfig().deleteScenarioDefinition(options.id);
|
|
192
|
+
if (options.json)
|
|
193
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
194
|
+
else
|
|
195
|
+
console.log(`Deleted ${result.definition.id} ${result.definition.name}`);
|
|
196
|
+
});
|
|
197
|
+
scenario
|
|
198
|
+
.command('restore')
|
|
199
|
+
.description('Restore a soft-deleted scenario or template (admin only)')
|
|
200
|
+
.requiredOption('--id <id>', 'definition id')
|
|
201
|
+
.option('--json', 'emit stable JSON')
|
|
202
|
+
.action(async (options) => {
|
|
203
|
+
const result = await DeckClient.fromConfig().restoreScenarioDefinition(options.id);
|
|
204
|
+
output(result.definition, options.json);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=scenarios.js.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zcd sdk` — run each clone's official vendor SDK through CyberDeck.
|
|
3
|
+
*
|
|
4
|
+
* The server owns the official SDKs and conformance runner. The standalone CLI
|
|
5
|
+
* starts a run for each supported clone, polls that exact run ID, writes the
|
|
6
|
+
* returned reports, and applies the requested score threshold locally.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { DeckClient, } from '../client.js';
|
|
11
|
+
import { parseCloneTargets, resolveDeckId, pollClone } from './decks.js';
|
|
12
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
13
|
+
function pct(value) {
|
|
14
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
15
|
+
}
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function normalizeReport(run) {
|
|
20
|
+
const raw = isRecord(run.report) ? run.report : {};
|
|
21
|
+
const categories = isRecord(raw.perCategory)
|
|
22
|
+
? raw.perCategory
|
|
23
|
+
: {};
|
|
24
|
+
const failures = Array.isArray(raw.failureReports)
|
|
25
|
+
? raw.failureReports
|
|
26
|
+
: undefined;
|
|
27
|
+
return {
|
|
28
|
+
vendor: typeof raw.vendor === 'string' ? raw.vendor : run.vendor,
|
|
29
|
+
weightedScore: typeof raw.weightedScore === 'number'
|
|
30
|
+
? raw.weightedScore
|
|
31
|
+
: run.weightedScore ?? 0,
|
|
32
|
+
passed: typeof raw.passed === 'number' ? raw.passed : run.passed ?? 0,
|
|
33
|
+
totalCases: typeof raw.totalCases === 'number' ? raw.totalCases : run.totalCases ?? 0,
|
|
34
|
+
perCategory: categories,
|
|
35
|
+
...(failures ? { failureReports: failures } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function pollConformanceRun(client, deckId, cloneId, runId, timeoutMs) {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
let firstRequest = true;
|
|
41
|
+
while (true) {
|
|
42
|
+
const remainingBeforeRequest = deadline - Date.now();
|
|
43
|
+
if (!firstRequest && remainingBeforeRequest <= 0) {
|
|
44
|
+
throw new Error(`conformance timed out after ${(timeoutMs / 1000).toFixed(1)}s`);
|
|
45
|
+
}
|
|
46
|
+
const requestTimeoutMs = Math.max(1, Math.min(30_000, remainingBeforeRequest));
|
|
47
|
+
const { run } = await client.getConformanceRun(deckId, cloneId, runId, requestTimeoutMs).catch((error) => {
|
|
48
|
+
// The deadline can expire while fetch is awaiting headers or a body, not
|
|
49
|
+
// only between polls. Keep both timeout paths recognizable to CLI users.
|
|
50
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
51
|
+
throw new Error(`conformance request timed out after ${(requestTimeoutMs / 1000).toFixed(1)}s`, { cause: error });
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
});
|
|
55
|
+
firstRequest = false;
|
|
56
|
+
if (run.status !== 'running')
|
|
57
|
+
return run;
|
|
58
|
+
const remaining = deadline - Date.now();
|
|
59
|
+
if (remaining <= 0) {
|
|
60
|
+
throw new Error(`conformance timed out after ${(timeoutMs / 1000).toFixed(1)}s`);
|
|
61
|
+
}
|
|
62
|
+
await sleep(Math.min(1000, remaining));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function scoreTarget(value) {
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 0.9;
|
|
68
|
+
}
|
|
69
|
+
function runTimeout(value) {
|
|
70
|
+
const seconds = Number(value);
|
|
71
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 600_000;
|
|
72
|
+
}
|
|
73
|
+
export function registerSdkCommand(program) {
|
|
74
|
+
program
|
|
75
|
+
.command('sdk')
|
|
76
|
+
.description('Run each clone\'s official vendor SDK against it through CyberDeck')
|
|
77
|
+
.option('--deck <id>', 'target an existing deck (else --clones spins a new one)')
|
|
78
|
+
.option('--clones <list>', 'comma-separated clone target ids to spin up first')
|
|
79
|
+
.option('--vendor <v>', 'only run this one clone target from the deck')
|
|
80
|
+
.option('--no-seed', 'tell the server not to reseed clones before each SDK run')
|
|
81
|
+
.option('--target <score>', 'min weighted score per vendor (0-1)', '0.9')
|
|
82
|
+
.option('--down', 'tear the deck down after (only meaningful with --clones)')
|
|
83
|
+
.option('--timeout <seconds>', 'per-clone provision/run wait timeout', '600')
|
|
84
|
+
.action(async (opts) => {
|
|
85
|
+
const client = DeckClient.fromConfig();
|
|
86
|
+
const catalog = await client.getCatalog();
|
|
87
|
+
const targets = new Map(catalog.targets.map((entry) => [entry.id, entry]));
|
|
88
|
+
const targetScore = scoreTarget(opts.target);
|
|
89
|
+
const timeoutMs = runTimeout(opts.timeout);
|
|
90
|
+
let deckId;
|
|
91
|
+
let ownsDeck = false;
|
|
92
|
+
const createdClones = [];
|
|
93
|
+
if (opts.clones) {
|
|
94
|
+
const vendors = parseCloneTargets(opts.clones, catalog);
|
|
95
|
+
if (vendors.length === 0)
|
|
96
|
+
throw new Error('--clones is empty');
|
|
97
|
+
const created = await client.createDeck(`zcd sdk ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`);
|
|
98
|
+
deckId = created.id;
|
|
99
|
+
ownsDeck = true;
|
|
100
|
+
console.log(`Created deck "${created.name}" (${created.id})`);
|
|
101
|
+
for (const vendor of vendors) {
|
|
102
|
+
const clone = await client.addClone(created.id, vendor);
|
|
103
|
+
createdClones.push(clone);
|
|
104
|
+
await client.setClone(created.id, clone.id, 'running');
|
|
105
|
+
console.log(` + ${vendor} started (${clone.id})`);
|
|
106
|
+
}
|
|
107
|
+
console.log('\nWaiting for clones to reach running…');
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
deckId = await resolveDeckId(client, opts.deck);
|
|
111
|
+
}
|
|
112
|
+
const deck = await client.getDeck(deckId);
|
|
113
|
+
let clones = deck.clones && deck.clones.length > 0 ? deck.clones : createdClones;
|
|
114
|
+
if (opts.vendor) {
|
|
115
|
+
const selected = opts.vendor.trim().toLowerCase();
|
|
116
|
+
clones = clones.filter((clone) => clone.vendor === selected);
|
|
117
|
+
}
|
|
118
|
+
const sdkClones = clones.filter((clone) => targets.get(clone.vendor)?.conformanceAvailable === true);
|
|
119
|
+
const skipped = clones
|
|
120
|
+
.filter((clone) => targets.get(clone.vendor)?.conformanceAvailable !== true)
|
|
121
|
+
.map((clone) => clone.vendor);
|
|
122
|
+
if (sdkClones.length === 0) {
|
|
123
|
+
console.log('No clones with a server-side SDK conformance suite in this deck.');
|
|
124
|
+
if (skipped.length > 0)
|
|
125
|
+
console.log(` skipped (no SDK suite): ${skipped.join(', ')}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const reportDir = resolve(process.cwd(), 'reports', 'live', deckId);
|
|
129
|
+
mkdirSync(reportDir, { recursive: true });
|
|
130
|
+
const rows = [];
|
|
131
|
+
const createdIds = new Set(createdClones.map((clone) => clone.id));
|
|
132
|
+
for (const clone of sdkClones) {
|
|
133
|
+
const metadata = targets.get(clone.vendor);
|
|
134
|
+
console.log(`\n── ${clone.vendor} (${metadata.vendorEvokes}) ─────────────────────────`);
|
|
135
|
+
try {
|
|
136
|
+
if (createdIds.has(clone.id)) {
|
|
137
|
+
const runtime = await pollClone(client, deckId, clone.id, timeoutMs);
|
|
138
|
+
const status = runtime.cloneStatus || runtime.status;
|
|
139
|
+
if (status !== 'running') {
|
|
140
|
+
throw new Error(`not running (${status ?? 'unknown'})${runtime.errorMessage ? ` — ${runtime.errorMessage}` : ''}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const started = await client.startConformance(deckId, clone.id, opts.seed !== false);
|
|
144
|
+
console.log(` run: ${started.runId}${opts.seed === false ? ' (seed skipped)' : ''}`);
|
|
145
|
+
const run = await pollConformanceRun(client, deckId, clone.id, started.runId, timeoutMs);
|
|
146
|
+
if (run.status === 'failed') {
|
|
147
|
+
throw new Error(run.error || 'server-side conformance run failed');
|
|
148
|
+
}
|
|
149
|
+
const report = normalizeReport(run);
|
|
150
|
+
const sdkCategory = report.perCategory.sdk;
|
|
151
|
+
const sdkApplicable = Boolean(sdkCategory && sdkCategory.total > 0);
|
|
152
|
+
const sdkOk = !sdkApplicable || sdkCategory?.score === 1;
|
|
153
|
+
const pass = report.weightedScore >= targetScore && sdkOk;
|
|
154
|
+
console.log(` ${pass ? '✓' : '✗'} ${pct(report.weightedScore)} weighted `
|
|
155
|
+
+ `(${report.passed}/${report.totalCases} cases`
|
|
156
|
+
+ `${sdkCategory ? `, sdk ${sdkApplicable ? `${sdkCategory.passed}/${sdkCategory.total}` : 'n/a'}` : ''})`);
|
|
157
|
+
for (const failure of (report.failureReports ?? []).slice(0, 5)) {
|
|
158
|
+
console.log(` [${failure.category}] ${failure.caseId} — ${failure.error.slice(0, 90)}`);
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(resolve(reportDir, `${clone.vendor}.json`), JSON.stringify(report, null, 2));
|
|
161
|
+
rows.push({ vendor: clone.vendor, evokes: metadata.vendorEvokes, report, pass });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
console.log(` ✗ run failed: ${message}`);
|
|
166
|
+
rows.push({
|
|
167
|
+
vendor: clone.vendor,
|
|
168
|
+
evokes: metadata.vendorEvokes,
|
|
169
|
+
error: message,
|
|
170
|
+
pass: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
console.log(`\n═══ SDK conformance summary (target ≥ ${pct(targetScore)}, sdk = 100%) ═══`);
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
const score = row.report ? pct(row.report.weightedScore).padStart(6) : ' — ';
|
|
177
|
+
const sdk = row.report?.perCategory.sdk && row.report.perCategory.sdk.total > 0
|
|
178
|
+
? `${row.report.perCategory.sdk.passed}/${row.report.perCategory.sdk.total}`
|
|
179
|
+
: 'n/a';
|
|
180
|
+
console.log(` ${row.pass ? '✓' : '✗'} ${row.vendor.padEnd(8)} ${score} sdk ${String(sdk).padStart(5)} `
|
|
181
|
+
+ `${row.evokes}${row.error ? ` (${row.error})` : ''}`);
|
|
182
|
+
}
|
|
183
|
+
if (skipped.length > 0)
|
|
184
|
+
console.log(` · skipped (no SDK suite): ${skipped.join(', ')}`);
|
|
185
|
+
const summary = {
|
|
186
|
+
deckId,
|
|
187
|
+
generatedAt: new Date().toISOString(),
|
|
188
|
+
target: targetScore,
|
|
189
|
+
vendors: rows.map((row) => ({
|
|
190
|
+
vendor: row.vendor,
|
|
191
|
+
evokes: row.evokes,
|
|
192
|
+
pass: row.pass,
|
|
193
|
+
weightedScore: row.report?.weightedScore ?? null,
|
|
194
|
+
passed: row.report?.passed ?? null,
|
|
195
|
+
totalCases: row.report?.totalCases ?? null,
|
|
196
|
+
sdk: row.report?.perCategory.sdk ?? null,
|
|
197
|
+
error: row.error ?? null,
|
|
198
|
+
})),
|
|
199
|
+
};
|
|
200
|
+
writeFileSync(resolve(reportDir, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
201
|
+
console.log(`\n reports → ${reportDir}`);
|
|
202
|
+
if (opts.down && ownsDeck) {
|
|
203
|
+
console.log(`\nTearing down deck ${deckId}…`);
|
|
204
|
+
for (const clone of deck.clones ?? createdClones) {
|
|
205
|
+
try {
|
|
206
|
+
await client.deleteClone(deckId, clone.id);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Best effort: continue tearing down the remaining deck resources.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
await client.deleteDeck(deckId);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Best effort: the run result remains authoritative even if cleanup fails.
|
|
217
|
+
}
|
|
218
|
+
console.log(' done.');
|
|
219
|
+
}
|
|
220
|
+
else if (opts.down) {
|
|
221
|
+
console.log('\n--down ignored (targets an existing deck; tear down with `zcd down --deck <id>`).');
|
|
222
|
+
}
|
|
223
|
+
const failed = rows.filter((row) => !row.pass);
|
|
224
|
+
console.log(`\n${failed.length === 0 ? '✓ all SDK vendors met target' : `✗ ${failed.length} vendor(s) below target`}`);
|
|
225
|
+
if (failed.length > 0)
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=sdk.js.map
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
import { resolveCloneSelector } from '../automation.js';
|
|
5
|
+
import { DeckClient, } from '../client.js';
|
|
6
|
+
async function selectClone(client, deckId, selector) {
|
|
7
|
+
const deck = await client.getDeck(deckId);
|
|
8
|
+
return { deck, clone: resolveCloneSelector(deck, selector) };
|
|
9
|
+
}
|
|
10
|
+
function printWorkbench(workbench) {
|
|
11
|
+
console.log(`${workbench.clone.vendor} (${workbench.clone.id})`);
|
|
12
|
+
console.log(` runtime: ${workbench.runtime.serviceStatus}`
|
|
13
|
+
+ ` / ${workbench.runtime.lifecycleStatus}`
|
|
14
|
+
+ ` / ${workbench.runtime.presenceStatus}`);
|
|
15
|
+
console.log(` ztc: ${workbench.terminal.status}`
|
|
16
|
+
+ ` / ${workbench.terminal.provider || '—'}`
|
|
17
|
+
+ ` / ${workbench.terminal.model || '—'}`);
|
|
18
|
+
console.log(` changes: ${workbench.changes.clean ? 'clean' : `${workbench.changes.changedFiles} files`}`
|
|
19
|
+
+ ` (+${workbench.changes.additions} -${workbench.changes.deletions})`);
|
|
20
|
+
console.log(` head: ${workbench.changes.head ?? '—'}`);
|
|
21
|
+
console.log(` seed: ${workbench.changes.seed ?? '—'}`);
|
|
22
|
+
}
|
|
23
|
+
async function requirementText(options) {
|
|
24
|
+
if (options.file && options.requirement) {
|
|
25
|
+
throw new Error('Use either --file or --requirement, not both.');
|
|
26
|
+
}
|
|
27
|
+
const value = options.file
|
|
28
|
+
? await readFile(resolve(options.file), 'utf8')
|
|
29
|
+
: options.requirement ?? '';
|
|
30
|
+
const cleaned = value.trim();
|
|
31
|
+
if (!cleaned)
|
|
32
|
+
throw new Error('A non-empty --file or --requirement is required.');
|
|
33
|
+
if (cleaned.length > 8000)
|
|
34
|
+
throw new Error('Requirement exceeds the 8000 character limit.');
|
|
35
|
+
return cleaned;
|
|
36
|
+
}
|
|
37
|
+
async function submitRequirement(options, captureBefore = false) {
|
|
38
|
+
const client = DeckClient.fromConfig();
|
|
39
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
40
|
+
const before = captureBefore
|
|
41
|
+
? await client.getWorkbench(selected.deck.id, selected.clone.id)
|
|
42
|
+
: null;
|
|
43
|
+
if (before && !before.capabilities.requirements) {
|
|
44
|
+
throw new Error(`Clone '${selected.clone.vendor}' does not accept live requirements.`);
|
|
45
|
+
}
|
|
46
|
+
await client.submitWorkbenchRequirement(selected.deck.id, selected.clone.id, await requirementText(options));
|
|
47
|
+
console.log(`Requirement accepted by ${selected.clone.vendor} (${selected.clone.id}).`);
|
|
48
|
+
return { client, selected, before };
|
|
49
|
+
}
|
|
50
|
+
function safeRelativePath(value) {
|
|
51
|
+
const normalized = posix.normalize(value.replaceAll('\\', '/'));
|
|
52
|
+
if (!normalized
|
|
53
|
+
|| normalized === '.'
|
|
54
|
+
|| normalized.startsWith('/')
|
|
55
|
+
|| normalized === '..'
|
|
56
|
+
|| normalized.startsWith('../')) {
|
|
57
|
+
throw new Error(`Unsafe clone source path: ${value}`);
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
function localTarget(root, relative) {
|
|
62
|
+
const base = resolve(root);
|
|
63
|
+
const target = resolve(base, relative);
|
|
64
|
+
if (target !== base && !target.startsWith(`${base}${sep}`)) {
|
|
65
|
+
throw new Error(`Clone source path escapes output directory: ${relative}`);
|
|
66
|
+
}
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
async function pullChanges(client, selected, workbench, outDir, promotion) {
|
|
70
|
+
const workspaceRoot = workbench.changes.workspaceRoot;
|
|
71
|
+
if (!workspaceRoot?.startsWith('/')) {
|
|
72
|
+
throw new Error('Runtime did not advertise an absolute clone workspace root.');
|
|
73
|
+
}
|
|
74
|
+
const outputRoot = resolve(outDir);
|
|
75
|
+
await mkdir(outputRoot, { recursive: true });
|
|
76
|
+
const pulled = [];
|
|
77
|
+
const deleted = [];
|
|
78
|
+
for (const file of workbench.changes.files) {
|
|
79
|
+
const relative = safeRelativePath(file.path);
|
|
80
|
+
if (file.status.includes('D')) {
|
|
81
|
+
deleted.push(relative);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const remote = posix.join(workspaceRoot, relative);
|
|
85
|
+
const snapshot = await client.getCloneFileContent(selected.deck.id, selected.clone.id, remote);
|
|
86
|
+
if (snapshot.status !== 'available' || snapshot.truncated) {
|
|
87
|
+
throw new Error(`Could not pull complete source file: ${relative}`);
|
|
88
|
+
}
|
|
89
|
+
const content = snapshot.encoding === 'base64'
|
|
90
|
+
? Buffer.from(snapshot.content, 'base64')
|
|
91
|
+
: Buffer.from(snapshot.content, 'utf8');
|
|
92
|
+
const target = localTarget(outputRoot, relative);
|
|
93
|
+
await mkdir(resolve(target, '..'), { recursive: true });
|
|
94
|
+
await writeFile(target, content);
|
|
95
|
+
pulled.push(relative);
|
|
96
|
+
}
|
|
97
|
+
if (promotion) {
|
|
98
|
+
await writeFile(localTarget(outputRoot, 'zerg-promotion.json'), `${JSON.stringify({
|
|
99
|
+
schemaVersion: 1,
|
|
100
|
+
deckId: selected.deck.id,
|
|
101
|
+
cloneId: selected.clone.id,
|
|
102
|
+
vendor: selected.clone.vendor,
|
|
103
|
+
seed: workbench.changes.seed,
|
|
104
|
+
head: workbench.changes.head,
|
|
105
|
+
files: pulled,
|
|
106
|
+
deletedFiles: deleted,
|
|
107
|
+
}, null, 2)}\n`);
|
|
108
|
+
}
|
|
109
|
+
console.log(`${promotion ? 'Promotion bundle' : 'Changes'}: ${pulled.length} files`
|
|
110
|
+
+ `${deleted.length ? `, ${deleted.length} deletions recorded` : ''} → ${outputRoot}`);
|
|
111
|
+
}
|
|
112
|
+
function addSelectionOptions(command) {
|
|
113
|
+
return command
|
|
114
|
+
.requiredOption('--deck <id>', 'deck id')
|
|
115
|
+
.requiredOption('--clone <vendor-or-uuid>', 'unique vendor or exact clone UUID');
|
|
116
|
+
}
|
|
117
|
+
export function registerWorkbenchCommands(program) {
|
|
118
|
+
const zerg = program.command('zerg').description('Operate a clone-resident Zerg');
|
|
119
|
+
addSelectionOptions(zerg.command('inspect').description('Inspect the resident Zerg and live source'))
|
|
120
|
+
.option('--json', 'emit stable JSON')
|
|
121
|
+
.action(async (options) => {
|
|
122
|
+
const client = DeckClient.fromConfig();
|
|
123
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
124
|
+
const workbench = await client.getWorkbench(selected.deck.id, selected.clone.id);
|
|
125
|
+
if (options.json)
|
|
126
|
+
console.log(JSON.stringify(workbench, null, 2));
|
|
127
|
+
else
|
|
128
|
+
printWorkbench(workbench);
|
|
129
|
+
});
|
|
130
|
+
addSelectionOptions(zerg.command('require').description('Submit one compatibility requirement'))
|
|
131
|
+
.option('--file <path>', 'read requirement from a UTF-8 file')
|
|
132
|
+
.option('--requirement <text>', 'inline requirement')
|
|
133
|
+
.action(async (options) => {
|
|
134
|
+
await submitRequirement(options);
|
|
135
|
+
});
|
|
136
|
+
addSelectionOptions(zerg.command('run').description('Submit a requirement and optionally watch for edits'))
|
|
137
|
+
.option('--file <path>', 'read requirement from a UTF-8 file')
|
|
138
|
+
.option('--requirement <text>', 'inline requirement')
|
|
139
|
+
.option('--watch', 'poll until the workspace changes')
|
|
140
|
+
.option('--timeout <seconds>', 'watch timeout', '600')
|
|
141
|
+
.action(async (options) => {
|
|
142
|
+
const { client, selected, before } = await submitRequirement(options, true);
|
|
143
|
+
if (!options.watch)
|
|
144
|
+
return;
|
|
145
|
+
if (!before)
|
|
146
|
+
throw new Error('Could not capture the pre-run workbench state.');
|
|
147
|
+
const timeoutSeconds = Number(options.timeout);
|
|
148
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600) {
|
|
149
|
+
throw new Error('--timeout must be between 1 and 3600 seconds.');
|
|
150
|
+
}
|
|
151
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
152
|
+
while (Date.now() < deadline) {
|
|
153
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 2000));
|
|
154
|
+
const current = await client.getWorkbench(selected.deck.id, selected.clone.id);
|
|
155
|
+
if (current.changes.head !== before.changes.head
|
|
156
|
+
|| current.changes.changedFiles !== before.changes.changedFiles) {
|
|
157
|
+
printWorkbench(current);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw new Error('Timed out waiting for the clone workspace to change.');
|
|
162
|
+
});
|
|
163
|
+
const changes = program.command('changes').description('Inspect and recover clone source divergence');
|
|
164
|
+
addSelectionOptions(changes.command('status').description('Show current source divergence'))
|
|
165
|
+
.option('--json', 'emit stable JSON')
|
|
166
|
+
.action(async (options) => {
|
|
167
|
+
const client = DeckClient.fromConfig();
|
|
168
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
169
|
+
const workbench = await client.getWorkbench(selected.deck.id, selected.clone.id);
|
|
170
|
+
if (options.json)
|
|
171
|
+
console.log(JSON.stringify(workbench, null, 2));
|
|
172
|
+
else
|
|
173
|
+
printWorkbench(workbench);
|
|
174
|
+
});
|
|
175
|
+
addSelectionOptions(changes.command('show').description('List changed source files'))
|
|
176
|
+
.action(async (options) => {
|
|
177
|
+
const client = DeckClient.fromConfig();
|
|
178
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
179
|
+
const workbench = await client.getWorkbench(selected.deck.id, selected.clone.id);
|
|
180
|
+
for (const file of workbench.changes.files) {
|
|
181
|
+
console.log(`${file.status.padEnd(3)} ${file.path}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
addSelectionOptions(changes.command('history').description('List clone checkpoints'))
|
|
185
|
+
.action(async (options) => {
|
|
186
|
+
const client = DeckClient.fromConfig();
|
|
187
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
188
|
+
const workbench = await client.getWorkbench(selected.deck.id, selected.clone.id);
|
|
189
|
+
for (const item of workbench.changes.history ?? []) {
|
|
190
|
+
console.log(`${item.head.slice(0, 12)} ${item.createdAt} ${item.label}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
addSelectionOptions(changes.command('checkpoint').description('Commit the current live source'))
|
|
194
|
+
.option('--label <text>', 'checkpoint label', 'operator checkpoint')
|
|
195
|
+
.action(async (options) => {
|
|
196
|
+
const client = DeckClient.fromConfig();
|
|
197
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
198
|
+
await client.runWorkbenchAction(selected.deck.id, selected.clone.id, 'checkpoint', { label: options.label });
|
|
199
|
+
console.log(`Checkpoint created for ${selected.clone.vendor}.`);
|
|
200
|
+
});
|
|
201
|
+
addSelectionOptions(changes.command('reset').description('Discard uncheckpointed live source'))
|
|
202
|
+
.option('--yes', 'confirm reset')
|
|
203
|
+
.action(async (options) => {
|
|
204
|
+
if (!options.yes)
|
|
205
|
+
throw new Error('Reset requires --yes.');
|
|
206
|
+
const client = DeckClient.fromConfig();
|
|
207
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
208
|
+
await client.runWorkbenchAction(selected.deck.id, selected.clone.id, 'reset');
|
|
209
|
+
console.log(`Reset ${selected.clone.vendor} to its latest checkpoint.`);
|
|
210
|
+
});
|
|
211
|
+
for (const promotion of [false, true]) {
|
|
212
|
+
const command = changes.command(promotion ? 'promote' : 'pull')
|
|
213
|
+
.description(promotion
|
|
214
|
+
? 'Create a canonical promotion bundle from live changes'
|
|
215
|
+
: 'Pull changed live source files')
|
|
216
|
+
.requiredOption('--out <directory>', 'local output directory');
|
|
217
|
+
addSelectionOptions(command).action(async (options) => {
|
|
218
|
+
const client = DeckClient.fromConfig();
|
|
219
|
+
const selected = await selectClone(client, options.deck, options.clone);
|
|
220
|
+
const workbench = await client.getWorkbench(selected.deck.id, selected.clone.id);
|
|
221
|
+
await pullChanges(client, selected, workbench, options.out, promotion);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=workbench.js.map
|