From f9eb14e87ee9ed678dfa03943696542cb0cb46ca Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Tue, 22 Sep 2026 13:04:08 -0700 Subject: [PATCH] Add predictable automation errors and recover existing downloads --- README.md | 29 ++++++++++++++++-- RELEASE.md | 10 +++++++ package.json | 6 ++-- src/cli.ts | 54 ++++++++++++++++++++++++++++++---- test/cli.test.mjs | 41 +++++++++++++++++++++++++- test/packaged-install.test.mjs | 4 +-- 6 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 RELEASE.md diff --git a/README.md b/README.md index 1a5c097..1ebbda2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ dreamlayer cutout [--out file.png] # background removal dreamlayer upscale [--out file.png] # 2x dreamlayer answer [--image file.png] dreamlayer status +dreamlayer download --out file.png dreamlayer balance # spends nothing dreamlayer capabilities # spends nothing ``` @@ -77,8 +78,8 @@ dreamlayer balance --json ## Retries are safe if you reuse the key -An idempotency key is generated per run. After an uncertain response, pass the same one -back and the original result replays instead of paying twice: +Save a key before starting text generation. After an uncertain response, reuse that key +and the identical prompt/options, or check the existing execution first: ```bash dreamlayer generate "a fox logo" --idempotency-key fox-001 @@ -124,3 +125,27 @@ dreamlayer status EXECUTION_ID Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes. For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier. + +## Automating recovery + +Commands never prompt. `--json` sends results to stdout and structured errors to stderr, +including usage and transport errors. Exit 0 from `status` means the read succeeded; +inspect its `status` field to learn whether the execution completed. + +After a lost download, recover the existing execution without generation: + +```sh +dreamlayer status EXECUTION_ID --json +dreamlayer download EXECUTION_ID --out recovered.png --json +``` + +`download` refuses to overwrite a file. Use `.zip` for sprite results. Generation errors +in JSON include the available execution ID and idempotency key for recovery. Treat them +as private identifiers. For file-based commands, rerunning uploads a new asset: the same +local file is not an identical API request. Prefer `status` and `download`, or the +[journaled API examples](https://docs.dreamlayer.io/agent-api/examples). + +[API overview](https://docs.dreamlayer.io/agent-api) · +[Limits](https://docs.dreamlayer.io/agent-api/limits) · +[Automation](https://docs.dreamlayer.io/cli/automation) · +[MCP tools](https://docs.dreamlayer.io/mcp/tools) diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..dc6e20b --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,10 @@ +# Agent readiness release + +Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable release is deliberately selected. + +1. Pass CI, review and merge the change. +2. With npm publisher authentication, run `pnpm test` and `npm publish --tag beta`. +3. Verify `npm view dreamlayer dist-tags`, install the package in a clean directory, and run `dreamlayer --version` and `dreamlayer download --help` without credentials. +4. Update pinned documentation examples only after the version exists on npm. + +No paid generation is necessary to verify installation or help output. diff --git a/package.json b/package.json index eb6e546..223be11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dreamlayer", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Generate and edit images from your terminal, over local files, with one API key.", "license": "MIT", "type": "module", @@ -31,7 +31,9 @@ "cli", "dreamlayer", "ai", - "image-editing" + "image-editing", + "sprite-sheet", + "automation" ], "repository": { "type": "git", diff --git a/src/cli.ts b/src/cli.ts index 616d452..3a13bad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,6 +46,7 @@ USAGE dreamlayer upscale [--out ] dreamlayer sprite --action [--frames <7–100>] --max-credits [--out ] dreamlayer answer [--image ] [--out ] + dreamlayer download --out dreamlayer status dreamlayer balance dreamlayer capabilities @@ -60,10 +61,19 @@ OPTIONS --frame-size Square export: 32, 64, 128, 256, 512 (default), 720, 1080 --frames Frame count: integer 7–100, default 12 --max-credits Maximum approved charge for the sprite job - --json Machine-readable output on stdout + --json JSON results on stdout; JSON errors on stderr --quiet No progress on stderr --idempotency-key Reuse to retry safely after an uncertain response +EXIT CODES + 0 success, 1 usage/local error, 2 authentication/access, 3 credits/quota, + 4 permanent API failure, 5 temporary failure, 6 input required + +AUTOMATION + Commands never prompt. Save a unique --idempotency-key before paid work. + After uncertainty, use status then download; do not start a replacement job. + JSON output is documented at https://docs.dreamlayer.io/cli/automation + ENVIRONMENT DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io @@ -189,6 +199,8 @@ function defaultOut(): string { return `dreamlayer-${Date.now()}.png`; } +let recovery: { idempotency_key?: string; execution_id?: string | null } = {}; + async function run( api: ManagedClient, input: ManagedExecuteInput, @@ -196,12 +208,14 @@ async function run( ): Promise { const progress = new Progress(!options.quiet && process.stderr.isTTY === true); const idempotencyKey = options.idempotencyKey ?? randomUUID(); + recovery = { idempotency_key: idempotencyKey }; const outcome = await consume(api.follow(input, { idempotencyKey }), progress); + recovery.execution_id = outcome.execution_id; if (outcome.question) { progress.stop(); if (options.json) { - process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`); } else { process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`); // The server's needs_input question always asks for an image, so point at the @@ -221,8 +235,8 @@ async function run( const terminal = terminalExecutionError(await api.getExecution(outcome.execution_id)); if (terminal) throw terminal; } - if (options.json) process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`); - else process.stderr.write(`Run ended as ${outcome.status}. No credit was settled.\n`); + if (options.json) process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`); + else process.stderr.write(`Run ended as ${outcome.status}. Check the execution before retrying.\n`); return 5; } @@ -233,7 +247,7 @@ async function run( progress.stop(); if (options.json) { - process.stdout.write(`${JSON.stringify({ ...outcome, file: path.resolve(target) }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey, file: path.resolve(target) }, null, 2)}\n`); } else { // The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file. process.stdout.write(`${target}\n`); @@ -325,6 +339,10 @@ async function main(argv: string[]): Promise { process.stdout.write(USAGE); return command ? 0 : 1; } + if (rest.includes("--help") || rest.includes("-h")) { + process.stdout.write(USAGE); + return 0; + } if (command === "--version" || command === "-v") { process.stdout.write(`${PACKAGE_VERSION}\n`); return 0; @@ -388,6 +406,19 @@ async function main(argv: string[]): Promise { options, ); } + case "download": { + const executionId = positional[0]; + if (!executionId || positional.length !== 1 || !options.out) throw new UsageError("download needs one execution id and --out "); + const api = client(); + recovery = { execution_id: executionId }; + const execution = await api.getExecution(executionId); + const assets = execution.image_job?.finished_assets; + if (execution.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new UsageError("The execution has no finished asset. Check status before downloading."); + const bytes = await api.download(assets[0].download_url); + await writeFile(options.out, bytes, { flag: "wx" }); + process.stdout.write(options.json ? `${JSON.stringify({ execution_id: executionId, file: path.resolve(options.out), bytes: bytes.length })}\n` : `${options.out}\n`); + return 0; + } case "status": { const executionId = positional[0]; if (!executionId) throw new UsageError("status needs an execution id"); @@ -427,6 +458,19 @@ main(process.argv.slice(2)) process.exitCode = code; }) .catch((error: unknown) => { + if (process.argv.slice(2).includes("--json")) { + const partial = (error as { partialOutcome?: { execution_id?: string | null } } | null)?.partialOutcome; + const temporary = error instanceof StreamIdleError || error instanceof UploadTimeoutError; + const envelope = error instanceof ApiError ? error.toPublicEnvelope() : { + error: { code: error instanceof UsageError ? "VALIDATION_FAILED" : temporary ? "SERVICE_UNAVAILABLE" : "INTERNAL_ERROR", + reason: error instanceof UsageError ? "invalid_request" : temporary ? "temporarily_unavailable" : "generation_failed", + message: error instanceof UsageError ? "Check command arguments and local input or output files; use --help." : temporary ? "The request timed out. Check the saved execution before retrying." : "The command could not complete. Check saved execution state and local output access.", + retryable: temporary, request_id: null }, + }; + process.stderr.write(`${JSON.stringify({ error: { ...(envelope.error as Record), ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) } })}\n`); + process.exitCode = error instanceof ApiError ? exitCodeFor(error) : temporary ? 5 : 1; + return; + } if (error instanceof UsageError) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 96a3a83..c919a98 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -322,7 +322,7 @@ test("balance rejects inconsistent or expanded responses without echoing private api.close(); assert.equal(result.code, 1); - assert.match(result.stderr, /Invalid DreamLayer balance response/); + assert.equal(JSON.parse(result.stderr).error.code, "INTERNAL_ERROR"); assert.doesNotMatch(result.stderr, /do-not-print-this/); assert.equal(result.stdout, ""); }); @@ -981,3 +981,42 @@ test("fractional funding explains affordability without changing JSON balances", assert.deepEqual(JSON.parse(json.stdout), body); } finally { api.close(); } }); + + +test("JSON usage errors remain parseable and do not leak local arguments", async () => { + const result = await runCli(["edit", "/private/customer-photo.png", "--json"], {}); + assert.equal(result.code, 1); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).error.reason, "invalid_request"); + assert.doesNotMatch(result.stderr, /customer-photo/); +}); + +test("subcommand help requires no credentials or network", async () => { + const result = await runCli(["sprite", "--help"], { DREAMLAYER_API_KEY: "" }); + assert.equal(result.code, 0); + assert.match(result.stdout, /EXIT CODES/); +}); + +test("download recovers existing assets using GET only and refuses overwrite", async () => { + const handler = fakeApi({}); + const api = await listen(handler); + const directory = await mkdtemp(path.join(tmpdir(), "dreamlayer-download-")); + const destination = path.join(directory, "result.png"); + handler.server.removeAllListeners('request'); + handler.server.on('request', (request, response) => { + handler.calls.push({ method: request.method, url: request.url }); + if (request.url === '/asset.png') return response.end(PNG); + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ status: 'completed', image_job: { finished_assets: [{ download_url: api.url + '/asset.png' }] } })); + }); + try { + const result = await runCli(['download', 'owned', '--out', destination, '--json'], { DREAMLAYER_API_URL: api.url }); + assert.equal(result.code, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).execution_id, 'owned'); + assert.deepEqual(await readFile(destination), PNG); + assert.ok(handler.calls.every(call => call.method === 'GET')); + const duplicate = await runCli(['download', 'owned', '--out', destination, '--json'], { DREAMLAYER_API_URL: api.url }); + assert.equal(duplicate.code, 1); + assert.deepEqual(await readFile(destination), PNG); + } finally { api.close(); } +}); diff --git a/test/packaged-install.test.mjs b/test/packaged-install.test.mjs index f66fd2f..fcabb29 100644 --- a/test/packaged-install.test.mjs +++ b/test/packaged-install.test.mjs @@ -25,7 +25,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: assert.equal(packed.status, 0, packed.stderr); const metadata = JSON.parse(packed.stdout)[0]; assert.equal(metadata.name, "dreamlayer"); - assert.equal(metadata.version, "0.4.0-beta.1"); + assert.equal(metadata.version, "0.4.0-beta.2"); assert.ok(metadata.integrity.startsWith("sha512-")); assert.deepEqual( metadata.files.map(({ path: file }) => file).sort(), @@ -57,7 +57,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: const packageJson = JSON.parse( await readFile(path.join(installRoot, "node_modules", "dreamlayer", "package.json"), "utf8"), ); - assert.equal(packageJson.version, "0.4.0-beta.1"); + assert.equal(packageJson.version, "0.4.0-beta.2"); assert.equal(packageJson.bin.dreamlayer, "./dist/cli.js"); const env = { ...process.env, DREAMLAYER_API_KEY: "" };