cuttlely 1.0.2 → 1.1.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/README.md +56 -1
- package/bin/cuttlely.js +4 -3
- package/lib/check.js +530 -0
- package/lib/cli.js +261 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,10 +41,65 @@ cuttlely --help
|
|
|
41
41
|
cuttlely --version
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
## Check a draft
|
|
45
|
+
|
|
46
|
+
`check` runs the Publish check on a flow's draft and waits for it. It reads the API key from `CUTTLELY_API_KEY` and calls the server you name. It does not start Docker.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export CUTTLELY_API_KEY=<key>
|
|
50
|
+
npx cuttlely check --flow <flow id> --server http://127.0.0.1:43117 --timeout 300 --json check.json --fail-on-changed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Flag | Meaning |
|
|
54
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `--flow` | Flow id from the canvas address bar. Required. |
|
|
56
|
+
| `--server` | Server origin. Default `http://127.0.0.1:43117`. |
|
|
57
|
+
| `--timeout` | Seconds to wait for the check. Default 300. The final confirmation read after a `--gate` pass has its own window of at most 30 seconds. |
|
|
58
|
+
| `--json` | Write the full check JSON to this path. |
|
|
59
|
+
| `--fail-on-changed` | Exit 1 when any answer is Changed. Without it, a finished check exits 0 and the summary still lists them. |
|
|
60
|
+
|
|
61
|
+
`pnpm dev` serves the API on port 3000. `scripts/start-cuttlely.sh` and `npx cuttlely start` listen on port 43117.
|
|
62
|
+
|
|
63
|
+
| Code | When |
|
|
64
|
+
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| 0 | The check finished. No answer was Changed, or `--fail-on-changed` was off. |
|
|
66
|
+
| 1 | `--fail-on-changed` was set and at least one answer was Changed. |
|
|
67
|
+
| 2 | The check timed out, the server returned an error, `CUTTLELY_API_KEY` was missing, the flow has no draft, or the command was used wrong. |
|
|
68
|
+
|
|
69
|
+
A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. Cancelling the command stops that server-side check too. That stop is best effort: if it fails, the exit code is still 2.
|
|
70
|
+
|
|
71
|
+
The key needs both permissions for the flow: `chatflows:view` and `chatflows:update`, or `agentflows:view` and `agentflows:update`. Update starts the check. View is what reading the result uses. The guide is [Fail a build when answers change](https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/ci-gate.md).
|
|
72
|
+
|
|
73
|
+
## Score a test set
|
|
74
|
+
|
|
75
|
+
`--dataset` or `--gate` asks a dataset instead of the four recent questions. The command exits on the server verdict. It does not score the answers itself.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx cuttlely check --flow <flow id> --dataset <dataset id> --evaluator <evaluator id> --threshold 80
|
|
79
|
+
npx cuttlely check --flow <flow id> --gate
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Flag | Meaning |
|
|
83
|
+
| ------------- | ----------------------------------------------------------------------------------------- |
|
|
84
|
+
| `--dataset` | Dataset id. Selects scored mode. |
|
|
85
|
+
| `--evaluator` | Evaluator id. Repeat for another evaluator. |
|
|
86
|
+
| `--threshold` | Pass mark from 0 to 100. Omit it and the server uses 100. |
|
|
87
|
+
| `--gate` | Dataset, evaluators, and threshold saved on the flow. Do not combine it with `--dataset`. |
|
|
88
|
+
|
|
89
|
+
Do not combine those flags with `--fail-on-changed`.
|
|
90
|
+
|
|
91
|
+
| Code | When |
|
|
92
|
+
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| 0 | The server verdict is passed. With `--gate`, a second read of the flow still has the passing test set on, no block, and this check's id stored as a passed result. |
|
|
94
|
+
| 1 | The server verdict is failed. |
|
|
95
|
+
| 2 | The same failures as the question check, a missing verdict, a usage error, a withheld dataset or evaluator id, or a `--gate` pass that is no longer the stored result. The message is the server's block text, or `The saved test set changed during the check. Run it again.` when there is none. |
|
|
96
|
+
|
|
97
|
+
A 403 names the flow view and update permissions, plus `datasets:view` and `evaluators:view`. The summary prints the pass rate, the threshold, and the cases whose draft scores are not Pass. `--json` writes the full check, including each score.
|
|
98
|
+
|
|
44
99
|
The container name is `cuttlely`. `start` leaves it running when it is already on the same image and port. `upgrade` always pulls and starts it again. The volume is `cuttlely-data`, mounted at `/var/cuttlely`.
|
|
45
100
|
|
|
46
101
|
Compose uses the volume name `cuttlely_cuttlely-data`. This launcher uses `cuttlely-data`.
|
|
47
102
|
|
|
48
103
|
The first account is created from the one-time setup link in `cuttlely logs`.
|
|
49
104
|
|
|
50
|
-
|
|
105
|
+
The start, stop, status, logs and upgrade commands make no network call of their own. Docker pulls the image, and start waits on the local health check. `check` calls the Cuttlely server named with `--server`.
|
package/bin/cuttlely.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { main } from '../lib/cli.js'
|
|
2
|
+
import { commandFailureCode, main } from '../lib/cli.js'
|
|
3
3
|
|
|
4
|
+
const argv = process.argv.slice(2)
|
|
4
5
|
try {
|
|
5
|
-
process.exitCode = await main(
|
|
6
|
+
process.exitCode = await main(argv)
|
|
6
7
|
} catch (error) {
|
|
7
8
|
console.error(error instanceof Error ? error.message : String(error))
|
|
8
|
-
process.exitCode =
|
|
9
|
+
process.exitCode = commandFailureCode(argv)
|
|
9
10
|
}
|
package/lib/check.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Runs the Publish check (POST /api/v1/flow-drafts/:id/check) and waits for it.
|
|
2
|
+
// Talks only to the server named with --server. The API key stays in the environment.
|
|
3
|
+
import { writeFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export const API_KEY_ENV = 'CUTTLELY_API_KEY'
|
|
6
|
+
export const DEFAULT_SERVER = 'http://127.0.0.1:43117'
|
|
7
|
+
export const DEFAULT_TIMEOUT_SECONDS = 300
|
|
8
|
+
export const DEFAULT_QUESTION_COUNT = 4
|
|
9
|
+
export const POLL_MS = 1000
|
|
10
|
+
|
|
11
|
+
export const EXIT_PASS = 0
|
|
12
|
+
export const EXIT_CHANGED = 1
|
|
13
|
+
export const EXIT_ERROR = 2
|
|
14
|
+
|
|
15
|
+
// Flow, dataset, and evaluator ids share this shape.
|
|
16
|
+
export const RESOURCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
|
|
17
|
+
|
|
18
|
+
const ANSWER_CLIP = 240
|
|
19
|
+
|
|
20
|
+
function redact(text, secret) {
|
|
21
|
+
if (!secret || typeof text !== 'string' || !text.includes(secret)) return text
|
|
22
|
+
return text.split(secret).join('[redacted]')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function redactValue(value, secret) {
|
|
26
|
+
if (typeof value === 'string') return redact(value, secret)
|
|
27
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, secret))
|
|
28
|
+
if (value && typeof value === 'object') {
|
|
29
|
+
const copy = {}
|
|
30
|
+
for (const [key, item] of Object.entries(value)) copy[key] = redactValue(item, secret)
|
|
31
|
+
return copy
|
|
32
|
+
}
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function oneLine(text) {
|
|
37
|
+
const flat = String(text ?? '')
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.trim()
|
|
40
|
+
if (!flat) return '(empty)'
|
|
41
|
+
if (flat.length <= ANSWER_CLIP) return flat
|
|
42
|
+
return `${flat.slice(0, ANSWER_CLIP - 3)}...`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatMs(value) {
|
|
46
|
+
return value == null ? 'n/a' : `${value} ms`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatUsd(value) {
|
|
50
|
+
const n = Number(value)
|
|
51
|
+
if (!Number.isFinite(n)) return ''
|
|
52
|
+
if (n === 0) return '$0'
|
|
53
|
+
if (Math.abs(n) >= 0.01) return `$${n.toFixed(2)}`
|
|
54
|
+
return `$${n.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function estimateLine(label, usd, price) {
|
|
58
|
+
if (price === 'no price') return `${label}: estimated cost no price`
|
|
59
|
+
if (price === 'partial') {
|
|
60
|
+
const money = formatUsd(usd)
|
|
61
|
+
return money ? `${label}: estimated ${money} (some models have no price)` : `${label}: estimated cost no price`
|
|
62
|
+
}
|
|
63
|
+
if (price === 'known') {
|
|
64
|
+
const money = formatUsd(usd)
|
|
65
|
+
return money ? `${label}: estimated ${money}` : ''
|
|
66
|
+
}
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function changedCases(check) {
|
|
71
|
+
return (check?.cases || []).filter((row) => row && row.same === false && row.live && row.draft && !row.live.error && !row.draft.error)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function errorCases(check) {
|
|
75
|
+
return (check?.cases || []).filter((row) => row && (row.live?.error || row.draft?.error))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function verdictLine(verdict) {
|
|
79
|
+
const rate = verdict.passRate == null ? 'no scored answers' : `${verdict.passRate}%`
|
|
80
|
+
const mark = verdict.threshold == null ? 'n/a' : `${verdict.threshold}%`
|
|
81
|
+
const passed = Number(verdict.passed ?? 0)
|
|
82
|
+
const failed = Number(verdict.failed ?? 0)
|
|
83
|
+
const errored = Number(verdict.errored ?? 0)
|
|
84
|
+
return `Pass rate: ${rate} (threshold ${mark}). ${passed} passed, ${failed} failed, ${errored} errored.`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function failingCases(check) {
|
|
88
|
+
return (check?.cases || []).filter((row) => {
|
|
89
|
+
const scores = row?.draft?.scores
|
|
90
|
+
if (!Array.isArray(scores) || !scores.length) return false
|
|
91
|
+
return scores.some((score) => score && score.result !== 'Pass')
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function failingLines(check) {
|
|
96
|
+
const rows = failingCases(check)
|
|
97
|
+
if (!rows.length) return []
|
|
98
|
+
const lines = ['', 'Failing']
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
lines.push(` ${oneLine(row.question)}`)
|
|
101
|
+
for (const score of row.draft.scores) {
|
|
102
|
+
if (!score || score.result === 'Pass') continue
|
|
103
|
+
lines.push(` ${score.name ? `${score.name}: ${score.result}` : score.result}`)
|
|
104
|
+
}
|
|
105
|
+
if (row.expected) lines.push(` Expected: ${oneLine(row.expected)}`)
|
|
106
|
+
if (row.draft?.text) lines.push(` Draft: ${oneLine(row.draft.text)}`)
|
|
107
|
+
else if (row.draft?.error) lines.push(` ${oneLine(row.draft.error)}`)
|
|
108
|
+
}
|
|
109
|
+
return lines
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function summaryText(check) {
|
|
113
|
+
const summary = check.summary || {}
|
|
114
|
+
const lines = [
|
|
115
|
+
`Check ${check.id} ${check.status}`,
|
|
116
|
+
`Questions: ${summary.ran ?? 0} ran, ${summary.same ?? 0} same, ${summary.changed ?? 0} changed, ${summary.errors ?? 0} errors`
|
|
117
|
+
]
|
|
118
|
+
// Present only when the server finished a dataset check. Diff mode has no verdict.
|
|
119
|
+
if (check.verdict && typeof check.verdict === 'object') lines.push(verdictLine(check.verdict))
|
|
120
|
+
lines.push(
|
|
121
|
+
`Live: ${summary.liveTokens ?? 0} tokens, median ${formatMs(summary.liveMedianMs)}`,
|
|
122
|
+
`Draft: ${summary.draftTokens ?? 0} tokens, median ${formatMs(summary.draftMedianMs)}`
|
|
123
|
+
)
|
|
124
|
+
// The check total, with each model priced once. It is not the sum of the per-answer figures.
|
|
125
|
+
const liveEstimate = estimateLine('Live', summary.liveEstimatedCostUsd, summary.livePrice)
|
|
126
|
+
const draftEstimate = estimateLine('Draft', summary.draftEstimatedCostUsd, summary.draftPrice)
|
|
127
|
+
if (liveEstimate) lines.push(liveEstimate)
|
|
128
|
+
if (draftEstimate) lines.push(draftEstimate)
|
|
129
|
+
if (check.liveEmpty) lines.push('Live has no cards, so only the draft was asked.')
|
|
130
|
+
if (check.stopReason) lines.push(`Stopped: ${check.stopReason}`)
|
|
131
|
+
if (check.error) lines.push(`Error: ${check.error}`)
|
|
132
|
+
const changed = changedCases(check)
|
|
133
|
+
if (changed.length) {
|
|
134
|
+
lines.push('', 'Changed')
|
|
135
|
+
for (const row of changed) {
|
|
136
|
+
lines.push(` ${oneLine(row.question)}`)
|
|
137
|
+
lines.push(` Live: ${oneLine(row.live.text)}`)
|
|
138
|
+
lines.push(` Draft: ${oneLine(row.draft.text)}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const errors = errorCases(check)
|
|
142
|
+
if (errors.length) {
|
|
143
|
+
lines.push('', 'Errors')
|
|
144
|
+
for (const row of errors) {
|
|
145
|
+
const why = [row.live?.error, row.draft?.error].filter(Boolean).join('; ')
|
|
146
|
+
lines.push(` ${oneLine(row.question)}`)
|
|
147
|
+
lines.push(` ${oneLine(why)}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (check.verdict && typeof check.verdict === 'object') lines.push(...failingLines(check))
|
|
151
|
+
return lines.join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Replay is the question check. Scored mode reads the server verdict and does not rescore.
|
|
155
|
+
export function replayVerdict(check, { failOnChanged }) {
|
|
156
|
+
if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
|
|
157
|
+
if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
|
|
158
|
+
if (check.status === 'stopped') return { code: EXIT_ERROR, label: 'stopped' }
|
|
159
|
+
if (check.status !== 'done') return { code: EXIT_ERROR, label: 'incomplete' }
|
|
160
|
+
if (check.draftChanged) return { code: EXIT_ERROR, label: 'draft-changed' }
|
|
161
|
+
const changed = Number(check.summary?.changed ?? 0)
|
|
162
|
+
if (!Number.isFinite(changed)) return { code: EXIT_ERROR, label: 'error' }
|
|
163
|
+
if (failOnChanged && changed > 0) return { code: EXIT_CHANGED, label: 'changed' }
|
|
164
|
+
return { code: EXIT_PASS, label: 'pass' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Exit 1 is the server's failed verdict. A missing verdict is an error, not a guess from the pass rate.
|
|
168
|
+
export function scoredVerdict(check) {
|
|
169
|
+
if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
|
|
170
|
+
if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
|
|
171
|
+
if (check.status === 'stopped') return { code: EXIT_ERROR, label: 'stopped' }
|
|
172
|
+
if (check.status !== 'done') return { code: EXIT_ERROR, label: 'incomplete' }
|
|
173
|
+
if (check.draftChanged) return { code: EXIT_ERROR, label: 'draft-changed' }
|
|
174
|
+
const result = check.verdict && check.verdict.result
|
|
175
|
+
if (result === 'passed') return { code: EXIT_PASS, label: 'passed' }
|
|
176
|
+
if (result === 'failed') return { code: EXIT_CHANGED, label: 'score-failed' }
|
|
177
|
+
return { code: EXIT_ERROR, label: 'no-verdict' }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function selectVerdict(check, options) {
|
|
181
|
+
if (options && options.scored) return scoredVerdict(check)
|
|
182
|
+
return replayVerdict(check, options)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function deadlineSignal(deadline, now) {
|
|
186
|
+
const remaining = Math.max(1, deadline - now())
|
|
187
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(remaining)
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function callApi(fetchImpl, url, { method, headers, body, signal }) {
|
|
192
|
+
let response
|
|
193
|
+
try {
|
|
194
|
+
response = await fetchImpl(url, { method, headers, body, redirect: 'manual', signal })
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const name = error && error.name
|
|
197
|
+
if (name === 'AbortError' || name === 'TimeoutError') return { timeout: true }
|
|
198
|
+
return { network: true, message: error instanceof Error ? error.message : String(error) }
|
|
199
|
+
}
|
|
200
|
+
const status = Number(response.status)
|
|
201
|
+
if (status >= 300 && status < 400) {
|
|
202
|
+
return { ok: false, status, message: 'The server sent a redirect, which this command will not follow.' }
|
|
203
|
+
}
|
|
204
|
+
let text = ''
|
|
205
|
+
try {
|
|
206
|
+
text = typeof response.text === 'function' ? await response.text() : ''
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return { network: true, message: error instanceof Error ? error.message : String(error) }
|
|
209
|
+
}
|
|
210
|
+
let json = null
|
|
211
|
+
if (text) {
|
|
212
|
+
try {
|
|
213
|
+
json = JSON.parse(text)
|
|
214
|
+
} catch {
|
|
215
|
+
json = null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const raw = json && (json.message || json.error)
|
|
219
|
+
const message = typeof raw === 'string' && raw ? raw : text ? text.slice(0, 300) : `The server returned HTTP ${status}.`
|
|
220
|
+
return { ok: status >= 200 && status < 300, status, message, json }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isNoDraft(result) {
|
|
224
|
+
return result.status === 409 && typeof result.message === 'string' && /no unpublished changes/i.test(result.message)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Starting a check needs update. Reading it needs view. A key with only one of those gets 403,
|
|
228
|
+
// and the server message does not say which permission is missing.
|
|
229
|
+
function permissionDeniedMessage(scored) {
|
|
230
|
+
const flow = 'The API key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.'
|
|
231
|
+
if (!scored) return flow
|
|
232
|
+
return `${flow} Scored mode also needs datasets:view and evaluators:view.`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function scoreFailedLine(verdict) {
|
|
236
|
+
const rate = !verdict || verdict.passRate == null ? 'no scored answers' : `${verdict.passRate}%`
|
|
237
|
+
const mark = !verdict || verdict.threshold == null ? 'n/a' : `${verdict.threshold}%`
|
|
238
|
+
return `The test set failed at ${rate} (threshold ${mark}).`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const STALE_GATE = 'The saved test set changed during the check. Run it again.'
|
|
242
|
+
// The check can use the whole --timeout. The confirmation read gets its own window, capped so a hung read cannot double a 3600s wait.
|
|
243
|
+
const CONFIRM_READ_CAP_SECONDS = 30
|
|
244
|
+
|
|
245
|
+
function confirmReadSeconds(timeoutSeconds) {
|
|
246
|
+
return Math.min(timeoutSeconds, CONFIRM_READ_CAP_SECONDS)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// An enabled gate always stores a dataset and at least one evaluator. A read that
|
|
250
|
+
// omits them withheld the ids: datasets:view and evaluators:view are what reveal them.
|
|
251
|
+
function withheldIdsMessage(datasetMissing, evaluatorsMissing) {
|
|
252
|
+
if (datasetMissing && evaluatorsMissing) {
|
|
253
|
+
return 'The response did not include the dataset id or the evaluator ids. The API key needs datasets:view and evaluators:view.'
|
|
254
|
+
}
|
|
255
|
+
if (datasetMissing) return 'The response did not include the dataset id. The API key needs datasets:view.'
|
|
256
|
+
return 'The response did not include the evaluator ids. The API key needs evaluators:view.'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function datasetIdMissing(gate) {
|
|
260
|
+
return typeof gate.datasetId !== 'string' || gate.datasetId.trim() === ''
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function evaluatorIdsMissing(gate) {
|
|
264
|
+
return !Array.isArray(gate.evaluatorIds) || gate.evaluatorIds.length === 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --gate copies the dataset, evaluators, and threshold the flow already saved.
|
|
268
|
+
function gateSettings(body) {
|
|
269
|
+
const gate = body && body.publishGate
|
|
270
|
+
if (!gate || gate.enabled !== true) {
|
|
271
|
+
return { ok: false, message: 'This flow has no saved test set. Turn the passing test set on, or pass --dataset.' }
|
|
272
|
+
}
|
|
273
|
+
const datasetMissing = datasetIdMissing(gate)
|
|
274
|
+
const evaluatorsMissing = evaluatorIdsMissing(gate)
|
|
275
|
+
if (datasetMissing || evaluatorsMissing) return { ok: false, message: withheldIdsMessage(datasetMissing, evaluatorsMissing) }
|
|
276
|
+
if (!RESOURCE_ID_RE.test(gate.datasetId)) {
|
|
277
|
+
return { ok: false, message: 'The saved test set has no dataset id.' }
|
|
278
|
+
}
|
|
279
|
+
const evaluatorIds = []
|
|
280
|
+
const seen = new Set()
|
|
281
|
+
for (const id of gate.evaluatorIds) {
|
|
282
|
+
if (typeof id !== 'string' || !RESOURCE_ID_RE.test(id)) {
|
|
283
|
+
return { ok: false, message: 'The saved test set has an evaluator id this command cannot use.' }
|
|
284
|
+
}
|
|
285
|
+
if (seen.has(id)) continue
|
|
286
|
+
seen.add(id)
|
|
287
|
+
evaluatorIds.push(id)
|
|
288
|
+
}
|
|
289
|
+
let threshold = null
|
|
290
|
+
if (gate.threshold != null) {
|
|
291
|
+
if (typeof gate.threshold !== 'number' || !Number.isInteger(gate.threshold) || gate.threshold < 0 || gate.threshold > 100) {
|
|
292
|
+
return { ok: false, message: 'The saved pass mark is not a whole number from 0 to 100.' }
|
|
293
|
+
}
|
|
294
|
+
threshold = gate.threshold
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, datasetId: gate.datasetId, evaluatorIds, threshold }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A passed --gate check is current only when Publish would still accept this run.
|
|
300
|
+
function gateBlockMessage(gate) {
|
|
301
|
+
if (gate && typeof gate.block === 'string' && gate.block.trim()) return gate.block
|
|
302
|
+
return STALE_GATE
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function gatePassStillCurrent(body, checkId) {
|
|
306
|
+
const gate = body && body.publishGate
|
|
307
|
+
const message = gateBlockMessage(gate)
|
|
308
|
+
if (!gate || gate.enabled !== true) return { ok: false, message }
|
|
309
|
+
if (typeof gate.block === 'string' && gate.block.trim()) return { ok: false, message }
|
|
310
|
+
const result = gate.result
|
|
311
|
+
if (!result || result.checkId !== checkId || result.verdict !== 'passed') return { ok: false, message }
|
|
312
|
+
return { ok: true }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function requestBody({ scored, datasetId, evaluatorIds, threshold }) {
|
|
316
|
+
if (!scored) return { count: DEFAULT_QUESTION_COUNT }
|
|
317
|
+
const body = { datasetId }
|
|
318
|
+
if (evaluatorIds && evaluatorIds.length) body.evaluatorIds = evaluatorIds
|
|
319
|
+
if (threshold != null) body.threshold = threshold
|
|
320
|
+
return body
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function writeResult(writeFile, jsonPath, check, secret) {
|
|
324
|
+
if (!jsonPath || !check) return
|
|
325
|
+
const safe = redactValue(check, secret)
|
|
326
|
+
writeFile(jsonPath, `${JSON.stringify(safe, null, 4)}\n`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function stopCheck(fetchImpl, url, headers) {
|
|
330
|
+
try {
|
|
331
|
+
const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined
|
|
332
|
+
await fetchImpl(url, { method: 'DELETE', headers, redirect: 'manual', signal })
|
|
333
|
+
} catch {
|
|
334
|
+
// The timeout result stands either way. A stuck stop must not hide it.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function runCheck(options, io = {}) {
|
|
339
|
+
const out = io.out || ((line) => console.info(line))
|
|
340
|
+
const err = io.err || ((line) => console.error(line))
|
|
341
|
+
const env = io.env || process.env
|
|
342
|
+
const fetchImpl = io.fetch || globalThis.fetch.bind(globalThis)
|
|
343
|
+
const sleep = io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
344
|
+
const now = io.now || Date.now
|
|
345
|
+
const writeFile = io.writeFile || ((file, text) => writeFileSync(file, text))
|
|
346
|
+
const secret = typeof env[API_KEY_ENV] === 'string' ? env[API_KEY_ENV].trim() : ''
|
|
347
|
+
if (!secret) {
|
|
348
|
+
err(`${API_KEY_ENV} is not set.`)
|
|
349
|
+
return EXIT_ERROR
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const server = options.server || DEFAULT_SERVER
|
|
353
|
+
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS
|
|
354
|
+
const flow = options.flow
|
|
355
|
+
const failOnChanged = Boolean(options.failOnChanged)
|
|
356
|
+
const scored = Boolean(options.scored || options.gate)
|
|
357
|
+
let datasetId = options.datasetId
|
|
358
|
+
let evaluatorIds = options.evaluatorIds || []
|
|
359
|
+
let threshold = options.threshold ?? null
|
|
360
|
+
const flowUrl = `${server}/api/v1/flow-drafts/${encodeURIComponent(flow)}`
|
|
361
|
+
const base = `${flowUrl}/check`
|
|
362
|
+
const headers = { Authorization: `Bearer ${secret}`, Accept: 'application/json' }
|
|
363
|
+
const deadline = now() + timeoutSeconds * 1000
|
|
364
|
+
|
|
365
|
+
const say = (text) => redact(text, secret)
|
|
366
|
+
|
|
367
|
+
const fail = (text) => {
|
|
368
|
+
err(say(text))
|
|
369
|
+
return EXIT_ERROR
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Set once the server has accepted the check. Every later failure tries to stop it.
|
|
373
|
+
let check = null
|
|
374
|
+
const stopIfStarted = async () => {
|
|
375
|
+
if (!check?.id) return
|
|
376
|
+
await stopCheck(fetchImpl, `${base}/${encodeURIComponent(check.id)}`, headers)
|
|
377
|
+
}
|
|
378
|
+
// The stop is best effort. A failed DELETE does not replace the exit code.
|
|
379
|
+
const failRunning = async (text) => {
|
|
380
|
+
await stopIfStarted()
|
|
381
|
+
return fail(text)
|
|
382
|
+
}
|
|
383
|
+
// Signals are armed only after the id is known, and removed on a normal return so a
|
|
384
|
+
// finished check is left alone. process.exit skips the finally, so the handler disarms first.
|
|
385
|
+
const signalTarget = io.signals || process
|
|
386
|
+
let signalExit = false
|
|
387
|
+
const onSignal = () => {
|
|
388
|
+
if (signalExit) return
|
|
389
|
+
signalExit = true
|
|
390
|
+
disarmSignals()
|
|
391
|
+
void (async () => {
|
|
392
|
+
await stopIfStarted()
|
|
393
|
+
err('The check was cancelled.')
|
|
394
|
+
const exit = io.exit || ((code) => process.exit(code))
|
|
395
|
+
exit(EXIT_ERROR)
|
|
396
|
+
})()
|
|
397
|
+
}
|
|
398
|
+
const disarmSignals = () => {
|
|
399
|
+
signalTarget.removeListener('SIGTERM', onSignal)
|
|
400
|
+
signalTarget.removeListener('SIGINT', onSignal)
|
|
401
|
+
}
|
|
402
|
+
const armSignals = () => {
|
|
403
|
+
signalTarget.on('SIGTERM', onSignal)
|
|
404
|
+
signalTarget.on('SIGINT', onSignal)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
if (options.gate) {
|
|
409
|
+
const loaded = await callApi(fetchImpl, flowUrl, {
|
|
410
|
+
method: 'GET',
|
|
411
|
+
headers,
|
|
412
|
+
signal: deadlineSignal(deadline, now)
|
|
413
|
+
})
|
|
414
|
+
if (loaded.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
|
|
415
|
+
if (loaded.network) return fail(`Could not reach ${server}. ${loaded.message}`)
|
|
416
|
+
if (!loaded.ok) {
|
|
417
|
+
if (loaded.status === 403) return fail(permissionDeniedMessage(true))
|
|
418
|
+
return fail(`Check failed (${loaded.status}): ${loaded.message}`)
|
|
419
|
+
}
|
|
420
|
+
const parsed = gateSettings(loaded.json)
|
|
421
|
+
if (!parsed.ok) return fail(parsed.message)
|
|
422
|
+
datasetId = parsed.datasetId
|
|
423
|
+
evaluatorIds = parsed.evaluatorIds
|
|
424
|
+
threshold = parsed.threshold
|
|
425
|
+
}
|
|
426
|
+
const started = await callApi(fetchImpl, base, {
|
|
427
|
+
method: 'POST',
|
|
428
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
429
|
+
body: JSON.stringify(requestBody({ scored, datasetId, evaluatorIds, threshold })),
|
|
430
|
+
signal: deadlineSignal(deadline, now)
|
|
431
|
+
})
|
|
432
|
+
if (started.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
|
|
433
|
+
if (started.network) return fail(`Could not reach ${server}. ${started.message}`)
|
|
434
|
+
if (!started.ok) {
|
|
435
|
+
if (started.status === 403) return fail(permissionDeniedMessage(scored))
|
|
436
|
+
if (isNoDraft(started)) return fail(`No draft to check. ${started.message}`)
|
|
437
|
+
return fail(`Check failed (${started.status}): ${started.message}`)
|
|
438
|
+
}
|
|
439
|
+
check = started.json
|
|
440
|
+
if (!check || typeof check !== 'object' || !check.id) return fail('The server did not return a check.')
|
|
441
|
+
armSignals()
|
|
442
|
+
out(`Started check ${check.id} for flow ${flow} (${check.total ?? 0} questions).`)
|
|
443
|
+
|
|
444
|
+
const giveUp = async () => {
|
|
445
|
+
await stopIfStarted()
|
|
446
|
+
try {
|
|
447
|
+
writeResult(writeFile, options.jsonPath, check, secret)
|
|
448
|
+
} catch (error) {
|
|
449
|
+
err(say(`Could not write ${options.jsonPath}. ${error instanceof Error ? error.message : String(error)}`))
|
|
450
|
+
}
|
|
451
|
+
return fail(`Timed out after ${timeoutSeconds}s waiting for check ${check.id}.`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
while (check.status === 'running') {
|
|
455
|
+
// Read before sleeping, and once more at the deadline, so a check that
|
|
456
|
+
// finishes in the last poll interval is not reported from a stale body.
|
|
457
|
+
const polled = await callApi(fetchImpl, `${base}/${encodeURIComponent(check.id)}`, {
|
|
458
|
+
method: 'GET',
|
|
459
|
+
headers,
|
|
460
|
+
signal: deadlineSignal(deadline + POLL_MS, now)
|
|
461
|
+
})
|
|
462
|
+
if (polled.timeout) return giveUp()
|
|
463
|
+
if (polled.network) return failRunning(`Could not reach ${server}. ${polled.message}`)
|
|
464
|
+
if (polled.status === 403) return failRunning(permissionDeniedMessage(scored))
|
|
465
|
+
if (!polled.ok) return failRunning(`Check failed (${polled.status}): ${polled.message}`)
|
|
466
|
+
if (!polled.json || typeof polled.json !== 'object') return failRunning('The server did not return a check.')
|
|
467
|
+
const previous = check.completed
|
|
468
|
+
check = polled.json
|
|
469
|
+
if (check.status === 'running' && check.completed !== previous && check.completed != null) {
|
|
470
|
+
out(`Asked ${check.completed} of ${check.total}.`)
|
|
471
|
+
}
|
|
472
|
+
if (check.status !== 'running') break
|
|
473
|
+
const remaining = deadline - now()
|
|
474
|
+
if (remaining <= 0) return giveUp()
|
|
475
|
+
await sleep(Math.min(POLL_MS, remaining))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
writeResult(writeFile, options.jsonPath, check, secret)
|
|
480
|
+
} catch (error) {
|
|
481
|
+
err(say(`Could not write ${options.jsonPath}. ${error instanceof Error ? error.message : String(error)}`))
|
|
482
|
+
out(say(summaryText(check)))
|
|
483
|
+
await stopIfStarted()
|
|
484
|
+
return EXIT_ERROR
|
|
485
|
+
}
|
|
486
|
+
out(say(summaryText(check)))
|
|
487
|
+
const verdict = selectVerdict(check, { failOnChanged, scored })
|
|
488
|
+
if (verdict.label === 'draft-changed') {
|
|
489
|
+
err('The draft changed while the check was running, so the result is out of date.')
|
|
490
|
+
} else if (verdict.label === 'failed') {
|
|
491
|
+
err(say(check.error ? `The check failed. ${check.error}` : 'The check failed.'))
|
|
492
|
+
} else if (verdict.label === 'stopped') {
|
|
493
|
+
err(`The check stopped${check.stopReason ? ` (${check.stopReason})` : ''} before it finished.`)
|
|
494
|
+
} else if (verdict.label === 'changed') {
|
|
495
|
+
const count = Number(check.summary?.changed ?? 0)
|
|
496
|
+
err(count === 1 ? '1 answer changed.' : `${count} answers changed.`)
|
|
497
|
+
} else if (verdict.label === 'score-failed') {
|
|
498
|
+
err(scoreFailedLine(check.verdict))
|
|
499
|
+
} else if (verdict.label === 'no-verdict') {
|
|
500
|
+
err('The server did not return a verdict for this test set.')
|
|
501
|
+
} else if (verdict.code === EXIT_ERROR) {
|
|
502
|
+
err('The check did not finish.')
|
|
503
|
+
}
|
|
504
|
+
if (verdict.code === EXIT_ERROR) await stopIfStarted()
|
|
505
|
+
// --gate can exit 0 only when this run is still the stored pass. A threshold,
|
|
506
|
+
// row, or evaluator change during the check makes Publish refuse it.
|
|
507
|
+
if (options.gate && verdict.code === EXIT_PASS) {
|
|
508
|
+
const confirmSeconds = confirmReadSeconds(timeoutSeconds)
|
|
509
|
+
const again = await callApi(fetchImpl, flowUrl, {
|
|
510
|
+
method: 'GET',
|
|
511
|
+
headers,
|
|
512
|
+
signal: deadlineSignal(now() + confirmSeconds * 1000, now)
|
|
513
|
+
})
|
|
514
|
+
if (again.timeout) return fail(`Timed out after ${confirmSeconds}s before the saved test set could be read again.`)
|
|
515
|
+
if (again.network) return fail(`Could not reach ${server}. ${again.message}`)
|
|
516
|
+
if (!again.ok) {
|
|
517
|
+
if (again.status === 403) return fail(permissionDeniedMessage(true))
|
|
518
|
+
return fail(`Check failed (${again.status}): ${again.message}`)
|
|
519
|
+
}
|
|
520
|
+
const current = gatePassStillCurrent(again.json, check.id)
|
|
521
|
+
if (!current.ok) return fail(current.message)
|
|
522
|
+
}
|
|
523
|
+
return verdict.code
|
|
524
|
+
} catch (error) {
|
|
525
|
+
await stopIfStarted()
|
|
526
|
+
return fail(error instanceof Error ? error.message : String(error))
|
|
527
|
+
} finally {
|
|
528
|
+
if (!signalExit) disarmSignals()
|
|
529
|
+
}
|
|
530
|
+
}
|
package/lib/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync, statSync } from 'node:fs'
|
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { RESOURCE_ID_RE, runCheck } from './check.js'
|
|
7
8
|
import { spawnDocker } from './docker.js'
|
|
8
9
|
|
|
9
10
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
@@ -20,7 +21,12 @@ export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
|
|
|
20
21
|
const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
|
|
21
22
|
const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
|
|
22
23
|
|
|
23
|
-
export class UsageError extends Error {
|
|
24
|
+
export class UsageError extends Error {
|
|
25
|
+
constructor(message, exitCode = 1) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.exitCode = exitCode
|
|
28
|
+
}
|
|
29
|
+
}
|
|
24
30
|
|
|
25
31
|
export function helpText() {
|
|
26
32
|
return [
|
|
@@ -32,6 +38,9 @@ export function helpText() {
|
|
|
32
38
|
' cuttlely status',
|
|
33
39
|
' cuttlely logs [--follow]',
|
|
34
40
|
' cuttlely upgrade [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
|
|
41
|
+
' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] [--fail-on-changed]',
|
|
42
|
+
' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] --dataset <id> [--evaluator <id> ...] [--threshold <0-100>]',
|
|
43
|
+
' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] --gate',
|
|
35
44
|
' cuttlely --help',
|
|
36
45
|
' cuttlely --version',
|
|
37
46
|
'',
|
|
@@ -45,30 +54,124 @@ export function helpText() {
|
|
|
45
54
|
' status Show whether Cuttlely is running.',
|
|
46
55
|
' logs Show container logs. --follow keeps streaming.',
|
|
47
56
|
' upgrade Pull the image and start it again.',
|
|
57
|
+
' check Run the Publish check on a flow draft. Reads CUTTLELY_API_KEY.',
|
|
58
|
+
' The key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.',
|
|
59
|
+
' --dataset or --gate also needs datasets:view and evaluators:view.',
|
|
60
|
+
'',
|
|
61
|
+
'Exit codes for check:',
|
|
62
|
+
' 0 The check finished. No answer was Changed, or --fail-on-changed was off. A scored check exits 0 when the server verdict is passed.',
|
|
63
|
+
' 1 --fail-on-changed was set and at least one answer was Changed. A scored check exits 1 when the server verdict is failed.',
|
|
64
|
+
' 2 The check timed out, the server returned an error, CUTTLELY_API_KEY was missing, the flow has no draft, or the command was used wrong.',
|
|
65
|
+
'A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. That stop is best effort: if it fails, the exit code is still 2.',
|
|
66
|
+
'Cancelling the command stops the server-side check.',
|
|
67
|
+
'A scored check (--dataset or --gate) exits on the server verdict and does not score the answers itself. --gate reads the dataset, evaluators, and threshold saved on the flow, and reads the flow again after a pass. Exit 0 only when that second read still stores this check as passed with no block. Otherwise exit 2. Do not combine --gate with --dataset, or those flags with --fail-on-changed.',
|
|
48
68
|
'',
|
|
49
69
|
`Install guide: ${INSTALL_DOCS}`
|
|
50
70
|
].join('\n')
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
function parsePort(value) {
|
|
73
|
+
function parsePort(value, exitCode = 1) {
|
|
54
74
|
if (!value || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) {
|
|
55
|
-
throw new UsageError(`Port must be a number from 1 to 65535, got ${value || '(missing)'}
|
|
75
|
+
throw new UsageError(`Port must be a number from 1 to 65535, got ${value || '(missing)'}.`, exitCode)
|
|
56
76
|
}
|
|
57
77
|
return Number(value)
|
|
58
78
|
}
|
|
59
79
|
|
|
60
|
-
function parseTag(value) {
|
|
80
|
+
function parseTag(value, exitCode = 1) {
|
|
61
81
|
if (!value || !TAG_RE.test(value)) {
|
|
62
|
-
throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest
|
|
82
|
+
throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest.`, exitCode)
|
|
83
|
+
}
|
|
84
|
+
return value
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// These options take the next argv token as their value. A following `check` is that value, not the command.
|
|
88
|
+
// Any other previous token, including an unknown flag, leaves `check` as the command.
|
|
89
|
+
const VALUE_OPTIONS = new Set([
|
|
90
|
+
'--flow',
|
|
91
|
+
'--server',
|
|
92
|
+
'--timeout',
|
|
93
|
+
'--json',
|
|
94
|
+
'--dataset',
|
|
95
|
+
'--evaluator',
|
|
96
|
+
'--threshold',
|
|
97
|
+
'--port',
|
|
98
|
+
'-p',
|
|
99
|
+
'--tag',
|
|
100
|
+
'--env-file'
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
export function isCheckCommand(argv) {
|
|
104
|
+
return argv.some((item, index) => {
|
|
105
|
+
if (item !== 'check') return false
|
|
106
|
+
if (index === 0) return true
|
|
107
|
+
return !VALUE_OPTIONS.has(argv[index - 1])
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// An unexpected failure while running check is not a Changed answer.
|
|
112
|
+
export function commandFailureCode(argv) {
|
|
113
|
+
return isCheckCommand(argv) ? 2 : 1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseFlow(value) {
|
|
117
|
+
if (!value || !RESOURCE_ID_RE.test(value)) {
|
|
118
|
+
throw new UsageError('--flow needs the flow id from the canvas address bar.', 2)
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseResourceId(value, flag) {
|
|
124
|
+
if (!value || !RESOURCE_ID_RE.test(value)) {
|
|
125
|
+
throw new UsageError(`${flag} needs ${flag === '--dataset' ? 'a dataset id' : 'an evaluator id'}.`, 2)
|
|
126
|
+
}
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseThreshold(value) {
|
|
131
|
+
if (!/^(?:0|[1-9]\d?|100)$/.test(value || '')) {
|
|
132
|
+
throw new UsageError(`--threshold must be a whole number from 0 to 100, got ${value || '(missing)'}.`, 2)
|
|
133
|
+
}
|
|
134
|
+
return Number(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseServer(value) {
|
|
138
|
+
let url
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(value)
|
|
141
|
+
} catch {
|
|
142
|
+
throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', 2)
|
|
143
|
+
}
|
|
144
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
145
|
+
throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', 2)
|
|
146
|
+
}
|
|
147
|
+
if (url.username || url.password) {
|
|
148
|
+
throw new UsageError(`Put the API key in CUTTLELY_API_KEY, not in the server URL.`, 2)
|
|
63
149
|
}
|
|
150
|
+
if (url.pathname !== '/' && url.pathname !== '') {
|
|
151
|
+
throw new UsageError('--server is the server origin, for example http://127.0.0.1:43117.', 2)
|
|
152
|
+
}
|
|
153
|
+
return url.origin
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseTimeout(value) {
|
|
157
|
+
if (!value || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 3600) {
|
|
158
|
+
throw new UsageError(`Timeout must be a whole number of seconds from 1 to 3600, got ${value || '(missing)'}.`, 2)
|
|
159
|
+
}
|
|
160
|
+
return Number(value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseJsonPath(value) {
|
|
164
|
+
if (!value || value.includes('\0')) throw new UsageError('--json needs a file path.', 2)
|
|
64
165
|
return value
|
|
65
166
|
}
|
|
66
167
|
|
|
67
|
-
function takeValue(argv, index, flag) {
|
|
168
|
+
function takeValue(argv, index, flag, allowSigned = false) {
|
|
68
169
|
const inline = argv[index].indexOf('=')
|
|
69
170
|
if (inline !== -1) return { value: argv[index].slice(inline + 1), next: index }
|
|
70
171
|
const value = argv[index + 1]
|
|
71
|
-
if (!value || value.startsWith('-'))
|
|
172
|
+
if (!value || (!allowSigned && value.startsWith('-'))) {
|
|
173
|
+
throw new UsageError(`${flag} needs a value. Run cuttlely --help.`, commandFailureCode(argv))
|
|
174
|
+
}
|
|
72
175
|
return { value, next: index + 1 }
|
|
73
176
|
}
|
|
74
177
|
|
|
@@ -81,37 +184,156 @@ export function parseArgs(argv) {
|
|
|
81
184
|
tag: VERSION,
|
|
82
185
|
envFile: null,
|
|
83
186
|
open: true,
|
|
84
|
-
follow: false
|
|
187
|
+
follow: false,
|
|
188
|
+
flow: null,
|
|
189
|
+
server: null,
|
|
190
|
+
timeoutSeconds: null,
|
|
191
|
+
jsonPath: null,
|
|
192
|
+
failOnChanged: false,
|
|
193
|
+
datasetId: null,
|
|
194
|
+
evaluatorIds: [],
|
|
195
|
+
threshold: null,
|
|
196
|
+
gate: false,
|
|
197
|
+
scored: false
|
|
85
198
|
}
|
|
86
199
|
const words = []
|
|
200
|
+
const dockerFlags = []
|
|
201
|
+
const checkFlags = []
|
|
87
202
|
for (let i = 0; i < argv.length; i++) {
|
|
88
203
|
const arg = argv[i]
|
|
89
204
|
if (arg === '--help' || arg === '-h') opts.help = true
|
|
90
205
|
else if (arg === '--version' || arg === '-V') opts.version = true
|
|
91
|
-
else if (arg === '--no-open')
|
|
92
|
-
|
|
93
|
-
|
|
206
|
+
else if (arg === '--no-open') {
|
|
207
|
+
opts.open = false
|
|
208
|
+
dockerFlags.push('--no-open')
|
|
209
|
+
} else if (arg === '--follow' || arg === '-f') {
|
|
210
|
+
opts.follow = true
|
|
211
|
+
dockerFlags.push(arg === '-f' ? '-f' : '--follow')
|
|
212
|
+
} else if (arg === '--port' || arg === '-p' || arg.startsWith('--port=')) {
|
|
94
213
|
const taken = takeValue(argv, i, '--port')
|
|
95
|
-
opts.port = parsePort(taken.value)
|
|
214
|
+
opts.port = parsePort(taken.value, commandFailureCode(argv))
|
|
96
215
|
i = taken.next
|
|
216
|
+
dockerFlags.push('--port')
|
|
97
217
|
} else if (arg === '--tag' || arg.startsWith('--tag=')) {
|
|
98
218
|
const taken = takeValue(argv, i, '--tag')
|
|
99
|
-
opts.tag = parseTag(taken.value)
|
|
219
|
+
opts.tag = parseTag(taken.value, commandFailureCode(argv))
|
|
100
220
|
i = taken.next
|
|
221
|
+
dockerFlags.push('--tag')
|
|
101
222
|
} else if (arg === '--env-file' || arg.startsWith('--env-file=')) {
|
|
102
223
|
const taken = takeValue(argv, i, '--env-file')
|
|
103
224
|
opts.envFile = taken.value
|
|
104
225
|
i = taken.next
|
|
105
|
-
|
|
106
|
-
else
|
|
226
|
+
dockerFlags.push('--env-file')
|
|
227
|
+
} else if (arg === '--flow' || arg.startsWith('--flow=')) {
|
|
228
|
+
const taken = takeValue(argv, i, '--flow')
|
|
229
|
+
opts.flow = taken.value
|
|
230
|
+
i = taken.next
|
|
231
|
+
checkFlags.push('--flow')
|
|
232
|
+
} else if (arg === '--server' || arg.startsWith('--server=')) {
|
|
233
|
+
const taken = takeValue(argv, i, '--server')
|
|
234
|
+
opts.server = taken.value
|
|
235
|
+
i = taken.next
|
|
236
|
+
checkFlags.push('--server')
|
|
237
|
+
} else if (arg === '--timeout' || arg.startsWith('--timeout=')) {
|
|
238
|
+
const taken = takeValue(argv, i, '--timeout')
|
|
239
|
+
opts.timeoutSeconds = taken.value
|
|
240
|
+
i = taken.next
|
|
241
|
+
checkFlags.push('--timeout')
|
|
242
|
+
} else if (arg === '--json' || arg.startsWith('--json=')) {
|
|
243
|
+
const taken = takeValue(argv, i, '--json')
|
|
244
|
+
opts.jsonPath = taken.value
|
|
245
|
+
i = taken.next
|
|
246
|
+
checkFlags.push('--json')
|
|
247
|
+
} else if (arg === '--fail-on-changed') {
|
|
248
|
+
opts.failOnChanged = true
|
|
249
|
+
checkFlags.push('--fail-on-changed')
|
|
250
|
+
} else if (arg === '--dataset' || arg.startsWith('--dataset=')) {
|
|
251
|
+
const taken = takeValue(argv, i, '--dataset')
|
|
252
|
+
opts.datasetId = taken.value
|
|
253
|
+
i = taken.next
|
|
254
|
+
checkFlags.push('--dataset')
|
|
255
|
+
} else if (arg === '--evaluator' || arg.startsWith('--evaluator=')) {
|
|
256
|
+
const taken = takeValue(argv, i, '--evaluator')
|
|
257
|
+
opts.evaluatorIds.push(taken.value)
|
|
258
|
+
i = taken.next
|
|
259
|
+
checkFlags.push('--evaluator')
|
|
260
|
+
} else if (arg === '--threshold' || arg.startsWith('--threshold=')) {
|
|
261
|
+
const taken = takeValue(argv, i, '--threshold', true)
|
|
262
|
+
opts.threshold = taken.value
|
|
263
|
+
i = taken.next
|
|
264
|
+
checkFlags.push('--threshold')
|
|
265
|
+
} else if (arg === '--gate') {
|
|
266
|
+
opts.gate = true
|
|
267
|
+
checkFlags.push('--gate')
|
|
268
|
+
} else if (arg.startsWith('-')) {
|
|
269
|
+
throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`, commandFailureCode(argv))
|
|
270
|
+
} else words.push(arg)
|
|
107
271
|
}
|
|
108
|
-
if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help
|
|
272
|
+
if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help.`, words[0] === 'check' ? 2 : 1)
|
|
109
273
|
if (words.length === 1) {
|
|
110
|
-
if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help'].includes(words[0])) {
|
|
274
|
+
if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help', 'check'].includes(words[0])) {
|
|
111
275
|
throw new UsageError(`Unknown command ${words[0]}. Run cuttlely --help.`)
|
|
112
276
|
}
|
|
113
277
|
opts.command = words[0]
|
|
114
278
|
}
|
|
279
|
+
if (opts.command === 'check') {
|
|
280
|
+
const checkExit = 2
|
|
281
|
+
if (dockerFlags.length) {
|
|
282
|
+
throw new UsageError(`${dockerFlags[0]} is not a check option. Run cuttlely check --help.`, checkExit)
|
|
283
|
+
}
|
|
284
|
+
if (!opts.help && !opts.version) {
|
|
285
|
+
if (!opts.flow) throw new UsageError('check needs --flow <id>. Run cuttlely check --help.', checkExit)
|
|
286
|
+
opts.flow = parseFlow(opts.flow)
|
|
287
|
+
if (checkFlags.includes('--server')) {
|
|
288
|
+
if (!opts.server)
|
|
289
|
+
throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', checkExit)
|
|
290
|
+
opts.server = parseServer(opts.server)
|
|
291
|
+
}
|
|
292
|
+
if (checkFlags.includes('--timeout')) {
|
|
293
|
+
if (!opts.timeoutSeconds)
|
|
294
|
+
throw new UsageError('Timeout must be a whole number of seconds from 1 to 3600, got (missing).', checkExit)
|
|
295
|
+
opts.timeoutSeconds = parseTimeout(opts.timeoutSeconds)
|
|
296
|
+
}
|
|
297
|
+
// An empty --json= is still the flag. Skipping it would run the check and write nothing.
|
|
298
|
+
if (checkFlags.includes('--json')) opts.jsonPath = parseJsonPath(opts.jsonPath)
|
|
299
|
+
if (opts.gate && checkFlags.includes('--dataset')) {
|
|
300
|
+
throw new UsageError('--gate uses the saved test set. Do not pass --dataset with it.', checkExit)
|
|
301
|
+
}
|
|
302
|
+
if (opts.gate && (checkFlags.includes('--evaluator') || checkFlags.includes('--threshold'))) {
|
|
303
|
+
throw new UsageError(
|
|
304
|
+
'--gate uses the saved dataset, evaluators, and threshold. Do not pass --evaluator or --threshold with it.',
|
|
305
|
+
checkExit
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
const scoredFlag = ['--dataset', '--evaluator', '--threshold', '--gate'].some((flag) => checkFlags.includes(flag))
|
|
309
|
+
if (opts.failOnChanged && scoredFlag) {
|
|
310
|
+
throw new UsageError('--fail-on-changed cannot be combined with --dataset, --evaluator, --threshold, or --gate.', checkExit)
|
|
311
|
+
}
|
|
312
|
+
if (checkFlags.includes('--dataset')) opts.datasetId = parseResourceId(opts.datasetId, '--dataset')
|
|
313
|
+
if (checkFlags.includes('--evaluator')) {
|
|
314
|
+
const seen = new Set()
|
|
315
|
+
const unique = []
|
|
316
|
+
for (const id of opts.evaluatorIds) {
|
|
317
|
+
const parsed = parseResourceId(id, '--evaluator')
|
|
318
|
+
if (seen.has(parsed)) continue
|
|
319
|
+
seen.add(parsed)
|
|
320
|
+
unique.push(parsed)
|
|
321
|
+
}
|
|
322
|
+
opts.evaluatorIds = unique
|
|
323
|
+
}
|
|
324
|
+
if (checkFlags.includes('--threshold')) opts.threshold = parseThreshold(opts.threshold)
|
|
325
|
+
if (
|
|
326
|
+
!opts.gate &&
|
|
327
|
+
!checkFlags.includes('--dataset') &&
|
|
328
|
+
(checkFlags.includes('--evaluator') || checkFlags.includes('--threshold'))
|
|
329
|
+
) {
|
|
330
|
+
throw new UsageError('--evaluator and --threshold need --dataset.', checkExit)
|
|
331
|
+
}
|
|
332
|
+
opts.scored = Boolean(opts.gate || checkFlags.includes('--dataset'))
|
|
333
|
+
}
|
|
334
|
+
} else if (checkFlags.length) {
|
|
335
|
+
throw new UsageError(`Unknown option ${checkFlags[0]}. Run cuttlely --help.`)
|
|
336
|
+
}
|
|
115
337
|
return opts
|
|
116
338
|
}
|
|
117
339
|
|
|
@@ -407,7 +629,7 @@ export async function main(argv, io = {}) {
|
|
|
407
629
|
} catch (error) {
|
|
408
630
|
if (error instanceof UsageError) {
|
|
409
631
|
err(error.message)
|
|
410
|
-
return
|
|
632
|
+
return error.exitCode
|
|
411
633
|
}
|
|
412
634
|
throw error
|
|
413
635
|
}
|
|
@@ -424,6 +646,9 @@ export async function main(argv, io = {}) {
|
|
|
424
646
|
out,
|
|
425
647
|
err,
|
|
426
648
|
docker: io.docker || spawnDocker,
|
|
649
|
+
env: io.env || process.env,
|
|
650
|
+
fetch: io.fetch || globalThis.fetch.bind(globalThis),
|
|
651
|
+
writeFile: io.writeFile,
|
|
427
652
|
fetchImpl: io.fetch || globalThis.fetch.bind(globalThis),
|
|
428
653
|
sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
429
654
|
open: io.open || openBrowser,
|
|
@@ -431,6 +656,23 @@ export async function main(argv, io = {}) {
|
|
|
431
656
|
timeoutMs: io.healthTimeoutMs ?? HEALTH_TIMEOUT_MS
|
|
432
657
|
}
|
|
433
658
|
try {
|
|
659
|
+
if (opts.command === 'check') {
|
|
660
|
+
return await runCheck(
|
|
661
|
+
{
|
|
662
|
+
flow: opts.flow,
|
|
663
|
+
server: opts.server,
|
|
664
|
+
timeoutSeconds: opts.timeoutSeconds,
|
|
665
|
+
jsonPath: opts.jsonPath,
|
|
666
|
+
failOnChanged: opts.failOnChanged,
|
|
667
|
+
scored: opts.scored,
|
|
668
|
+
gate: opts.gate,
|
|
669
|
+
datasetId: opts.datasetId,
|
|
670
|
+
evaluatorIds: opts.evaluatorIds,
|
|
671
|
+
threshold: opts.threshold
|
|
672
|
+
},
|
|
673
|
+
ctx
|
|
674
|
+
)
|
|
675
|
+
}
|
|
434
676
|
if (opts.command === 'start') return await launch(ctx, { restart: false })
|
|
435
677
|
if (opts.command === 'upgrade') return await launch(ctx, { restart: true })
|
|
436
678
|
if (opts.command === 'stop') return await stop(ctx)
|
|
@@ -439,7 +681,7 @@ export async function main(argv, io = {}) {
|
|
|
439
681
|
} catch (error) {
|
|
440
682
|
if (error instanceof UsageError) {
|
|
441
683
|
err(error.message)
|
|
442
|
-
return
|
|
684
|
+
return error.exitCode
|
|
443
685
|
}
|
|
444
686
|
throw error
|
|
445
687
|
}
|