@caveman-ai/cloud 0.0.1 → 0.1.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/bin/ci.mjs +228 -0
- package/bin/cvm.mjs +12 -3
- package/dist/cloud-client.mjs +33 -33
- package/dist/cloud-mcp.mjs +31 -31
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -13,10 +13,28 @@ cvm mcp # Cloud MCP server over stdio
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
- **Sign-in.** cvm uses the credential `caveman login` saved. If `caveman` isn't installed, `cvm login` fetches it once with npx. In CI, set `CAVE_TOKEN` (and `CAVE_API_URL` for a private install).
|
|
16
|
+
- **Environment.** A flag wins over the environment, and the environment over the saved login: `CAVE_API_URL`, `CAVE_PROJECT_ID`, `CAVE_TOKEN`. The saved credential is only ever sent to the address it was saved with; another `CAVE_API_URL` needs its own `CAVE_TOKEN`.
|
|
16
17
|
- **Projects.** Use `--project UUID`, `CAVE_PROJECT_ID`, `cvm context bind` (writes `.caveman-cloud.json` in the repository) or the saved default.
|
|
17
18
|
- **Output.** JSON when piped, readable when on a terminal; `--format json|jsonl|table`.
|
|
18
19
|
- **Exit codes.** 0 ok · 1 request failed · 2 usage or invalid input (HTTP 400/422) · 3 signed out or forbidden (401/403) · 4 conflict (409) · 5 a comparison or gate failed · 6 timeout, still running or not enough data · 7 rate limited (429).
|
|
19
20
|
|
|
21
|
+
## Pull request checks from any CI system
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
cvm evals ci --suite evals --junit 'reports/junit*.xml' -- npm test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Starts a CI run, runs the test command with the run's spend-capped key (`CAVE_API_KEY`, `CAVE_GATEWAY_URL`, `CAVE_CI_RUN_ID`), uploads the JUnit reports, waits for the verdict against the base branch and prints it. The commit, branch, pull request and job come from GitHub Actions, GitLab CI, Buildkite or Jenkins variables, or the checkout; `--sha`, `--ref`, `--base-sha`, `--base-ref`, `--pr`, `--job`, `--repository-id` override them.
|
|
28
|
+
|
|
29
|
+
- `--advisory` never fails on Caveman's account: not on the verdict, not when Caveman cannot be reached.
|
|
30
|
+
- `--max-quality-drop-pp N` and `--max-cost-increase-pct N` add limits of your own. They can only add a failure.
|
|
31
|
+
- `--junit-out PATH` writes the verdict as a JUnit report for the CI system's test tab (`-` prints it).
|
|
32
|
+
- `--max-usd N` caps the run's key; `--timeout SECONDS` bounds the wait (default 180, 0 does not wait).
|
|
33
|
+
- Exit: the test command's own code when it fails; else 0, 5 when the gate failed, 6 when a gating suite had no verdict in time.
|
|
34
|
+
- It signs in as a person (`CAVE_TOKEN` from a session, or `caveman login`): a project API key is refused. On GitHub Actions use the Caveman action instead, which needs no secret and writes the check run.
|
|
35
|
+
|
|
36
|
+
`cvm evals watch RUN_ID [--timeout SECONDS]` waits for any run's verdict: exit 0 ship, pass or skipped, 5 don't ship or a failed gate, 6 need more data or still running. `cvm evals watch WORKBENCH_ID RUN_ID` is `cvm evals wait`. Operations that start a long run take `--wait` (`cvm evals run --wait`).
|
|
37
|
+
|
|
20
38
|
Commands come from the Cloud release they ship with: `cvm <family> <verb>` runs the operation `family.verb`. `cvm tools describe <family>.<verb>` prints its input schema.
|
|
21
39
|
|
|
22
40
|
Source: Caveman Cloud `public/cloud-cli/`, which bundles `cloud/agent-surface` at the release commit.
|
package/bin/ci.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// `cvm evals ci` and `cvm evals watch`: the pull request check from any CI
|
|
2
|
+
// system, and a wait on one run's verdict. The GitHub Action
|
|
3
|
+
// (public/integrations/github-action) does the same with the job's OIDC
|
|
4
|
+
// token; here the caller is a signed-in person, so nothing is written on
|
|
5
|
+
// GitHub and this command's exit code is the gate.
|
|
6
|
+
import {globSync,readFileSync,writeFileSync} from 'node:fs';
|
|
7
|
+
import {execFileSync,spawnSync} from 'node:child_process';
|
|
8
|
+
import {randomUUID} from 'node:crypto';
|
|
9
|
+
import {join} from 'node:path';
|
|
10
|
+
import {parseArgs} from 'node:util';
|
|
11
|
+
import {setTimeout as delay} from 'node:timers/promises';
|
|
12
|
+
import {CloudClient,CloudError,inertJSON,runCloudCLI} from '../dist/cloud-client.mjs';
|
|
13
|
+
|
|
14
|
+
const MAX_REPORTS=20,MAX_REPORT_BYTES=10*1024*1024; // the finish route's own limits
|
|
15
|
+
const say=line=>process.stderr.write(line+'\n');
|
|
16
|
+
const sha40=value=>/^[0-9a-f]{40}$/.test(value??'')&&!/^0+$/.test(value);
|
|
17
|
+
const digits=value=>/^\d+$/.test(value??'')?value:undefined;
|
|
18
|
+
const git=(...args)=>{try{return execFileSync('git',args,{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim();}catch{return '';}};
|
|
19
|
+
const xml=text=>String(text).replace(/[<>&"]/g,c=>({'<':'<','>':'>','&':'&','"':'"'})[c]).replace(/[^\x09\x0a\x0d\x20--�]/g,'');
|
|
20
|
+
|
|
21
|
+
// Flags, then CAVE_PROJECT_ID, then the repository's binding, then the saved default.
|
|
22
|
+
async function connect(host,project){
|
|
23
|
+
const cfg=await host();
|
|
24
|
+
let bound;try{bound=JSON.parse(readFileSync('.caveman-cloud.json','utf8')).project_id;}catch{}
|
|
25
|
+
return new CloudClient({...cfg,projectId:project??process.env.CAVE_PROJECT_ID??bound??cfg.projectId});
|
|
26
|
+
}
|
|
27
|
+
function projectPath(client){
|
|
28
|
+
if(!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(client.projectId??''))throw new CloudError('cave_project_required','Select a project with --project, CAVE_PROJECT_ID or cvm context bind.',2);
|
|
29
|
+
return `/api/v1/projects/${client.projectId}`;
|
|
30
|
+
}
|
|
31
|
+
function seconds(raw,fallback){
|
|
32
|
+
const value=Number(raw??fallback);
|
|
33
|
+
if(!Number.isFinite(value)||value<0||value>86400)throw new CloudError('cave_usage','--timeout is seconds, 0 to 86400.',2);
|
|
34
|
+
return value*1000;
|
|
35
|
+
}
|
|
36
|
+
function limit(raw,name){
|
|
37
|
+
if(raw===undefined)return undefined;
|
|
38
|
+
const value=Number(raw);
|
|
39
|
+
if(!Number.isFinite(value)||value<0)throw new CloudError('cave_usage',`--${name} must be a number of at least 0.`,2);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
// until reads again, 1 s apart growing to 10 s, until done says so or the
|
|
43
|
+
// time is up (undefined).
|
|
44
|
+
async function until(read,done,timeoutMs,waiting){
|
|
45
|
+
const deadline=Date.now()+timeoutMs;
|
|
46
|
+
for(let ms=1000;;ms=Math.min(ms*1.5,10000)){
|
|
47
|
+
const value=await read();
|
|
48
|
+
if(done(value))return value;
|
|
49
|
+
if(Date.now()+ms>deadline)return undefined;
|
|
50
|
+
waiting(value);
|
|
51
|
+
await delay(ms);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// detectRun reads the commit, branch, pull request and job from the CI
|
|
56
|
+
// system's own variables: GitHub Actions, GitLab CI, Buildkite, Jenkins, then
|
|
57
|
+
// the checkout itself. A flag wins over all of them.
|
|
58
|
+
export function detectRun(env,flags={},{gitRead=git,read=readFileSync}={}){
|
|
59
|
+
const first=(...values)=>values.find(value=>value!==undefined&&value!=='');
|
|
60
|
+
let event={};try{if(env.GITHUB_EVENT_PATH)event=JSON.parse(read(env.GITHUB_EVENT_PATH,'utf8'));}catch{}
|
|
61
|
+
const branch=first(env.CI_COMMIT_REF_NAME,env.BUILDKITE_BRANCH,env.BRANCH_NAME,env.GIT_BRANCH);
|
|
62
|
+
const pr=first(flags.pr,/^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF??'')?.[1],env.CI_MERGE_REQUEST_IID,digits(env.BUILDKITE_PULL_REQUEST),env.CHANGE_ID);
|
|
63
|
+
const run={
|
|
64
|
+
sha:flags.sha??[event.pull_request?.head?.sha,env.GITHUB_SHA,env.CI_COMMIT_SHA,env.BUILDKITE_COMMIT,env.GIT_COMMIT].find(sha40)??gitRead('rev-parse','HEAD'),
|
|
65
|
+
ref:first(flags.ref,env.GITHUB_REF,branch&&`refs/heads/${branch}`)??`refs/heads/${gitRead('rev-parse','--abbrev-ref','HEAD')||'HEAD'}`,
|
|
66
|
+
base_sha:first(flags['base-sha'],[event.pull_request?.base?.sha,event.merge_group?.base_sha,env.CI_MERGE_REQUEST_DIFF_BASE_SHA,event.before].find(sha40)),
|
|
67
|
+
base_ref:first(flags['base-ref'],env.GITHUB_BASE_REF,env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME,env.BUILDKITE_PULL_REQUEST_BASE_BRANCH,env.CHANGE_TARGET),
|
|
68
|
+
pr_number:pr===undefined?undefined:Number(pr),
|
|
69
|
+
event:first(flags.event,env.GITHUB_EVENT_NAME==='merge_group'?'merge_group':undefined,pr===undefined?'push':'pull_request'),
|
|
70
|
+
job:first(flags.job,env.GITHUB_JOB,env.CI_JOB_NAME,env.BUILDKITE_STEP_KEY,env.BUILDKITE_LABEL,env.JOB_BASE_NAME,'ci').replace(/[^A-Za-z0-9_-]/g,'-').slice(0,100),
|
|
71
|
+
// run_id and run_attempt only name the run to its reader; the request id is what makes it one run.
|
|
72
|
+
run_id:first(flags['run-id'],digits(env.GITHUB_RUN_ID),digits(env.CI_PIPELINE_ID),digits(env.BUILDKITE_BUILD_NUMBER),digits(env.BUILD_NUMBER),String(Date.now())),
|
|
73
|
+
run_attempt:Number(first(flags['run-attempt'],env.GITHUB_RUN_ATTEMPT,digits(env.BUILDKITE_RETRY_COUNT)&&Number(env.BUILDKITE_RETRY_COUNT)+1,1)),
|
|
74
|
+
repository_id:Number(first(flags['repository-id'],env.GITHUB_REPOSITORY_ID,env.CI_PROJECT_ID,0)),
|
|
75
|
+
};
|
|
76
|
+
for(const key of Object.keys(run))if(run[key]===undefined)delete run[key];
|
|
77
|
+
return run;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// collectReports reads the JUnit files the globs match, inside the upload's
|
|
81
|
+
// limits. What does not fit is left out and named, never silently truncated.
|
|
82
|
+
function collectReports(patterns,cwd){
|
|
83
|
+
const files=[...new Set(patterns.flatMap(pattern=>globSync(pattern,{cwd,exclude:entry=>(entry.name??entry)==='node_modules'})))].sort();
|
|
84
|
+
const reports=[],skipped=[];let bytes=0;
|
|
85
|
+
for(const file of files){
|
|
86
|
+
const text=readFileSync(join(cwd,file),'utf8'),size=Buffer.byteLength(text);
|
|
87
|
+
if(reports.length>=MAX_REPORTS||bytes+size>MAX_REPORT_BYTES){skipped.push(file);continue;}
|
|
88
|
+
bytes+=size;reports.push(text);
|
|
89
|
+
}
|
|
90
|
+
return {reports,skipped};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// judge reads a scored run: what fails the gate and why. A gating suite's
|
|
94
|
+
// failing check does; so does a limit the caller set. The limits only add a
|
|
95
|
+
// failure and --advisory only spares the job: the verdict stays Caveman's.
|
|
96
|
+
export function judge(answer,{advisory=false,maxQualityDropPP,maxCostIncreasePct}={}){
|
|
97
|
+
const reasons=[];
|
|
98
|
+
if(answer.mode==='gate'&&['failure','action_required'].includes(answer.conclusion))reasons.push(`the gating suite's check concluded ${answer.conclusion}`);
|
|
99
|
+
const {quality,cost}=answer.check??{};
|
|
100
|
+
if(maxQualityDropPP!==undefined&&quality&&quality.estimate<-maxQualityDropPP)reasons.push(`test pass rate moved ${quality.estimate.toFixed(1)} pp, past --max-quality-drop-pp ${maxQualityDropPP}`);
|
|
101
|
+
if(maxCostIncreasePct!==undefined&&cost&&cost.estimate>maxCostIncreasePct)reasons.push(`cost moved ${cost.estimate>0?'+':''}${cost.estimate.toFixed(1)}%, past --max-cost-increase-pct ${maxCostIncreasePct}`);
|
|
102
|
+
return {reasons,failed:reasons.length>0&&!advisory};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// verdictJUnit is the verdict as one JUnit test case, for a CI system that
|
|
106
|
+
// shows test reports: failed when the gate failed, skipped with no verdict.
|
|
107
|
+
export function verdictJUnit(suite,answer,verdict){
|
|
108
|
+
const check=answer.check,name=check?.title??'No verdict yet';
|
|
109
|
+
const body=!check?`<skipped message="${xml('No verdict within the wait; read it with cvm evals watch '+answer.run.id)}"/>`
|
|
110
|
+
:verdict.failed?`<failure message="${xml(verdict.reasons.join('; '))}">${xml(check.sentence)}</failure>`:'';
|
|
111
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="${xml('Caveman / '+suite)}" tests="1" failures="${check&&verdict.failed?1:0}" skipped="${check?0:1}">\n`+
|
|
112
|
+
` <testcase classname="${xml('caveman.'+suite)}" name="${xml(name)}">${body}<system-out>${xml([check?.summary,answer.details_url].filter(Boolean).join('\n\n'))}</system-out></testcase>\n</testsuite>\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ciUsage='cvm evals ci [--suite NAME] [--junit GLOB]... [--junit-out PATH|-] [--advisory] [--max-usd N] [--max-quality-drop-pp N] [--max-cost-increase-pct N] [--timeout SECONDS] [--repository-id N] [--sha SHA] [--ref REF] [--base-sha SHA] [--base-ref REF] [--pr N] [--event push|pull_request|merge_group] [--job NAME] [--run-id N] [--run-attempt N] [--project UUID] -- TEST COMMAND';
|
|
116
|
+
const text={type:'string'};
|
|
117
|
+
|
|
118
|
+
export async function evalsCI(argv,{context}){
|
|
119
|
+
const split=argv.indexOf('--');
|
|
120
|
+
try{
|
|
121
|
+
const {values}=parseArgs({args:split<0?argv:argv.slice(0,split),strict:true,allowPositionals:false,options:{suite:text,junit:{type:'string',multiple:true},'junit-out':text,advisory:{type:'boolean'},'max-usd':text,
|
|
122
|
+
'max-quality-drop-pp':text,'max-cost-increase-pct':text,timeout:text,'repository-id':text,sha:text,ref:text,'base-sha':text,'base-ref':text,pr:text,event:text,job:text,'run-id':text,'run-attempt':text,project:text,help:{type:'boolean'}}});
|
|
123
|
+
if(values.help){process.stdout.write(inertJSON({usage:ciUsage,exit_codes:'the test command\'s own when it fails; else 0 no gate failed (always with --advisory), 5 the gate failed, 6 a gating suite had no verdict within --timeout'},2)+'\n');return 0;}
|
|
124
|
+
const command=split<0?[]:argv.slice(split+1);
|
|
125
|
+
if(!command.length)throw new CloudError('cave_usage',ciUsage,2);
|
|
126
|
+
const timeoutMs=seconds(values.timeout,180);
|
|
127
|
+
const limits={advisory:!!values.advisory,maxQualityDropPP:limit(values['max-quality-drop-pp'],'max-quality-drop-pp'),maxCostIncreasePct:limit(values['max-cost-increase-pct'],'max-cost-increase-pct')};
|
|
128
|
+
const run=detectRun(process.env,values);
|
|
129
|
+
const suite=values.suite??run.job;
|
|
130
|
+
const body={request_id:randomUUID(),...run,suite:{name:suite},...(values['max-usd']===undefined?{}:{max_usd:limit(values['max-usd'],'max-usd')})};
|
|
131
|
+
if(!sha40(run.sha))throw new CloudError('cave_usage','No commit found: run inside the checkout or pass --sha with the full commit.',2);
|
|
132
|
+
if(!(run.repository_id>0))throw new CloudError('cave_usage','Pass --repository-id: the numeric id of the repository connected to this project (gh api repos/OWNER/NAME --jq .id). GitHub Actions and GitLab CI supply it themselves.',2);
|
|
133
|
+
|
|
134
|
+
// Start. With --advisory Caveman never fails the job, an unreachable or
|
|
135
|
+
// refusing API included: the tests then run unmeasured.
|
|
136
|
+
let client,base,started;
|
|
137
|
+
try{
|
|
138
|
+
client=await connect(context,values.project);base=projectPath(client)+'/ci/runs';
|
|
139
|
+
started=await client.request(base,{body});
|
|
140
|
+
}catch(error){
|
|
141
|
+
if(!limits.advisory||error.exitCode===2&&error.code==='cave_usage')throw error;
|
|
142
|
+
say(`caveman: ${error.code??'cave_request_failed'}: ${error.message} The tests run unmeasured (--advisory).`);
|
|
143
|
+
}
|
|
144
|
+
const env={...process.env};
|
|
145
|
+
if(started?.key){
|
|
146
|
+
Object.assign(env,{CAVE_API_KEY:started.key.token,CAVE_CI_RUN_ID:started.run.id},started.key.gateway_url?{CAVE_GATEWAY_URL:started.key.gateway_url}:{});
|
|
147
|
+
say(`caveman: run ${started.run.id} started, ${started.mode} mode, baseline ${started.baseline?.match}, key capped at $${started.key.max_usd}.`);
|
|
148
|
+
}else if(started)say(`caveman: run ${started.run.id} has no key; its model calls are not measured.`);
|
|
149
|
+
|
|
150
|
+
// Windows runs a .cmd shim through cmd.exe with every argument quoted.
|
|
151
|
+
const child=process.platform==='win32'
|
|
152
|
+
?spawnSync(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`"${command.map(arg=>`"${String(arg).replaceAll('"','""')}"`).join(' ')}"`],{stdio:'inherit',env,windowsVerbatimArguments:true})
|
|
153
|
+
:spawnSync(command[0],command.slice(1),{stdio:'inherit',env});
|
|
154
|
+
if(child.error)say(`caveman: could not run ${command[0]}: ${child.error.message}`);
|
|
155
|
+
const commandExit=child.status??(child.error?127:1);
|
|
156
|
+
if(!started)return commandExit;
|
|
157
|
+
|
|
158
|
+
const status=child.signal?'cancelled':commandExit===0?'completed':'failed';
|
|
159
|
+
const patterns=values.junit?.length?values.junit:['**/junit*.xml'];
|
|
160
|
+
const {reports,skipped}=collectReports(patterns,process.cwd());
|
|
161
|
+
if(skipped.length)say(`caveman: ${skipped.length} JUnit reports were over the upload limit (20 files, 10 MiB) and were left out: ${skipped.join(', ')}`);
|
|
162
|
+
if(!reports.length)say(`caveman: no JUnit report matched ${patterns.join(', ')}; there are no tests to compare.`);
|
|
163
|
+
const finish=`${base}/${started.run.id}/finish`;
|
|
164
|
+
let answer;
|
|
165
|
+
try{
|
|
166
|
+
answer=await client.request(finish,{body:{status,junit:reports}});
|
|
167
|
+
say(`caveman: uploaded ${reports.length} JUnit reports for run ${started.run.id}.`);
|
|
168
|
+
// A closed run is returned unchanged: repeating the finish reads its verdict.
|
|
169
|
+
if(status!=='cancelled'&&timeoutMs>0)answer=await until(()=>client.request(finish,{body:{status}}),a=>a.run?.status==='complete',timeoutMs,()=>say('caveman: scoring…'))??answer;
|
|
170
|
+
}catch(error){
|
|
171
|
+
if(!limits.advisory)throw error;
|
|
172
|
+
say(`caveman: ${error.code??'cave_request_failed'}: ${error.message} (--advisory: the job is not failed).`);
|
|
173
|
+
return commandExit;
|
|
174
|
+
}
|
|
175
|
+
const mode=answer.mode??started.mode;
|
|
176
|
+
const verdict=answer.check?judge({...answer,mode},limits):{reasons:[],failed:false};
|
|
177
|
+
if(answer.check){
|
|
178
|
+
say(`caveman: ${answer.check.title} (${mode} mode). ${answer.check.sentence}`);
|
|
179
|
+
if(answer.check.summary)say('\n'+answer.check.summary+'\n');
|
|
180
|
+
}else if(status!=='cancelled')say(`caveman: no verdict for run ${started.run.id} yet. Read it later with: cvm evals watch ${started.run.id} --project ${client.projectId}`);
|
|
181
|
+
for(const reason of verdict.reasons)say(`caveman: ${reason}${verdict.failed?'':' (--advisory: the job is not failed)'}.`);
|
|
182
|
+
if(answer.details_url)say(`caveman: ${answer.details_url}`);
|
|
183
|
+
// A gating suite with no verdict has not passed: this exit code is its gate.
|
|
184
|
+
const unread=!answer.check&&status!=='cancelled'&&timeoutMs>0&&mode==='gate'&&!limits.advisory;
|
|
185
|
+
const exit=commandExit!==0?commandExit:verdict.failed?5:unread?6:0;
|
|
186
|
+
const report=verdictJUnit(suite,answer,verdict);
|
|
187
|
+
if(values['junit-out']&&values['junit-out']!=='-')writeFileSync(values['junit-out'],report);
|
|
188
|
+
process.stdout.write(values['junit-out']==='-'?report:inertJSON({schema_version:1,ok:exit===0,run_id:started.run.id,suite,mode,status:answer.run?.status,conclusion:answer.conclusion??null,
|
|
189
|
+
verdict:answer.check?{kind:answer.check.kind,title:answer.check.title,sentence:answer.check.sentence,quality:answer.check.quality??null,cost:answer.check.cost??null}:null,
|
|
190
|
+
details_url:answer.details_url??null,advisory:limits.advisory,failed:verdict.failed,reasons:verdict.reasons,command_exit:commandExit})+'\n');
|
|
191
|
+
return exit;
|
|
192
|
+
}catch(error){
|
|
193
|
+
const e=error instanceof CloudError?error:new CloudError('cave_usage',error.message??'Command failed.',2);
|
|
194
|
+
process.stdout.write(inertJSON({schema_version:1,ok:false,status:'failed',error:{code:e.code,message:e.message}})+'\n');return e.exitCode;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const watchUsage='cvm evals watch RUN_ID [--source eval_run|experiment|scenario_run|factory_comparison] [--timeout SECONDS] [--project UUID] or cvm evals watch WORKBENCH_ID RUN_ID [--timeout SECONDS] [--project UUID]';
|
|
199
|
+
|
|
200
|
+
// evalsWatch waits for one run's verdict. Exit 0 ship, pass or skipped; 5
|
|
201
|
+
// don't ship, or a gating check that failed; 6 need more data, or no verdict
|
|
202
|
+
// within --timeout (the run continues).
|
|
203
|
+
export async function evalsWatch(argv,{context,login}){
|
|
204
|
+
try{
|
|
205
|
+
const {values,positionals}=parseArgs({args:argv,strict:true,allowPositionals:true,options:{source:text,timeout:text,project:text,format:text,help:{type:'boolean'}}});
|
|
206
|
+
if(values.help){process.stdout.write(inertJSON({usage:watchUsage,exit_codes:'0 ship, pass or skipped; 5 don\'t ship or a failed gate; 6 need more data, or no verdict within --timeout'},2)+'\n');return 0;}
|
|
207
|
+
const timeoutMs=seconds(values.timeout,3600);
|
|
208
|
+
// A workbench run named with its workbench is the client's own wait, cell progress included.
|
|
209
|
+
if(positionals.length===2)return runCloudCLI(['evals','wait',...positionals,'--wait-timeout',String(Math.max(1,timeoutMs)),...(values.project?['--project',values.project]:[]),...(values.format?['--format',values.format]:[])],{context,login});
|
|
210
|
+
if(positionals.length!==1)throw new CloudError('cave_usage',watchUsage,2);
|
|
211
|
+
const client=await connect(context,values.project),input={source:values.source??'eval_run',run_id:positionals[0]};
|
|
212
|
+
projectPath(client);
|
|
213
|
+
let last='';
|
|
214
|
+
const tell=line=>{if(line!==last)say(`caveman: ${last=line}`);};
|
|
215
|
+
// A run that has not produced a verdict row yet reads as not found.
|
|
216
|
+
const read=()=>client.execute('verdicts.get',input).catch(error=>{if(error.code!=='cave_not_found')throw error;return undefined;});
|
|
217
|
+
const result=await until(read,r=>r&&r.data.state!=='running',timeoutMs,r=>tell(r?`${r.data.label}: running`:'no verdict yet'));
|
|
218
|
+
if(!result)throw new CloudError('cave_wait_timeout',`No verdict for run ${input.run_id} within the wait; the run continues. Reconnect with cvm evals watch ${input.run_id}.`,6);
|
|
219
|
+
const {verdict,gate,label,sentence}=result.data;
|
|
220
|
+
tell(`${label}: ${verdict}. ${sentence}`);
|
|
221
|
+
process.stdout.write(inertJSON(result)+'\n');
|
|
222
|
+
if(verdict==='dont_ship'||gate?.mode==='gate'&&['failure','action_required'].includes(gate.conclusion))return 5;
|
|
223
|
+
return ['ship','pass','skipped'].includes(verdict)?0:6;
|
|
224
|
+
}catch(error){
|
|
225
|
+
const e=error instanceof CloudError?error:new CloudError('cave_usage',error.message??'Command failed.',2);
|
|
226
|
+
process.stdout.write(inertJSON({schema_version:1,ok:false,status:'failed',error:{code:e.code,message:e.message}})+'\n');return e.exitCode;
|
|
227
|
+
}
|
|
228
|
+
}
|
package/bin/cvm.mjs
CHANGED
|
@@ -58,11 +58,17 @@ async function refresh(meta,credentials,baseURL){
|
|
|
58
58
|
}catch{return credentials;}
|
|
59
59
|
}
|
|
60
60
|
async function context(){
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// The environment wins over the saved login, and a flag over both (--project,
|
|
62
|
+
// read by the client). logout leaves baseURL empty.
|
|
63
|
+
const meta=readConfig(),origin=url=>(url??'').replace(/\/+$/,''),baseURL=process.env.CAVE_API_URL||meta.baseURL||'https://api.caveman.so';
|
|
64
|
+
const projectId=process.env.CAVE_PROJECT_ID||meta.projectId;
|
|
65
|
+
if(process.env.CAVE_TOKEN)return {baseURL,token:process.env.CAVE_TOKEN,projectId};
|
|
66
|
+
// The saved credential was issued by the saved address and is sent nowhere
|
|
67
|
+
// else: another CAVE_API_URL needs its own CAVE_TOKEN.
|
|
68
|
+
if(meta.baseURL&&origin(baseURL)!==origin(meta.baseURL))return {baseURL,token:undefined,projectId};
|
|
63
69
|
let credentials=decode(storedSecret(meta));
|
|
64
70
|
if(!meta.logoutPendingLocalCleanup&&credentials.refresh_token&&expiresSoon(credentials.access_token))credentials=await refresh(meta,credentials,baseURL);
|
|
65
|
-
return {baseURL,token:credentials.access_token,projectId:
|
|
71
|
+
return {baseURL,token:credentials.access_token,projectId:projectId??credentials.project_id};
|
|
66
72
|
}
|
|
67
73
|
// Sign-in stays in caveman: run the installed one, or fetch it once with npx.
|
|
68
74
|
function login(args){
|
|
@@ -85,6 +91,9 @@ if(argv[0]==='--version'||argv[0]==='-v'){
|
|
|
85
91
|
// A launcher may narrow the server to the operations one task needs.
|
|
86
92
|
const granted=(process.env.CAVE_MCP_OPERATIONS??'').split(',').map(id=>id.trim()).filter(Boolean);
|
|
87
93
|
await serveCloudMCP({context,...(granted.length?{grantedOperations:granted}:{})});
|
|
94
|
+
}else if(argv[0]==='evals'&&['ci','watch'].includes(argv[1])){
|
|
95
|
+
const {evalsCI,evalsWatch}=await import('./ci.mjs');
|
|
96
|
+
process.exitCode=await (argv[1]==='ci'?evalsCI:evalsWatch)(argv.slice(2),{context,login});
|
|
88
97
|
}else{
|
|
89
98
|
const {runCloudCLI}=await import('../dist/cloud-client.mjs');
|
|
90
99
|
process.exitCode=await runCloudCLI(argv,{context,login});
|