Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,7 @@ retry the indicated recovery action, never blindly repeat a paid command.
use `authentication_failed` (exit 2). A cancelled run uses `execution_cancelled`, exit 4,
and a JSON error on stderr. Unknown client failures use `client_error`; they do not
prove that the server-side generation failed.

## Error handling updates in beta.3

Transport failures include a safe connection category without exposing URLs or credentials. Cancelled executions recommend a status check; download authentication failures retain exit code 2. Help flags are parsed as options so a literal `-h` option value is preserved.
4 changes: 2 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent readiness release

Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable release is deliberately selected.
Prepared version: `0.4.0-beta.3`. 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`.
Expand All @@ -9,4 +9,4 @@ Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable releas

No paid generation is necessary to verify installation or help output.

The package defaults to `publishConfig.tag: beta`; still pass `--tag beta` explicitly. Re-review the recovery fixes before publication. README links use existing pages; the beta.2-only tool/automation pages are held outside the docs site until the packages are published. Restore those pages in a later docs change with the stated minimum version.
The package defaults to `publishConfig.tag: beta`; still pass `--tag beta` explicitly. Verify the exact published artifact and retain `latest` at `0.3.0`. The beta.2 documentation remains compatible with beta.3.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dreamlayer",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"description": "Generate and edit images from your terminal, over local files, with one API key.",
"license": "MIT",
"type": "module",
Expand Down
31 changes: 15 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ type Options = {
aspect: string;
json: boolean;
quiet: boolean;
help: boolean;
idempotencyKey: string | null;
};

Expand Down Expand Up @@ -133,7 +134,7 @@ async function saveOutput(target: string, bytes: Uint8Array): Promise<void> {
}
async function downloadOutput(api: ManagedClient, url: string): Promise<Uint8Array> {
try { return await api.download(url); }
catch { throw new CommandError("download_failed", "The completed output could not be downloaded.", 5, true, "Retry dreamlayer download with the saved execution_id. Do not generate again."); }
catch (error) { if (error instanceof ApiError && [401, 403].includes(error.status)) throw error; throw new CommandError("download_failed", "The completed output could not be downloaded.", 5, true, "Retry dreamlayer download with the saved execution_id. Do not generate again."); }
}

function parseOptions(argv: string[]): { positional: string[]; options: Options } {
Expand All @@ -146,11 +147,13 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options
aspect: "1:1",
json: false,
quiet: false,
help: false,
idempotencyKey: null,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--json") options.json = true;
if (token === "--help" || token === "-h") options.help = true;
else if (token === "--json") options.json = true;
else if (token === "--quiet") options.quiet = true;
else if (token === "--out" || token === "-o") {
const value = argv[++i];
Expand Down Expand Up @@ -376,16 +379,13 @@ async function main(argv: string[]): Promise<number> {
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;
}

const { positional, options } = parseOptions(rest);
if (options.help) { process.stdout.write(USAGE); return 0; }
if (["generate", "edit", "cutout", "upscale", "sprite", "answer"].includes(command)) {
options.out ??= command === "sprite" ? `dreamlayer-${Date.now()}.zip` : defaultOut();
await preflightOutput(options.out);
Expand Down Expand Up @@ -502,15 +502,18 @@ main(process.argv.slice(2))
const known = error instanceof CommandError ? error : error instanceof InputValidationError
? new CommandError("invalid_request", error.message, 1)
: error instanceof RecoveryRequiredError
? new CommandError("temporarily_unavailable", "Execution state is uncertain. Read saved state before retrying.", 5, true, "Use status and download for the saved execution. If no ID was received, replay identical inputs with the original idempotency key.") : null;
? new CommandError("temporarily_unavailable", error.message, 5, true, "Use status and download for the saved execution. If no ID was received, replay identical inputs with the original idempotency key.") : null;
if (known) {
const partial = (error as { partialOutcome?: { execution_id?: string | null } }).partialOutcome;
const identity = { ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) };
const envelope = { error: { code: "CLIENT_ERROR", reason: known.reason, message: known.message, retryable: known.retryable, request_id: null, guidance: known.guidance, ...identity } };
if (process.argv.slice(2).includes("--json")) process.stderr.write(`${JSON.stringify(envelope)}\n`);
else {
process.stderr.write(`${known.message}\n${known.guidance ?? ""}\n`);
if (identity.execution_id) process.stderr.write(`Execution: ${identity.execution_id}\n dreamlayer status ${identity.execution_id}\n dreamlayer download ${identity.execution_id} --out <new-file>\n`);
process.stderr.write(`${known.message}\n${known.guidance ? known.guidance + "\n" : ""}`);
if (identity.execution_id) {
process.stderr.write(`Execution: ${identity.execution_id}\n dreamlayer status ${identity.execution_id}\n`);
if (["local_output_failed", "download_failed"].includes(known.reason)) process.stderr.write(` dreamlayer download ${identity.execution_id} --out <new-file>\n`);
}
if (identity.idempotency_key) process.stderr.write(`Idempotency key: ${identity.idempotency_key}\n`);
}
process.exitCode = known.exitCode;
Expand Down Expand Up @@ -541,13 +544,9 @@ main(process.argv.slice(2))
: error.retryable
? "Temporary. Retry with --idempotency-key to avoid paying twice."
: "";
if (process.argv.slice(2).includes("--json")) {
process.stderr.write(`${JSON.stringify(error.toPublicEnvelope())}\n`);
} else {
process.stderr.write(
`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`,
);
}
process.stderr.write(
`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`,
);
process.exitCode = exitCodeFor(error);
return;
}
Expand Down
11 changes: 10 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,16 @@ export class ManagedClient {
if (error instanceof StreamIdleError) throw error;
if (error instanceof InputValidationError || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status))) throw error;
if (!executionId && error instanceof ApiError) throw error;
if (!executionId || ++failures > 5) throw new RecoveryRequiredError("Execution state is uncertain. Read saved state before retrying.");
if (!executionId || ++failures > 5) {
const code = (error as { cause?: { code?: string }; code?: string } | null)?.cause?.code ?? (error as { code?: string } | null)?.code;
const causes: Record<string, string> = {
ECONNREFUSED: "Connection refused", ENOTFOUND: "Host lookup failed", EAI_AGAIN: "Host lookup temporarily failed",
ECONNRESET: "Connection reset", ETIMEDOUT: "Connection timed out", UND_ERR_CONNECT_TIMEOUT: "Connection timed out",
UND_ERR_SOCKET: "Connection closed", CERT_HAS_EXPIRED: "TLS certificate expired", DEPTH_ZERO_SELF_SIGNED_CERT: "TLS certificate is untrusted",
};
const cause = code && causes[code] ? `${causes[code]} (${code}). ` : "Transport connection failed. ";
throw new RecoveryRequiredError(cause + "Execution state is uncertain. Read saved state before retrying.");
}
}
if (!executionId) throw new RecoveryRequiredError("Execution stream ended before an identifier was received; reuse your idempotency key.");
const state = await this.getExecution(executionId);
Expand Down
53 changes: 53 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ function fakeApi(behaviour) {
return;
}
if (request.url === "/asset.png") {
if (behaviour.assetStatus) {
response.writeHead(behaviour.assetStatus, { "content-type": "application/json" });
response.end(JSON.stringify({ detail: "private upstream details" }));
return;
}
response.writeHead(200, { "content-type": "image/png" });
response.end(PNG);
return;
Expand Down Expand Up @@ -1161,3 +1166,51 @@ test('repeating a successful command with the same output does not submit a seco
assert.match(help.stdout, /Existing destinations are refused before paid submission/);
} finally { api.close(); }
});

for (const json of [false, true]) {
test(`transport diagnostics retain a safe cause and request key (json=${json})`, async () => {
const probe = createServer();
await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${probe.address().port}`;
await new Promise(resolve => probe.close(resolve));
const result = await runCli(['generate', 'PRIVATE PROMPT', '--idempotency-key', 'saved', '--quiet', ...(json ? ['--json'] : [])], {DREAMLAYER_API_URL: url});
assert.equal(result.code, 5);
assert.match(result.stderr, /Connection refused.*ECONNREFUSED/);
assert.match(result.stderr, /saved/);
assert.doesNotMatch(result.stderr, /PRIVATE PROMPT|127\.0\.0\.1|dlr_live_test/);
});
for (const status of [401, 403]) {
test(`asset HTTP ${status} remains an access error (json=${json})`, async () => {
const api = await listen(fakeApi({assetStatus: status, events: [started, {event:'asset',data:{asset_id:'44444444-4444-4444-8444-444444444444',download_url:'ASSET'}}, {event:'done',data:{status:'completed'}}]}));
try {
const result = await runCli(['generate','a tree','--quiet', ...(json ? ['--json'] : [])], {DREAMLAYER_API_URL:api.url});
assert.equal(result.code, 2, result.stderr);
assert.match(result.stderr, status === 401 ? /authentication_failed/ : /access_denied/);
assert.doesNotMatch(result.stderr, /download_failed|private upstream details/);
assert.equal(api.calls.filter(c=>c.method==='POST').length, 1);
if(json) assert.equal(JSON.parse(result.stderr).error.execution_id, started.data.execution_id);
} finally {api.close();}
});
}
}
test('cancelled text output recommends status without download or blank guidance', async () => {
const api = await listen(fakeApi({events:[started,{event:'done',data:{status:'cancelled'}}]}));
try {
const result=await runCli(['generate','a tree','--quiet'],{DREAMLAYER_API_URL:api.url});
assert.equal(result.code,4); assert.match(result.stderr,/dreamlayer status/);
assert.doesNotMatch(result.stderr,/dreamlayer download|\n\n/);
} finally {api.close();}
});
test('help-like option values do not short-circuit a command', async () => {
const api=await listen(fakeApi({events:[started,{event:'done',data:{status:'cancelled'}}]}));
try {
const result=await runCli(['generate','a tree','--idempotency-key','-h','--quiet'],{DREAMLAYER_API_URL:api.url});
assert.equal(result.code,4); assert.equal(api.calls.find(c=>c.method==='POST').headers['idempotency-key'],'-h');
assert.doesNotMatch(result.stdout,/USAGE/);
} finally {api.close();}
});

test('help after positional arguments requires no key or network', async () => {
const result = await runCli(['generate', 'a tree', '--help'], {DREAMLAYER_API_KEY:'', DREAMLAYER_API_URL:'http://127.0.0.1:1'});
assert.equal(result.code,0); assert.match(result.stdout,/USAGE/);
});
4 changes: 2 additions & 2 deletions test/packaged-install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.2");
assert.equal(metadata.version, "0.4.0-beta.3");
assert.ok(metadata.integrity.startsWith("sha512-"));
assert.deepEqual(
metadata.files.map(({ path: file }) => file).sort(),
Expand Down Expand Up @@ -58,7 +58,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout:
await readFile(path.join(installRoot, "node_modules", "dreamlayer", "package.json"), "utf8"),
);
assert.equal(packageJson.publishConfig.tag, "beta");
assert.equal(packageJson.version, "0.4.0-beta.2");
assert.equal(packageJson.version, "0.4.0-beta.3");
assert.equal(packageJson.bin.dreamlayer, "./dist/cli.js");

const env = { ...process.env, DREAMLAYER_API_KEY: "" };
Expand Down
Loading