cuttlely 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -6
  2. package/lib/cli.js +92 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@ npx cuttlely start
15
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.1.0 --env-file .env --no-open
18
+ npx cuttlely start --port 8080 --tag 1.3.1 --env-file .env --no-open
19
19
  ```
20
20
 
21
21
  | Flag | Meaning |
@@ -88,11 +88,11 @@ npx cuttlely check --flow <flow id> --gate
88
88
 
89
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
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. |
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. With `--gate`, the command does not read the flow again. |
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. |
96
96
 
97
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
98
 
package/lib/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // This package only talks to the local Docker daemon.
3
3
  import { readFileSync, statSync } from 'node:fs'
4
4
  import { spawn } from 'node:child_process'
5
+ import { get as httpGet } from 'node:http'
5
6
  import { dirname, join } from 'node:path'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { RESOURCE_ID_RE, runCheck } from './check.js'
@@ -17,6 +18,21 @@ export const VOLUME_NAME = 'cuttlely-data'
17
18
  export const DATA_DIR = '/var/cuttlely'
18
19
  export const INSTALL_DOCS = 'https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/docker.md'
19
20
  export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
21
+ // Each health try gets its own limit, so one stuck request cannot use up the whole wait.
22
+ export const HEALTH_TRY_TIMEOUT_MS = 5000
23
+ // Error codes for a connection that the port accepts and then drops while the container boots.
24
+ export const RETRYABLE_HEALTH_ERRORS = new Set([
25
+ 'ECONNRESET',
26
+ 'ECONNREFUSED',
27
+ 'ECONNABORTED',
28
+ 'EPIPE',
29
+ 'ETIMEDOUT',
30
+ 'EHOSTUNREACH',
31
+ 'UND_ERR_SOCKET',
32
+ 'UND_ERR_CONNECT_TIMEOUT',
33
+ 'UND_ERR_HEADERS_TIMEOUT',
34
+ 'HEALTH_TRY_TIMEOUT'
35
+ ])
20
36
 
21
37
  const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
22
38
  const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
@@ -457,26 +473,86 @@ async function readImageId(docker, image) {
457
473
  return result.stdout.trim()
458
474
  }
459
475
 
460
- async function waitForHealth({ url, fetchImpl, sleep, timeoutMs, now }) {
476
+ function healthErrorCode(error) {
477
+ return error?.code || error?.cause?.code || error?.name || 'UNKNOWN'
478
+ }
479
+
480
+ // One health try with its own timeout. The timer is a normal (ref'd) timer, so a request that never settles still
481
+ // keeps the event loop alive until it fires; AbortSignal.timeout alone is unref'd and does not.
482
+ function healthTry(fetchImpl, url, tryTimeoutMs) {
483
+ const controller = new AbortController()
484
+ let timer
485
+ const timeout = new Promise((_, reject) => {
486
+ timer = setTimeout(() => {
487
+ const error = new Error(`No answer from ${url} within ${tryTimeoutMs} ms`)
488
+ error.code = 'HEALTH_TRY_TIMEOUT'
489
+ controller.abort(error)
490
+ reject(error)
491
+ }, tryTimeoutMs)
492
+ })
493
+ const attempt = (async () => {
494
+ const res = await fetchImpl(url, { redirect: 'manual', signal: controller.signal })
495
+ const body = typeof res.text === 'function' ? await res.text() : String(res.body ?? '')
496
+ return res.ok && body.includes('pong')
497
+ })()
498
+ attempt.catch(() => {})
499
+ return Promise.race([attempt, timeout]).finally(() => clearTimeout(timer))
500
+ }
501
+
502
+ // Waits until the ping answers pong or the deadline passes. Returns { ready, lastError }.
503
+ // A keep-alive interval runs for the whole wait: on Node 20 a fetch whose socket is reset during boot can stay
504
+ // pending with nothing else holding the event loop, and Node then exits with code 13 before the ready message.
505
+ export async function waitForHealth({ url, fetchImpl, sleep, timeoutMs, now, tryTimeoutMs = HEALTH_TRY_TIMEOUT_MS }) {
461
506
  const deadline = now() + timeoutMs
462
- for (;;) {
463
- try {
464
- const res = await fetchImpl(url, { redirect: 'manual' })
465
- const body = typeof res.text === 'function' ? await res.text() : String(res.body ?? '')
466
- if (res.ok && body.includes('pong')) return true
467
- } catch {
468
- // The server is still booting, or the port is not open yet.
507
+ const keepAlive = setInterval(() => {}, 1000)
508
+ let lastError = null
509
+ try {
510
+ for (;;) {
511
+ try {
512
+ if (await healthTry(fetchImpl, url, tryTimeoutMs)) return { ready: true, lastError: null }
513
+ lastError = null
514
+ } catch (error) {
515
+ // Resets, refusals and per-try timeouts mean the server is still booting: try again.
516
+ // Anything else is also retried until the deadline, but is reported if it is the last error seen.
517
+ lastError = { code: healthErrorCode(error), retryable: RETRYABLE_HEALTH_ERRORS.has(healthErrorCode(error)) }
518
+ }
519
+ if (now() >= deadline) return { ready: false, lastError }
520
+ await sleep(1000)
469
521
  }
470
- if (now() >= deadline) return false
471
- await sleep(1000)
472
- }
522
+ } finally {
523
+ clearInterval(keepAlive)
524
+ }
525
+ }
526
+
527
+ // Default health fetch: node:http with a socket timeout, so the open request itself holds the event loop.
528
+ export function httpHealthFetch(url, { signal, timeoutMs = HEALTH_TRY_TIMEOUT_MS } = {}) {
529
+ return new Promise((resolve, reject) => {
530
+ const req = httpGet(url, { signal, timeout: timeoutMs }, (res) => {
531
+ let body = ''
532
+ res.setEncoding('utf8')
533
+ res.on('data', (chunk) => {
534
+ body += chunk
535
+ })
536
+ res.on('end', () => {
537
+ const status = res.statusCode || 0
538
+ resolve({ ok: status >= 200 && status < 300, status, text: async () => body })
539
+ })
540
+ res.on('error', reject)
541
+ })
542
+ req.on('timeout', () => {
543
+ const error = new Error(`No answer from ${url} within ${timeoutMs} ms`)
544
+ error.code = 'HEALTH_TRY_TIMEOUT'
545
+ req.destroy(error)
546
+ })
547
+ req.on('error', reject)
548
+ })
473
549
  }
474
550
 
475
551
  async function reportReady(ctx) {
476
552
  const url = publicUrl(ctx.opts.port)
477
553
  const ping = healthUrl(ctx.opts.port)
478
554
  ctx.out(`Waiting for Cuttlely at ${ping}`)
479
- const ready = await waitForHealth({
555
+ const { ready, lastError } = await waitForHealth({
480
556
  url: ping,
481
557
  fetchImpl: ctx.fetchImpl,
482
558
  sleep: ctx.sleep,
@@ -484,6 +560,9 @@ async function reportReady(ctx) {
484
560
  now: ctx.now
485
561
  })
486
562
  if (!ready) {
563
+ const minutes = Math.max(1, Math.round(ctx.timeoutMs / 60000))
564
+ const reason = lastError ? ` (last error: ${lastError.code})` : ''
565
+ ctx.err(`Cuttlely did not answer ${ping} within ${minutes} min${reason}.`)
487
566
  ctx.err(`Cuttlely is still starting. Open ${url} when it is ready.`)
488
567
  ctx.err('Logs: cuttlely logs')
489
568
  ctx.err(INSTALL_DOCS)
@@ -649,7 +728,7 @@ export async function main(argv, io = {}) {
649
728
  env: io.env || process.env,
650
729
  fetch: io.fetch || globalThis.fetch.bind(globalThis),
651
730
  writeFile: io.writeFile,
652
- fetchImpl: io.fetch || globalThis.fetch.bind(globalThis),
731
+ fetchImpl: io.fetch || httpHealthFetch,
653
732
  sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
654
733
  open: io.open || openBrowser,
655
734
  now: io.now || Date.now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuttlely",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Start Cuttlely in Docker. The open-source agent platform for applications.",
5
5
  "type": "module",
6
6
  "bin": {