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
30 changes: 29 additions & 1 deletion .claude/skills/swamp-model/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,15 @@ swamp model validate my-model --json
vpcId: ${{ model.my-vpc.resource.vpc.main.attributes.VpcId }}
```

2. **Model never executed**:
2. **Model never executed** — expressions referencing `model.*.resource` or
`model.*.file` are automatically skipped when the referenced model has no
data. If a method accesses a skipped field, it throws a clear error:

```
Unresolved expression in globalArguments.ssh_keys: ${{ model.ssh-key.resource... }}
```

To fix, run the referenced model first:

```bash
swamp model method run my-vpc create --json
Expand All @@ -158,6 +166,26 @@ swamp model validate my-model --json
swamp data get my-vpc vpc --json
```

### "Unresolved expression in globalArguments"

**Symptom**:
`Error: Unresolved expression in globalArguments.<field>: ${{ ... }}`

**Cause**: A `globalArguments` field contains a CEL expression that couldn't be
resolved (e.g., the referenced model has no resource data), and the method tried
to use that field.

**Solutions**:

1. **Run the referenced model first** so its data is available:

```bash
swamp model method run <referenced-model> create --json
```

2. **Use a workflow** that runs models in the correct order — dependencies are
resolved automatically within a workflow run.

### "Model type not found"

**Symptom**: `Error: Model type '<type>' not found`
Expand Down
66 changes: 66 additions & 0 deletions integration/keeb_shell_model_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,69 @@ Deno.test("CLI: command/shell model with self-reference expressions", async () =
assertEquals(output.data.attributes.exitCode, 0);
});
});

Deno.test("CLI: model method run succeeds when globalArguments has unresolvable cross-model resource expression", async () => {
await withTempDir(async (repoDir) => {
await initializeTestRepo(repoDir);

const definitionRepo = new YamlDefinitionRepository(repoDir);

// Create source model (no data — never executed)
const sourceModel = Definition.create({
name: "source-shell",
globalArguments: {
run: "echo source",
},
methods: {
execute: {
arguments: {
run: "echo source",
},
},
},
});
await definitionRepo.save(SHELL_MODEL_TYPE, sourceModel);

// Create dependent model that references source model's resource state
// in globalArguments — this resource does NOT exist yet
const dependentModel = Definition.create({
name: "dependent-shell",
globalArguments: {
run: "echo hello",
workingDir:
"${{ model.source-shell.resource.state.result.attributes.stdout }}",
},
methods: {
execute: {
arguments: {
run: "echo hello",
},
},
},
});
await definitionRepo.save(SHELL_MODEL_TYPE, dependentModel);

// Run the dependent model directly — should succeed because:
// 1. The unresolvable resource expression in globalArguments.workingDir is skipped
// 2. The method only uses the "run" argument, not "workingDir"
const result = await runCliCommand(
[
"model",
"method",
"run",
"dependent-shell",
"execute",
"--repo-dir",
repoDir,
"--json",
],
Deno.cwd(),
);

assertEquals(
result.code,
0,
`Command should succeed. stderr: ${result.stderr}`,
);
});
});
33 changes: 29 additions & 4 deletions src/domain/expressions/expression_evaluation_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import {
replaceExpressions,
} from "./expression_parser.ts";
import type { ExpressionLocation } from "./expression.ts";
import { extractModelRefs } from "./dependency_extractor.ts";
import {
extractDependencies,
extractModelRefs,
} from "./dependency_extractor.ts";
import {
buildEnvContext,
type ExpressionContext,
Expand Down Expand Up @@ -215,19 +218,41 @@ export class ExpressionEvaluationService {
// This allows methods that don't need certain inputs to run without
// those inputs being provided (e.g., delete doesn't need create-time inputs).
const inputRefs = extractInputReferencesFromCel(expr.celExpression);
let hasMissing = false;
if (inputRefs.size > 0) {
let hasMissing = false;
for (const ref of inputRefs) {
if (!providedInputKeys.has(ref)) {
hasMissing = true;
break;
}
}
if (hasMissing) {
continue;
}

// Skip expressions referencing model resource/file data that isn't
// available in context (e.g., referenced model was never executed).
// This mirrors the inputs skip above — the raw expression is preserved
// so the Proxy on globalArgs will throw if the method actually needs it.
if (!hasMissing) {
const deps = extractDependencies(expr.celExpression);
for (const dep of deps) {
if (dep.type === "resource" || dep.type === "file") {
const modelData = ctx.model[dep.modelRef];
if (
!modelData ||
(dep.type === "resource" && !modelData.resource) ||
(dep.type === "file" && !modelData.file)
) {
hasMissing = true;
break;
}
}
}
}

if (hasMissing) {
continue;
}

const value = this.celEvaluator.evaluate(expr.celExpression, ctx);
evaluatedValues.set(expr.raw, value);
}
Expand Down
27 changes: 9 additions & 18 deletions src/domain/models/method_execution_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ import {
createResourceWriter,
} from "./data_writer.ts";
import { coerceMethodArgs } from "./zod_type_coercion.ts";
import {
containsExpression,
extractInputReferencesFromCel,
} from "../expressions/expression_parser.ts";
import { containsExpression } from "../expressions/expression_parser.ts";

/**
* Maximum depth for recursive follow-up action processing.
Expand Down Expand Up @@ -133,8 +130,8 @@ export class DefaultMethodExecutionService implements MethodExecutionService {

// Populate context with global args and definition metadata.
// Wrap globalArgs in a Proxy that throws a clear error when the method
// accesses a field with an unresolved ${{ inputs.* }} expression.
// This allows methods that don't need certain inputs to succeed while
// accesses a field with an unresolved ${{ ... }} expression.
// This allows methods that don't need certain fields to succeed while
// failing fast with a helpful message if they do.
const rawGlobalArgs = definition.globalArguments;
const globalArgsProxy = new Proxy(rawGlobalArgs, {
Expand All @@ -144,13 +141,9 @@ export class DefaultMethodExecutionService implements MethodExecutionService {
typeof prop === "string" && typeof value === "string" &&
containsExpression(value)
) {
const refs = extractInputReferencesFromCel(value);
if (refs.size > 0) {
const missing = [...refs].join(", ");
throw new Error(
`Missing required input(s): ${missing} (needed by globalArguments.${prop})`,
);
}
throw new Error(
`Unresolved expression in globalArguments.${prop}: ${value}`,
);
}
return value;
},
Expand Down Expand Up @@ -219,14 +212,12 @@ export class DefaultMethodExecutionService implements MethodExecutionService {
if (modelDef.globalArguments) {
const rawGlobalArgs = currentDefinition.globalArguments;

// Identify globalArg fields with unresolved input expressions
// Identify globalArg fields with unresolved expressions (inputs,
// model resource/file refs, or any other ${{ ... }} that wasn't evaluated)
let hasUnresolved = false;
const resolvedGlobalArgs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawGlobalArgs)) {
if (
typeof value === "string" && containsExpression(value) &&
extractInputReferencesFromCel(value).size > 0
) {
if (typeof value === "string" && containsExpression(value)) {
hasUnresolved = true;
} else {
resolvedGlobalArgs[key] = value;
Expand Down
118 changes: 118 additions & 0 deletions src/domain/models/method_execution_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1530,3 +1530,121 @@ Deno.test("executeWorkflow - explicit kind overrides name inference for deletion
// kind: "action" should NOT trigger deletion markers
assertEquals(mockRepo.savedData.length, 0);
});

// ---------- Unresolved Expression Tests ----------

Deno.test("execute - Proxy throws for any unresolved expression in globalArgs", async () => {
const service = new DefaultMethodExecutionService();

const model: ModelDefinition = {
type: ModelType.create("test/unresolved-expr"),
version: "1",
methods: {
run: {
description: "Test method",
arguments: z.object({}),
execute: (_args: Record<string, unknown>, context) => {
// Access the unresolved field — should throw
const _val = context.globalArgs.ssh_keys;
return Promise.resolve({});
},
},
},
};

const definition = Definition.create({
name: "test-definition",
globalArguments: {
name: "my-server",
ssh_keys:
'${{ string(model["test-ssh-key"].resource.state.key.attributes.id) }}',
},
methods: { run: { arguments: {} } },
});

const { context } = createTestContext({ modelType: model.type });
await assertRejects(
() => service.execute(definition, model.methods.run, context),
Error,
"Unresolved expression in globalArguments.ssh_keys",
);
});

Deno.test("execute - Proxy allows access to resolved globalArgs fields", async () => {
const service = new DefaultMethodExecutionService();

let receivedName = "";
const model: ModelDefinition = {
type: ModelType.create("test/resolved-expr"),
version: "1",
methods: {
run: {
description: "Test method",
arguments: z.object({}),
execute: (_args: Record<string, unknown>, context) => {
receivedName = context.globalArgs.name as string;
return Promise.resolve({});
},
},
},
};

const definition = Definition.create({
name: "test-definition",
globalArguments: {
name: "my-server",
ssh_keys:
'${{ string(model["test-ssh-key"].resource.state.key.attributes.id) }}',
},
methods: { run: { arguments: {} } },
});

const { context } = createTestContext({ modelType: model.type });
await service.execute(definition, model.methods.run, context);
assertEquals(receivedName, "my-server");
});

Deno.test("executeWorkflow - skips validation for globalArgs with unresolved model resource expressions", async () => {
const service = new DefaultMethodExecutionService();

const schema = z.object({
name: z.string(),
ssh_keys: z.array(z.string()),
});

let receivedName = "";
const model: ModelDefinition = {
type: ModelType.create("test/unresolved-resource-expr"),
version: "1",
globalArguments: schema,
methods: {
update: {
description: "Update method",
arguments: z.object({}),
execute: (_args: Record<string, unknown>, context) => {
receivedName = context.globalArgs.name as string;
return Promise.resolve({});
},
},
},
};

const definition = Definition.create({
name: "test-definition",
globalArguments: {
name: "my-server",
ssh_keys:
'${{ [string(model["test-ssh-key"].resource.state.key.attributes.id)] }}',
},
});

const { context } = createTestContext({ modelType: model.type });
const result = await service.executeWorkflow(
definition,
model,
"update",
context,
);
assertEquals(result !== undefined, true);
assertEquals(receivedName, "my-server");
});
Loading