cuttlely 1.0.3 → 1.2.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.
Files changed (4) hide show
  1. package/README.md +35 -9
  2. package/lib/check.js +201 -11
  3. package/lib/cli.js +103 -11
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -12,10 +12,10 @@ Requires Node.js 20 or newer, and Docker.
12
12
  npx cuttlely start
13
13
  ```
14
14
 
15
- That pulls `ghcr.io/cuttlely/cuttlely:1.0.0` (the same version as this package), keeps data in the Docker volume `cuttlely-data`, publishes port **43117**, waits until `http://127.0.0.1:43117/api/v1/ping` answers `pong`, prints `http://localhost:43117`, and opens it.
15
+ That pulls `ghcr.io/cuttlely/cuttlely`, tagged with the same version as the `cuttlely` package you ran, keeps data in the Docker volume `cuttlely-data`, publishes port **43117**, waits until `http://127.0.0.1:43117/api/v1/ping` answers `pong`, prints `http://localhost:43117`, and opens it.
16
16
 
17
17
  ```bash
18
- npx cuttlely start --port 8080 --tag 1.0.0 --env-file .env --no-open
18
+ npx cuttlely start --port 8080 --tag 1.1.0 --env-file .env --no-open
19
19
  ```
20
20
 
21
21
  | Flag | Meaning |
@@ -50,13 +50,13 @@ export CUTTLELY_API_KEY=<key>
50
50
  npx cuttlely check --flow <flow id> --server http://127.0.0.1:43117 --timeout 300 --json check.json --fail-on-changed
51
51
  ```
52
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. Default 300. |
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. |
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
60
 
61
61
  `pnpm dev` serves the API on port 3000. `scripts/start-cuttlely.sh` and `npx cuttlely start` listen on port 43117.
62
62
 
@@ -70,6 +70,32 @@ A check the server marks stopped or failed also exits 2. If the draft is saved,
70
70
 
71
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
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`. The gate covers Publish of the current draft only. Roll back, Promote, and `PUT /api/v1/chatflows/:id` can still change Live. A harness cannot store a passing test set. `--dataset` can score a harness for that run only. `--gate` on a harness exits 2 before any check.
90
+
91
+ | Code | When |
92
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
93
+ | 0 | The server verdict is passed. With `--gate`, a second read of the flow still stores this check as passed with an empty `block` (`publishGate.enabled` is true, `publishGate.result.checkId` is this check, and `publishGate.result.verdict` is `passed`). |
94
+ | 1 | The server verdict is failed. |
95
+ | 2 | The same failures as the question check, a missing verdict, a usage error, or a withheld dataset or evaluator id. `--gate` also exits 2 when the confirmation read fails: a non-empty block, a different checkId, or a missing passed. Turning the passing test set off during a `--gate` check, after the server verdict is `passed`, exits 2. The message is `The saved test set changed during the check. Run it again.` when the server sends no `block` text. Changing the dataset, the evaluators, or the pass mark during that check exits 2 the same way, and a `block` from the server is printed instead. A failed verdict exits 1 and does not read the flow again. |
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
+
73
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`.
74
100
 
75
101
  Compose uses the volume name `cuttlely_cuttlely-data`. This launcher uses `cuttlely-data`.
package/lib/check.js CHANGED
@@ -12,6 +12,9 @@ export const EXIT_PASS = 0
12
12
  export const EXIT_CHANGED = 1
13
13
  export const EXIT_ERROR = 2
14
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
+
15
18
  const ANSWER_CLIP = 240
16
19
 
17
20
  function redact(text, secret) {
@@ -72,14 +75,52 @@ function errorCases(check) {
72
75
  return (check?.cases || []).filter((row) => row && (row.live?.error || row.draft?.error))
73
76
  }
74
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
+
75
112
  export function summaryText(check) {
76
113
  const summary = check.summary || {}
77
114
  const lines = [
78
115
  `Check ${check.id} ${check.status}`,
79
- `Questions: ${summary.ran ?? 0} ran, ${summary.same ?? 0} same, ${summary.changed ?? 0} changed, ${summary.errors ?? 0} errors`,
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(
80
121
  `Live: ${summary.liveTokens ?? 0} tokens, median ${formatMs(summary.liveMedianMs)}`,
81
122
  `Draft: ${summary.draftTokens ?? 0} tokens, median ${formatMs(summary.draftMedianMs)}`
82
- ]
123
+ )
83
124
  // The check total, with each model priced once. It is not the sum of the per-answer figures.
84
125
  const liveEstimate = estimateLine('Live', summary.liveEstimatedCostUsd, summary.livePrice)
85
126
  const draftEstimate = estimateLine('Draft', summary.draftEstimatedCostUsd, summary.draftPrice)
@@ -106,11 +147,11 @@ export function summaryText(check) {
106
147
  lines.push(` ${oneLine(why)}`)
107
148
  }
108
149
  }
150
+ if (check.verdict && typeof check.verdict === 'object') lines.push(...failingLines(check))
109
151
  return lines.join('\n')
110
152
  }
111
153
 
112
- // Replay is the only verdict this command runs. A later scored mode (pass or fail
113
- // from a test set) should return the same { code, label } and be selected in selectVerdict.
154
+ // Replay is the question check. Scored mode reads the server verdict and does not rescore.
114
155
  export function replayVerdict(check, { failOnChanged }) {
115
156
  if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
116
157
  if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
@@ -123,7 +164,21 @@ export function replayVerdict(check, { failOnChanged }) {
123
164
  return { code: EXIT_PASS, label: 'pass' }
124
165
  }
125
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
+
126
180
  export function selectVerdict(check, options) {
181
+ if (options && options.scored) return scoredVerdict(check)
127
182
  return replayVerdict(check, options)
128
183
  }
129
184
 
@@ -171,8 +226,98 @@ function isNoDraft(result) {
171
226
 
172
227
  // Starting a check needs update. Reading it needs view. A key with only one of those gets 403,
173
228
  // and the server message does not say which permission is missing.
174
- function permissionDeniedMessage() {
175
- return 'The API key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.'
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
176
321
  }
177
322
 
178
323
  function writeResult(writeFile, jsonPath, check, secret) {
@@ -208,7 +353,12 @@ export async function runCheck(options, io = {}) {
208
353
  const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS
209
354
  const flow = options.flow
210
355
  const failOnChanged = Boolean(options.failOnChanged)
211
- const base = `${server}/api/v1/flow-drafts/${encodeURIComponent(flow)}/check`
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`
212
362
  const headers = { Authorization: `Bearer ${secret}`, Accept: 'application/json' }
