Skip to content

Commit 1ee6ecd

Browse files
stack72umagclaude
authored
fix: collect writer handles in RawExecutionDriver for extension model dataArtifacts (#908)
## Summary Fixes #907 `RawExecutionDriver` ignored `getHandles()` from both `createResourceWriter` and `createFileWriterFactory`. After method execution, it relied on `result.dataHandles` from the method's return value — which is `undefined` for extension models that return `{}`. **Impact:** All extension models using the raw driver produced empty `dataArtifacts` in their run output. This broke: - The CLI JSON output view (no artifacts shown) - Method-summary reports (empty data output table) - Output log artifact lookup (`output_logs.ts` filters on `dataArtifacts`) The data itself was persisted correctly to disk (`writeResource` works in-process), which is why this wasn't caught earlier — `swamp data get`, `swamp data list`, and CEL expressions like `data.latest()` all resolve from the filesystem and worked fine. **Fix:** Destructure `getHandles` from both writer factories, then use the combined writer handles as fallback when `result.dataHandles` is empty. Built-in models that return explicit `dataHandles` are unaffected — the fallback only triggers when the method returns no handles. - `src/domain/drivers/raw_execution_driver.ts` — wire up `getHandles` from both factories, use as fallback - `src/domain/drivers/raw_execution_driver_test.ts` — 3 new tests: writer handles collected when method returns none, explicit handles take precedence, empty when no writes ## Test Plan - [x] 3 new unit tests for `RawExecutionDriver` covering the fix and backward compatibility - [x] Full test suite passes (3680 tests, 0 failures) - [x] `deno check`, `deno lint`, `deno fmt` all clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Magistr <umag@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5388e90 commit 1ee6ecd

File tree

2 files changed

+250
-1
lines changed

2 files changed

+250
-1
lines changed

src/domain/drivers/raw_execution_driver.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class RawExecutionDriver implements ExecutionDriver {
8888

8989
const {
9090
writeResource,
91+
getHandles: getResourceHandles,
9192
} = createResourceWriter(
9293
this.context.dataRepository,
9394
this.context.modelType,
@@ -105,6 +106,7 @@ export class RawExecutionDriver implements ExecutionDriver {
105106

106107
const {
107108
createFileWriter,
109+
getHandles: getFileHandles,
108110
} = createFileWriterFactory(
109111
this.context.dataRepository,
110112
this.context.modelType,
@@ -141,7 +143,14 @@ export class RawExecutionDriver implements ExecutionDriver {
141143
);
142144

143145
const durationMs = performance.now() - start;
144-
const outputs = (result.dataHandles ?? []).map((handle) => ({
146+
const writerHandles = [
147+
...getResourceHandles(),
148+
...getFileHandles(),
149+
];
150+
const handles = result.dataHandles?.length
151+
? result.dataHandles
152+
: writerHandles;
153+
const outputs = handles.map((handle) => ({
145154
kind: "persisted" as const,
146155
handle,
147156
}));
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Swamp, an Automation Framework
2+
// Copyright (C) 2026 System Initiative, Inc.
3+
//
4+
// This file is part of Swamp.
5+
//
6+
// Swamp is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License version 3
8+
// as published by the Free Software Foundation, with the Swamp
9+
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
10+
// file).
11+
//
12+
// Swamp is distributed in the hope that it will be useful,
13+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
// GNU Affero General Public License for more details.
16+
//
17+
// You should have received a copy of the GNU Affero General Public License
18+
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.
19+
20+
import { assertEquals } from "@std/assert";
21+
import { RawExecutionDriver } from "./raw_execution_driver.ts";
22+
import type { MethodExecutor } from "./raw_execution_driver.ts";
23+
import type { ExecutionRequest } from "./execution_driver.ts";
24+
import { Definition } from "../definitions/definition.ts";
25+
import { ModelType } from "../models/model_type.ts";
26+
import type {
27+
DataHandle,
28+
MethodContext,
29+
MethodDefinition,
30+
ModelDefinition,
31+
} from "../models/model.ts";
32+
import { z } from "zod";
33+
import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts";
34+
import { type DataId, generateDataId } from "../data/data_id.ts";
35+
import { getLogger } from "@logtape/logtape";
36+
37+
const TEST_MODEL_TYPE = ModelType.create("test/raw-driver");
38+
39+
function createMockDataRepo(): UnifiedDataRepository {
40+
return {
41+
findAllGlobal: () => Promise.resolve([]),
42+
findByName: () => Promise.resolve(null),
43+
findById: () => Promise.resolve(null),
44+
listVersions: () => Promise.resolve([]),
45+
findAllForModel: () => Promise.resolve([]),
46+
save: () => Promise.resolve({ version: 1 }),
47+
append: () => Promise.resolve(),
48+
stream: async function* () {},
49+
getContent: () => Promise.resolve(null),
50+
delete: () => Promise.resolve(),
51+
removeLatestMarker: () => Promise.resolve(),
52+
nextId: () => generateDataId(),
53+
getPath: () => "",
54+
getContentPath: () => "",
55+
collectGarbage: () =>
56+
Promise.resolve({ versionsRemoved: 0, bytesReclaimed: 0 }),
57+
allocateVersion: () =>
58+
Promise.resolve({ version: 1, contentPath: "/tmp/mock" }),
59+
finalizeVersion: () =>
60+
Promise.resolve({ size: 0, checksum: "mock-checksum" }),
61+
getLatestVersionSync: () => null,
62+
findByNameSync: () => null,
63+
listVersionsSync: () => [],
64+
getContentSync: () => null,
65+
} as unknown as UnifiedDataRepository;
66+
}
67+
68+
function createMockHandle(name: string): DataHandle {
69+
return {
70+
name,
71+
specName: name,
72+
kind: "resource",
73+
dataId: `mock-${name}` as DataId,
74+
version: 1,
75+
size: 10,
76+
tags: {},
77+
metadata: {
78+
contentType: "application/json",
79+
lifetime: "infinite",
80+
garbageCollection: 10,
81+
streaming: false,
82+
tags: {},
83+
ownerDefinition: {
84+
ownerType: "model-method",
85+
ownerRef: "test",
86+
},
87+
},
88+
};
89+
}
90+
91+
const testDefinition = Definition.create({
92+
name: "test-model",
93+
type: TEST_MODEL_TYPE.normalized,
94+
});
95+
96+
const testMethod: MethodDefinition = {
97+
description: "Test method",
98+
arguments: z.object({}),
99+
execute: () => Promise.resolve({}),
100+
};
101+
102+
const testModelDef: ModelDefinition = {
103+
type: TEST_MODEL_TYPE,
104+
version: "2026.01.01.1",
105+
globalArguments: z.object({}),
106+
resources: {
107+
"output": {
108+
description: "Test output",
109+
schema: z.object({ value: z.string() }),
110+
lifetime: "ephemeral",
111+
garbageCollection: 10,
112+
},
113+
},
114+
methods: {
115+
test: testMethod,
116+
},
117+
};
118+
119+
function createMockContext(): MethodContext {
120+
return {
121+
signal: new AbortController().signal,
122+
repoDir: "/tmp/test-repo",
123+
modelType: TEST_MODEL_TYPE,
124+
modelId: testDefinition.id,
125+
globalArgs: {},
126+
definition: {
127+
id: testDefinition.id,
128+
name: testDefinition.name,
129+
version: testDefinition.version,
130+
tags: {},
131+
},
132+
methodName: "test",
133+
dataRepository: createMockDataRepo(),
134+
definitionRepository: {} as MethodContext["definitionRepository"],
135+
logger: getLogger(["test"]),
136+
} as MethodContext;
137+
}
138+
139+
function createMockRequest(): ExecutionRequest {
140+
return {
141+
protocolVersion: 1,
142+
modelType: TEST_MODEL_TYPE.normalized,
143+
modelId: testDefinition.id,
144+
methodName: "test",
145+
globalArgs: {},
146+
methodArgs: {},
147+
definitionMeta: {
148+
id: testDefinition.id,
149+
name: testDefinition.name,
150+
version: testDefinition.version,
151+
tags: {},
152+
},
153+
};
154+
}
155+
156+
Deno.test("RawExecutionDriver: collects writer handles when method returns no dataHandles", async () => {
157+
const executor: MethodExecutor = {
158+
execute: async (_def, _method, context) => {
159+
// Simulate extension model: writes resource but returns no dataHandles
160+
await context.writeResource!("output", "output", { value: "test" });
161+
return {};
162+
},
163+
};
164+
165+
const context = createMockContext();
166+
167+
const driver = new RawExecutionDriver(
168+
executor,
169+
testDefinition,
170+
testMethod,
171+
testModelDef,
172+
context,
173+
"test",
174+
);
175+
176+
const result = await driver.execute(createMockRequest());
177+
178+
assertEquals(result.status, "success");
179+
// The driver should collect handles from the writer since the method
180+
// returned no dataHandles
181+
assertEquals(result.outputs.length > 0, true);
182+
assertEquals(result.outputs[0].kind, "persisted");
183+
});
184+
185+
Deno.test("RawExecutionDriver: uses explicit dataHandles when method returns them", async () => {
186+
const explicitHandle = createMockHandle("explicit");
187+
188+
const executor: MethodExecutor = {
189+
execute: (_def, _method, _context) => {
190+
// Simulate built-in model: returns explicit dataHandles
191+
return Promise.resolve({ dataHandles: [explicitHandle] });
192+
},
193+
};
194+
195+
const context = createMockContext();
196+
197+
const driver = new RawExecutionDriver(
198+
executor,
199+
testDefinition,
200+
testMethod,
201+
testModelDef,
202+
context,
203+
"test",
204+
);
205+
206+
const result = await driver.execute(createMockRequest());
207+
208+
assertEquals(result.status, "success");
209+
assertEquals(result.outputs.length, 1);
210+
assertEquals(result.outputs[0].kind, "persisted");
211+
const output = result.outputs[0];
212+
if (output.kind === "persisted") {
213+
assertEquals(output.handle, explicitHandle);
214+
}
215+
});
216+
217+
Deno.test("RawExecutionDriver: returns empty outputs when no writes and no dataHandles", async () => {
218+
const executor: MethodExecutor = {
219+
execute: () => {
220+
// Method does nothing
221+
return Promise.resolve({});
222+
},
223+
};
224+
225+
const context = createMockContext();
226+
227+
const driver = new RawExecutionDriver(
228+
executor,
229+
testDefinition,
230+
testMethod,
231+
testModelDef,
232+
context,
233+
"test",
234+
);
235+
236+
const result = await driver.execute(createMockRequest());
237+
238+
assertEquals(result.status, "success");
239+
assertEquals(result.outputs.length, 0);
240+
});

0 commit comments

Comments
 (0)