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
20 changes: 18 additions & 2 deletions .claude/skills/swamp-data-query/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,23 @@ const names = await context.queryData!(
// names is string[]
```

## Empty results and errors

- A predicate that matches nothing returns an empty list (CLI: empty stdout in
JSON mode, or "No matching data" in log mode). This is success, not error —
exit code is 0.
- Malformed CEL fails fast with a `code: "validation_failed"` error before any
data is read. Verify the predicate against
[references/fields.md](references/fields.md) for available fields.
- Referencing `attributes.<x>` or `content.<y>` on a record that lacks the field
yields `null` for that record, not an error — predicates involving the field
simply don't match. Use `has(attributes.x)` to filter records that have the
field set.
- Binary data records expose `content` as `bytes`; field projection on binary
`content` returns base64.

## References

See [references/fields.md](references/fields.md) for the complete DataRecord
field reference and CEL operator examples.
See [references/fields.md](references/fields.md) (also at
`.claude/skills/swamp-data-query/references/fields.md` in this repo) for the
complete DataRecord field reference and CEL operator examples.
3 changes: 2 additions & 1 deletion .claude/skills/swamp-model/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,8 @@ swamp model method run my-shell execute
swamp model method run my-deploy create --input environment=prod
swamp model method run my-deploy create --input environment=prod --input replicas=3
swamp model method run my-deploy create --input config.timeout=30 # dot notation for nesting
swamp model method run my-deploy create --input '{"environment": "prod"}' # JSON also supported
swamp model method run my-deploy create --input 'tags:json=["prod","west"]' # :json suffix for arrays/objects
swamp model method run my-deploy create --input '{"environment": "prod"}' # legacy single-shot JSON
swamp model method run my-deploy create --input-file inputs.yaml
swamp model method run my-deploy create --last-evaluated
swamp model method run my-deploy create --skip-checks
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/swamp-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ swamp workflow validate --json # Validate all
swamp workflow run my-workflow
swamp workflow run my-workflow --input environment=production
swamp workflow run my-workflow --input environment=production --input replicas=3
swamp workflow run my-workflow --input '{"environment": "production"}' # JSON also supported
swamp workflow run my-workflow --input 'tags:json=["prod","west"]' # :json suffix for arrays/objects
swamp workflow run my-workflow --input '{"environment": "production"}' # legacy single-shot JSON
swamp workflow run my-workflow --input-file inputs.yaml
swamp workflow run my-workflow --last-evaluated # Use pre-evaluated workflow
```
Expand Down
30 changes: 27 additions & 3 deletions design/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,36 @@ base values and key=value pairs act as overrides (deep merged).

### Type coercion

Key=value inputs are always parsed as strings. When the workflow or model
Key=value inputs are parsed as strings by default. When the workflow or model
declares an `InputsSchema`, string values are automatically coerced to match
the schema's declared types (`number`, `integer`, `boolean`) before validation.
Without a schema, values remain as strings.

### JSON-typed values via `:json` suffix

Append `:json` to the leaf segment of a key to parse the value as JSON
instead of a string:

```sh
# Array
swamp model method run my-model search --input 'keywords:json=["typescript","retry"]'

# Object
swamp model method run my-model deploy --input 'config:json={"port":8080,"replicas":3}'

# Nested key (suffix attaches to the LEAF segment only)
swamp model method run my-model deploy --input 'server.config:json={"port":8080}'
# → { server: { config: { port: 8080 } } }
```

The `:json` suffix bypasses the `@file` shorthand and the `\@` escape;
the value is always parsed as a JSON literal. JSON parse failures are
hard errors. When both `--input key:json=...` and a YAML
`--input-file` set the same key, the CLI value wins (existing
deepMerge precedence).

### Arrays

Array inputs are not supported via key=value syntax. Use `--input-file` or JSON
for array values.
Array inputs are supported via the `:json` suffix above (preferred), or
via `--input-file` with YAML/JSON, or via the legacy single-shot
`--input '<json-object>'` form.
39 changes: 39 additions & 0 deletions design/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ export function createWorkflowRunRenderer(
The command handler becomes pure orchestration — wire deps, create contexts,
pick a renderer, consume the stream, check the result.

## JSON Mode Output Contract

When a command runs with `--json`, swamp guarantees the following invariants
to JSON consumers (`jq`, AI agents, CI scripts):

1. **stdout contains exactly one valid JSON document** for the command's
primary output, OR a stream of newline-delimited JSON (NDJSON) documents
for streaming commands. No trailing whitespace, no log lines, no
prompts.
2. **stderr is reserved for log records** at the configured log level. It
may be empty, may contain LogTape pretty-formatted lines, but never
doubles as a structured-output channel.
3. **Errors emit a structured JSON object on stdout** with the shape
`{ error: string, stack?: string, code?: string }` and a non-zero
process exit. The `code` field is OPTIONAL — consumers MUST tolerate
its presence or absence. When present, it carries a machine-readable
identifier (e.g. `"cancelled"`, `"timeout"`, `"not_found"`,
`"validation_failed"`) suitable for programmatic dispatch.
4. **Commands MUST NOT prompt interactively in JSON mode.** Any
confirmation gate must be bypassed when the output mode is `json`.
Use `Deno.stdin.isTerminal()` to detect non-interactive contexts in
addition to `outputMode`.

Renderer implementations for new commands MUST preserve these
guarantees. The regression test suite at `integration/json_isolation_test.ts`
exercises the contract across representative commands and is the
authoritative gate.

This contract is enforced at the logging layer by
`initializeLogging({ jsonMode: true })` in
`src/infrastructure/logging/logger.ts`, which configures the
`["model","method","run"]`, `["workflow","run"]`, and `["logtape","meta"]`
category loggers with `parentSinks: "override"` so they cannot inherit
the root logger's sinks. The single emitter for fatal output in JSON
mode is `renderError` in
`src/presentation/output/error_output.ts` — it writes to stdout and
skips `logger.fatal`, so log-mode sinks cannot produce a duplicate
entry.

## Logging Boundaries

libswamp and renderers have distinct logging responsibilities:
Expand Down
6 changes: 3 additions & 3 deletions integration/data_output_flow_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ Deno.test("Integration: output get fails for non-existent output", async () => {
true,
"Should fail for non-existent output",
);
assertStringIncludes(result.stderr, "not found");
assertStringIncludes(result.stderr + result.stdout, "not found");
});
});