213
363
  const deadline = now() + timeoutSeconds * 1000
214
364
 
@@ -255,16 +405,34 @@ export async function runCheck(options, io = {}) {
255
405
  }
256
406
 
257
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
+ }
258
426
  const started = await callApi(fetchImpl, base, {
259
427
  method: 'POST',
260
428
  headers: { ...headers, 'Content-Type': 'application/json' },
261
- body: JSON.stringify({ count: DEFAULT_QUESTION_COUNT }),
429
+ body: JSON.stringify(requestBody({ scored, datasetId, evaluatorIds, threshold })),
262
430
  signal: deadlineSignal(deadline, now)
263
431
  })
264
432
  if (started.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
265
433
  if (started.network) return fail(`Could not reach ${server}. ${started.message}`)
266
434
  if (!started.ok) {
267
- if (started.status === 403) return fail(permissionDeniedMessage())
435
+ if (started.status === 403) return fail(permissionDeniedMessage(scored))
268
436
  if (isNoDraft(started)) return fail(`No draft to check. ${started.message}`)
269
437
  return fail(`Check failed (${started.status}): ${started.message}`)
270
438
  }
@@ -293,7 +461,7 @@ export async function runCheck(options, io = {}) {
293
461
  })
294
462
  if (polled.timeout) return giveUp()
295
463
  if (polled.network) return failRunning(`Could not reach ${server}. ${polled.message}`)
296
- if (polled.status === 403) return failRunning(permissionDeniedMessage())
464
+ if (polled.status === 403) return failRunning(permissionDeniedMessage(scored))
297
465
  if (!polled.ok) return failRunning(`Check failed (${polled.status}): ${polled.message}`)
298
466
  if (!polled.json || typeof polled.json !== 'object') return failRunning('The server did not return a check.')
299
467
  const previous = check.completed
@@ -316,7 +484,7 @@ export async function runCheck(options, io = {}) {
316
484
  return EXIT_ERROR
317
485
  }
318
486
  out(say(summaryText(check)))
319
- const verdict = selectVerdict(check, { failOnChanged })
487
+ const verdict = selectVerdict(check, { failOnChanged, scored })
320
488
  if (verdict.label === 'draft-changed') {
321
489
  err('The draft changed while the check was running, so the result is out of date.')
322
490
  } else if (verdict.label === 'failed') {
@@ -326,10 +494,32 @@ export async function runCheck(options, io = {}) {
326
494
  } else if (verdict.label === 'changed') {
327
495
  const count = Number(check.summary?.changed ?? 0)
328
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.')
329
501
  } else if (verdict.code === EXIT_ERROR) {
330
502
  err('The check did not finish.')
331
503
  }
332
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
+ }
333
523
  return verdict.code
