fix(workflows): wire markDirty hook through workflow execution path#1441
Conversation
WorkflowExecutionService and DefaultStepExecutor created their own repository instances (FileSystemUnifiedDataRepository, YamlOutputRepository, YamlEvaluatedDefinitionRepository) without the markDirty hook, so workflow-executed model methods never notified the datastore sync service about dirty writes. This also fixes buildMarkDirtyHook to handle paths outside the cache root by falling back to the repo's .swamp/ directory, since both trees share the same internal layout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
None.
Verdict
PASS — This PR makes no user-facing changes. All diffs are internal plumbing: wiring the markDirty hook through WorkflowExecutionService, DefaultStepExecutor, and the path-computation fallback in buildMarkDirtyHook. No help text, flags, error messages, log-mode output, or JSON output is affected. The only user-observable effect is that custom datastores now sync correctly after workflow runs, which is the intended behavior fix.
There was a problem hiding this comment.
Code Review
Clean, well-scoped bug fix that threads the markDirty hook through the workflow execution path so datastore sync notifications fire for workflow-executed model methods — not just direct model method run invocations.
Blocking Issues
None.
Suggestions
-
Missing test for the
buildMarkDirtyHook..fallback path (src/cli/repo_context.ts:158). The existingrelPathwiring test uses a temp dir wherecacheRootandrepoSwampDircoincide, so therel.startsWith("..")branch is never exercised. A targeted unit test that constructs the hook with divergentcacheRootandrepoDirvalues, passes anabsPathunder the repo.swamp/dir, and asserts the fallback produces a correct relative path would pin this logic. Not blocking because the fix is straightforward and the branch is a strict improvement over the prior behavior (which produced a../...traversal path). -
Growing positional parameter list on
WorkflowExecutionService— the constructor now has 8 positional parameters, several of which areundefinedat most callsites. An options-object pattern (e.g.opts: { executor?, dataBaseDir?, directTypeResolver?, markDirty? }) would make callsites self-documenting and reduce the risk of positional mix-ups. This is pre-existing and not introduced by this PR, so purely a future-improvement note.
DDD Notes
MarkDirtyHooklives in the domain layer (src/domain/datastore/datastore_sync_service.ts) and is correctly imported as a type-only dependency — the domain defines the contract, infrastructure implements it. Good boundary placement.- The
DefaultStepExecutorandWorkflowExecutionServicein the domain layer already had direct infrastructure imports (e.g.YamlOutputRepository,FileSystemUnifiedDataRepository) before this PR. ThreadingmarkDirtythrough the same path is consistent with the existing pattern, even if the broader coupling is something to revisit later. - The
buildMarkDirtyHookfunction correctly stays in the CLI/application layer (repo_context.ts), bridging the infrastructure sync service into the domain hook — proper layering.
Overall: focused fix, correct wiring at all callsites (including child workflows), no boundary violations introduced. LGTM.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
DefaultStepExecutor.fromRepoDirdoes not forwardmarkDirtyto the instance field (src/domain/workflows/execution_service.ts:275):fromRepoDircallsnew DefaultStepExecutor(await buildDeps(repoDir, opts))— the repos insidebuildDepscorrectly receiveopts.markDirty, but the executor'sthis.markDirtyprivate field is never set (onlyinjectedDepsoccupies the first constructor param). Today this is safe becauseresolveDepsshort-circuits onthis.injectedDepsand never readsthis.markDirty. However, if a future change adds a code path that readsthis.markDirtyon an executor built viafromRepoDir, the hook will silently be absent. Suggested fix: forwardopts.markDirtyto the third constructor parameter:(return new DefaultStepExecutor( await DefaultStepExecutor.buildDeps(repoDir, opts), undefined, opts.markDirty, );
fromRepoDiris not called anywhere today, so this is defensive — not a current bug.)
Low
-
buildMarkDirtyHookfallback doesn't guard double-traversal (src/cli/repo_context.ts:158-159): IfabsPathis outside bothcacheRootandrepoSwampDir,relative(repoSwampDir, absPath)also produces a..-prefixed string. The resultingrelPathforwarded tosyncService.markDirty({ relPath })would be a traversal path like../../tmp/foo, violating the contract thatrelPathis cache-relative (rule 5 in theDatastoreSyncServicedocs). In practice repos only write inside one of the two known trees, so the condition can't fire in normal operation. If you want belt-and-suspenders, a guard likeif (rel.startsWith("..")) { return syncService.markDirty(); }(bulk fallback) on the second result would be fully safe. -
8 positional constructor parameters on
WorkflowExecutionService(src/domain/workflows/execution_service.ts:1247-1255): Call sites likedeps.ts:81andhttp.ts:1599now pass(wfRepo, rnRepo, dir, undefined, undefined, catalogStore, undefined, markDirty)— fourundefinedholes. A positional swap would silently compile if the swapped types happen to overlap (e.g.,DirectTypeResolverandMarkDirtyHookare both((…) => Promise<void>) | undefined). Not a bug today, but an options-object refactor would make future additions less fragile.
Verdict
PASS — The fix correctly wires markDirty through the workflow execution path. All four call sites (workflow_run.ts, deps.ts, http.ts, child-workflow in execution_service.ts) pass the hook in the right position. The buildMarkDirtyHook fallback for out-of-cache paths is sound for the supported write locations. The RepositoryContext interface and createRepositoryContext factory are updated consistently. No critical or high severity issues found.
Summary
markDirtythroughWorkflowExecutionServiceandDefaultStepExecutor: Both classes created their own repository instances (FileSystemUnifiedDataRepository,YamlOutputRepository,YamlEvaluatedDefinitionRepository) without themarkDirtyhook fromrepoContext. Workflow-executed model methods never notified the datastore sync service about dirty writes, while the same methods run viamodel method run(which usesrepoContext's hooked repos) worked correctly.buildMarkDirtyHookpath computation for out-of-cache paths: When a repo writes to<repoDir>/.swamp/outputs/butcacheRootis~/.swamp/repos/<id>/,relative(cacheRoot, absPath)produces../...traversal. The hook now falls back torelative(repoSwampDir, absPath)when the result starts with.., since both trees share the same internal layout.markDirtyonRepositoryContextso all callers (workflow_run.ts,serve/deps.ts,serve/open/http.ts) can forward it when constructingWorkflowExecutionService. Nested child workflows also propagate the hook.Test plan
execution_service_test.tstests passrepo_context_test.tstests passdeno checkpasses with no type errorsdeno lintanddeno fmtcleanmarkDirtyfor data, outputs, and evaluated definitions🤖 Generated with Claude Code