Expand All @@ -820,7 +820,7 @@ Deno.test("Integration: data get fails for non-existent model", async () => {
);

assertEquals(result.code !== 0, true, "Should fail for non-existent model");
assertStringIncludes(result.stderr, "not found");
assertStringIncludes(result.stderr + result.stdout, "not found");
});
});

Expand Down Expand Up @@ -886,6 +886,6 @@ Deno.test("Integration: output data fails for non-existent field", async () => {
);

assertEquals(result.code !== 0, true, "Should fail for non-existent field");
assertStringIncludes(result.stderr, "not found");
assertStringIncludes(result.stderr + result.stdout, "not found");
});
});
5 changes: 4 additions & 1 deletion integration/driver_resolution_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ Deno.test(
);
// Error surface is non-specific YAML parse failure; the point is the
// run fails rather than silently proceeding with no marker.
assertStringIncludes(result.stderr.toLowerCase(), "yaml");
assertStringIncludes(
(result.stderr + result.stdout).toLowerCase(),
"yaml",
);
});
},
);
2 changes: 1 addition & 1 deletion integration/foreach_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ Deno.test("CLI: workflow with forEach validates array minItems", async () => {
);

assertEquals(result.code !== 0, true, "Should fail for empty array");
assertStringIncludes(result.stderr, "at least 1 item");
assertStringIncludes(result.stderr + result.stdout, "at least 1 item");
});
});

Expand Down
2 changes: 1 addition & 1 deletion integration/input_override_validation_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Deno.test("CLI: model method run with type mismatch fails - number instead of st
assertEquals(result.code !== 0, true, "Should fail for type mismatch");
// Zod validation error for method arguments
assertStringIncludes(
result.stderr,
result.stderr + result.stdout,
"Method arguments validation failed",
);
});
Expand Down
16 changes: 8 additions & 8 deletions integration/inputs_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Deno.test("CLI: model method run with invalid enum value fails", async () => {
);

assertEquals(result.code !== 0, true, "Should fail for invalid enum");
assertStringIncludes(result.stderr, "must be one of");
assertStringIncludes(result.stderr + result.stdout, "must be one of");
});
});

Expand All @@ -208,7 +208,7 @@ Deno.test("CLI: model method run with missing required input fails", async () =>
);

assertEquals(result.code !== 0, true, "Should fail for missing input");
assertStringIncludes(result.stderr, "environment");
assertStringIncludes(result.stderr + result.stdout, "environment");
});
});

Expand Down Expand Up @@ -341,7 +341,7 @@ Deno.test("CLI: workflow run with missing required input fails", async () => {
);

assertEquals(result.code !== 0, true, "Should fail for missing input");
assertStringIncludes(result.stderr, "environment");
assertStringIncludes(result.stderr + result.stdout, "environment");
});
});

Expand Down Expand Up @@ -492,7 +492,7 @@ Deno.test("CLI: input validation reports type mismatch", async () => {
);

assertEquals(result.code !== 0, true, "Should fail for type mismatch");
assertStringIncludes(result.stderr, "must be a string");
assertStringIncludes(result.stderr + result.stdout, "must be a string");
});
});

Expand Down Expand Up @@ -550,8 +550,8 @@ Deno.test("CLI: input validation reports multiple errors", async () => {

assertEquals(result.code !== 0, true, "Should fail for multiple errors");
// Should report both errors
assertStringIncludes(result.stderr, "name");
assertStringIncludes(result.stderr, "count");
assertStringIncludes(result.stderr + result.stdout, "name");
assertStringIncludes(result.stderr + result.stdout, "count");
});
});

Expand Down Expand Up @@ -933,8 +933,8 @@ Deno.test("CLI: method validates required inputs that it references", async () =
true,
"Should fail without referenced required inputs",
);
assertStringIncludes(result.stderr, "dropletName");
assertStringIncludes(result.stderr, "region");
assertStringIncludes(result.stderr + result.stdout, "dropletName");
assertStringIncludes(result.stderr + result.stdout, "region");
});
});

Expand Down
Loading
Loading