334
524
  } catch (error) {
335
525
  await stopIfStarted()
package/lib/cli.js CHANGED
@@ -4,7 +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 { runCheck } from './check.js'
7
+ import { RESOURCE_ID_RE, runCheck } from './check.js'
8
8
  import { spawnDocker } from './docker.js'
9
9
 
10
10
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
@@ -39,6 +39,8 @@ export function helpText() {
39
39
  ' cuttlely logs [--follow]',
40
40
  ' cuttlely upgrade [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
41
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',
42
44
  ' cuttlely --help',
43
45
  ' cuttlely --version',
44
46
  '',
@@ -54,13 +56,15 @@ export function helpText() {
54
56
  ' upgrade Pull the image and start it again.',
55
57
  ' check Run the Publish check on a flow draft. Reads CUTTLELY_API_KEY.',
56
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.',
57
60
  '',
58
61
  'Exit codes for check:',
59
- ' 0 The check finished. No answer was Changed, or --fail-on-changed was off.',
60
- ' 1 --fail-on-changed was set and at least one answer was Changed.',
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.',
61
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.',
62
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.',
63
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.',
64
68
  '',
65
69
  `Install guide: ${INSTALL_DOCS}`
66
70
  ].join('\n')
@@ -82,7 +86,19 @@ function parseTag(value, exitCode = 1) {
82
86
 
83
87
  // These options take the next argv token as their value. A following `check` is that value, not the command.
84
88
  // Any other previous token, including an unknown flag, leaves `check` as the command.
85
- const VALUE_OPTIONS = new Set(['--flow', '--server', '--timeout', '--json', '--port', '-p', '--tag', '--env-file'])
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
+ ])
86
102
 
87
103
  export function isCheckCommand(argv) {
88
104
  return argv.some((item, index) => {
@@ -97,15 +113,27 @@ export function commandFailureCode(argv) {
97
113
  return isCheckCommand(argv) ? 2 : 1
98
114
  }
99
115
 
100
- const FLOW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
101
-
102
116
  function parseFlow(value) {
103
- if (!value || !FLOW_ID_RE.test(value)) {
117
+ if (!value || !RESOURCE_ID_RE.test(value)) {
104
118
  throw new UsageError('--flow needs the flow id from the canvas address bar.', 2)
105
119
  }
106
120
  return value
107
121
  }
108
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
+
109
137
  function parseServer(value) {
110
138
  let url
111
139
  try {
@@ -137,11 +165,13 @@ function parseJsonPath(value) {
137
165
  return value
138
166
  }
139
167
 
140
- function takeValue(argv, index, flag) {
168
+ function takeValue(argv, index, flag, allowSigned = false) {
141
169
  const inline = argv[index].indexOf('=')
142
170
  if (inline !== -1) return { value: argv[index].slice(inline + 1), next: index }
143
171
  const value = argv[index + 1]
144
- if (!value || value.startsWith('-')) throw new UsageError(`${flag} needs a value. Run cuttlely --help.`, commandFailureCode(argv))
172
+ if (!value || (!allowSigned && value.startsWith('-'))) {
173
+ throw new UsageError(`${flag} needs a value. Run cuttlely --help.`, commandFailureCode(argv))
174
+ }
145
175
  return { value, next: index + 1 }
146
176
  }
147
177
 
@@ -159,7 +189,12 @@ export function parseArgs(argv) {
159
189
  server: null,
160
190
  timeoutSeconds: null,
161
191
  jsonPath: null,
162
- failOnChanged: false
192
+ failOnChanged: false,
193
+ datasetId: null,
194
+ evaluatorIds: [],
195
+ threshold: null,
196
+ gate: false,
197
+ scored: false
163
198
  }
164
199
  const words = []
165
200
  const dockerFlags = []
@@ -212,6 +247,24 @@ export function parseArgs(argv) {
212
247
  } else if (arg === '--fail-on-changed') {
213
248
  opts.failOnChanged = true
214
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')
215
268
  } else if (arg.startsWith('-')) {
216
269
  throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`, commandFailureCode(argv))
217
270
  } else words.push(arg)
@@ -243,6 +296,40 @@ export function parseArgs(argv) {
243
296
  }
244
297
  // An empty --json= is still the flag. Skipping it would run the check and write nothing.
245
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'))
246
333
  }
247
334
  } else if (checkFlags.length) {
248
335
  throw new UsageError(`Unknown option ${checkFlags[0]}. Run cuttlely --help.`)
@@ -576,7 +663,12 @@ export async function main(argv, io = {}) {
576
663
  server: opts.server,
577
664
  timeoutSeconds: opts.timeoutSeconds,
578
665
  jsonPath: opts.jsonPath,
579
- failOnChanged: opts.failOnChanged
666
+ failOnChanged: opts.failOnChanged,
667
+ scored: opts.scored,
668
+ gate: opts.gate,
669
+ datasetId: opts.datasetId,
670
+ evaluatorIds: opts.evaluatorIds,
671
+ threshold: opts.threshold
580
672
  },
581
673
  ctx
582
674
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuttlely",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Start Cuttlely in Docker. The open-source agent platform for applications.",
5
5
  "type": "module",
6
6
  "bin": {