Skip to content

Commit 602820a

Browse files
authored
feat(plugin): wire ITripleStore into StatusButtonGroupBuilder for vault-based workflows (#2420)
- Add optional tripleStore to ButtonBuilderServices interface - Pass triple store from plugin's SPARQLApi to WorkflowResolver instead of creating an empty InMemoryTripleStore - Fix WorkflowResolver.loadWorkflowBySubject to return null when workflow has no states and no transitions (triggers hardcoded fallback) - Add tripleStore to ButtonGroupsBuilderConfig and wire through renderer - Add 3 unit tests for null-return behavior - Update existing test to reflect correct fallback behavior Closes #2419
1 parent 62a7605 commit 602820a

File tree

7 files changed

+132
-11
lines changed

7 files changed

+132
-11
lines changed

packages/exocortex/src/services/WorkflowResolver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ export class WorkflowResolver {
198198
// Load transitions
199199
const transitions = await this.loadTransitions(workflowSubject);
200200

201+
if (states.length === 0 && transitions.length === 0) {
202+
return null;
203+
}
204+
201205
return {
202206
id,
203207
name,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { InMemoryTripleStore } from "../../../src/infrastructure/rdf/InMemoryTripleStore";
2+
import { WorkflowResolver } from "../../../src/services/WorkflowResolver";
3+
import { Triple } from "../../../src/domain/models/rdf/Triple";
4+
import { IRI } from "../../../src/domain/models/rdf/IRI";
5+
import { Literal } from "../../../src/domain/models/rdf/Literal";
6+
import { Namespace } from "../../../src/domain/models/rdf/Namespace";
7+
import { AssetClass } from "../../../src/domain/constants/AssetClass";
8+
import { EffortStatus } from "../../../src/domain/constants/EffortStatus";
9+
10+
describe("WorkflowResolver - null return for empty workflow data", () => {
11+
let store: InMemoryTripleStore;
12+
let resolver: WorkflowResolver;
13+
14+
beforeEach(() => {
15+
store = new InMemoryTripleStore();
16+
resolver = new WorkflowResolver(store);
17+
});
18+
19+
describe("loadWorkflowBySubject returns null", () => {
20+
it("should return null when workflow IRI has no states and no transitions", async () => {
21+
const workflowIRI = new IRI("obsidian://vault/empty-workflow.md");
22+
23+
await store.addAll([
24+
new Triple(workflowIRI, Namespace.RDF.term("type"), Namespace.EMS.term("Workflow")),
25+
new Triple(workflowIRI, Namespace.EXO.term("Asset_uid"), new Literal("empty-workflow")),
26+
new Triple(workflowIRI, Namespace.EXO.term("Asset_label"), new Literal("Empty Workflow")),
27+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_targetClass"), Namespace.EMS.term("Task")),
28+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_isDefault"), new Literal("true")),
29+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_initialState"), new Literal(EffortStatus.DRAFT)),
30+
]);
31+
32+
const result = await resolver.resolveForClass(AssetClass.TASK);
33+
34+
expect(result.id).toBe("hardcoded-task-default");
35+
expect(result.name).toContain("hardcoded");
36+
});
37+
});
38+
39+
describe("resolveForAsset falls back to class default", () => {
40+
it("should fall back to hardcoded when asset-specific workflow has no states/transitions", async () => {
41+
const assetIRI = new IRI("obsidian://vault/my-task.md");
42+
const workflowIRI = new IRI("obsidian://vault/empty-workflow.md");
43+
44+
await store.addAll([
45+
new Triple(assetIRI, Namespace.EMS.term("Effort_workflow"), workflowIRI),
46+
new Triple(workflowIRI, Namespace.RDF.term("type"), Namespace.EMS.term("Workflow")),
47+
new Triple(workflowIRI, Namespace.EXO.term("Asset_uid"), new Literal("empty-workflow")),
48+
new Triple(workflowIRI, Namespace.EXO.term("Asset_label"), new Literal("Empty Workflow")),
49+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_targetClass"), Namespace.EMS.term("Task")),
50+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_isDefault"), new Literal("false")),
51+
]);
52+
53+
const result = await resolver.resolveForAsset(assetIRI, AssetClass.TASK);
54+
55+
expect(result.id).toBe("hardcoded-task-default");
56+
});
57+
});
58+
59+
describe("resolveForClass with populated workflow", () => {
60+
it("should return vault workflow when states and transitions exist", async () => {
61+
const workflowIRI = new IRI("obsidian://vault/real-workflow.md");
62+
63+
await store.addAll([
64+
new Triple(workflowIRI, Namespace.RDF.term("type"), Namespace.EMS.term("Workflow")),
65+
new Triple(workflowIRI, Namespace.EXO.term("Asset_uid"), new Literal("real-workflow")),
66+
new Triple(workflowIRI, Namespace.EXO.term("Asset_label"), new Literal("Real Workflow")),
67+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_targetClass"), Namespace.EMS.term("Task")),
68+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_isDefault"), new Literal("true")),
69+
new Triple(workflowIRI, Namespace.EMS.term("Workflow_initialState"), new Literal(EffortStatus.DRAFT)),
70+
]);
71+
72+
const stateIRI = new IRI("obsidian://vault/state-draft.md");
73+
await store.addAll([
74+
new Triple(stateIRI, Namespace.RDF.term("type"), Namespace.EMS.term("WorkflowState")),
75+
new Triple(stateIRI, Namespace.EMS.term("WorkflowState_workflow"), workflowIRI),
76+
new Triple(stateIRI, Namespace.EMS.term("WorkflowState_status"), new Literal(EffortStatus.DRAFT)),
77+
new Triple(stateIRI, Namespace.EMS.term("WorkflowState_order"), new Literal("1")),
78+
]);
79+
80+
const transIRI = new IRI("obsidian://vault/trans-draft-backlog.md");
81+
await store.addAll([
82+
new Triple(transIRI, Namespace.RDF.term("type"), Namespace.EMS.term("WorkflowTransition")),
83+
new Triple(transIRI, Namespace.EMS.term("WorkflowTransition_workflow"), workflowIRI),
84+
new Triple(transIRI, Namespace.EMS.term("WorkflowTransition_from"), new Literal(EffortStatus.DRAFT)),
85+
new Triple(transIRI, Namespace.EMS.term("WorkflowTransition_to"), new Literal(EffortStatus.BACKLOG)),
86+
new Triple(transIRI, Namespace.EMS.term("WorkflowTransition_label"), new Literal("Move to Backlog")),
87+
]);
88+
89+
const result = await resolver.resolveForClass(AssetClass.TASK);
90+
91+
expect(result.id).toBe("real-workflow");
92+
expect(result.name).toBe("Real Workflow");
93+
expect(result.states).toHaveLength(1);
94+
expect(result.transitions).toHaveLength(1);
95+
});
96+
});
97+
});

packages/exocortex/tests/unit/services/WorkflowResolver.perProject.test.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,22 +199,20 @@ describe("WorkflowResolver — per-project workflow override (ems__Effort_workfl
199199
expect(workflow.isDefault).toBe(false);
200200
});
201201

202-
it("should return a degraded workflow when ems__Effort_workflow points to non-existent workflow subject", async () => {
203-
// When the workflow IRI exists as a reference but has no triples in the store,
204-
// loadWorkflowBySubject still constructs a minimal WorkflowDefinition with defaults.
205-
// This is by design: the IRI is valid, it just has no states/transitions loaded.
202+
it("should fall back to class default when ems__Effort_workflow points to non-existent workflow subject", async () => {
203+
// When the workflow IRI exists as a reference but has no states/transitions in the store,
204+
// loadWorkflowBySubject returns null, causing resolveForAsset to fall back to
205+
// the class default (hardcoded fallback).
206206
const assetSubject = new IRI("obsidian://vault/my-project.md");
207207
const nonExistentWorkflow = new IRI("obsidian://vault/does-not-exist.md");
208208
await setAssetWorkflow(store, assetSubject, nonExistentWorkflow);
209209

210210
const workflow = await resolver.resolveForAsset(assetSubject, AssetClass.PROJECT);
211211

212-
// Uses the IRI value as id (no uid triple found), and "Unknown Workflow" as name
213-
expect(workflow.id).toBe("obsidian://vault/does-not-exist.md");
214-
expect(workflow.name).toBe("Unknown Workflow");
215-
expect(workflow.states).toHaveLength(0);
216-
expect(workflow.transitions).toHaveLength(0);
217-
// Terminal states default to [DONE, TRASHED] when none specified
212+
expect(workflow.id).toBe("hardcoded-project-default");
213+
expect(workflow.name).toContain("hardcoded");
214+
expect(workflow.states.length).toBeGreaterThan(0);
215+
expect(workflow.transitions.length).toBeGreaterThan(0);
218216
expect(workflow.terminalStates).toContain(EffortStatus.DONE);
219217
expect(workflow.terminalStates).toContain(EffortStatus.TRASHED);
220218
});

packages/obsidian-plugin/src/presentation/builders/ButtonGroupsBuilder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
MetadataExtractor,
2121
CriticalityZoneService,
2222
} from "exocortex";
23+
import type { ITripleStore } from "exocortex";
2324
import {
2425
ButtonBuilderContext,
2526
ButtonBuilderServices,
@@ -74,6 +75,8 @@ export interface ButtonGroupsBuilderConfig {
7475
metadataExtractor: MetadataExtractor;
7576
/** Logger instance */
7677
logger: ILogger;
78+
/** Triple store for workflow resolution (optional, falls back to empty store) */
79+
tripleStore?: ITripleStore;
7780
/** Callback to refresh the view */
7881
refresh: () => Promise<void>;
7982
}
@@ -115,6 +118,7 @@ export class ButtonGroupsBuilder {
115118
labelToAliasService,
116119
assetConversionService,
117120
criticalityZoneService,
121+
tripleStore,
118122
metadataExtractor,
119123
logger,
120124
refresh,
@@ -142,6 +146,7 @@ export class ButtonGroupsBuilder {
142146
effortVotingService,
143147
labelToAliasService,
144148
assetConversionService,
149+
tripleStore,
145150
};
146151

147152
// Initialize specialized builders

packages/obsidian-plugin/src/presentation/builders/button-groups/ButtonBuilderTypes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { RenameToUidService } from "exocortex";
1515
import { EffortVotingService } from "exocortex";
1616
import { LabelToAliasService } from "exocortex";
1717
import { AssetConversionService } from "exocortex";
18+
import type { ITripleStore } from "exocortex";
1819
import { ObsidianApp, ExocortexPluginInterface, MetadataRecord } from '@plugin/types';
1920

2021
/**
@@ -49,6 +50,7 @@ export interface ButtonBuilderServices {
4950
effortVotingService: EffortVotingService;
5051
labelToAliasService: LabelToAliasService;
5152
assetConversionService: AssetConversionService;
53+
tripleStore?: ITripleStore;
5254
}
5355

5456
/**

packages/obsidian-plugin/src/presentation/builders/button-groups/StatusButtonGroupBuilder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export class StatusButtonGroupBuilder implements IButtonGroupBuilder {
7676
const currentStatus = Object.values(EffortStatus).find((s) => s === normalized);
7777
if (!currentStatus) return [];
7878

79-
const resolver = new WorkflowResolver(new InMemoryTripleStore());
79+
const store = this.services.tripleStore ?? new InMemoryTripleStore();
80+
const resolver = new WorkflowResolver(store);
8081
const instanceClassRaw = metadata["exo__Instance_class"];
8182
const isTask = Array.isArray(instanceClassRaw)
8283
? instanceClassRaw.some((c: string) => String(c).includes("ems__Task") || String(c).includes("ems__Meeting"))

packages/obsidian-plugin/src/presentation/renderers/UniversalLayoutRenderer.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export class UniversalLayoutRenderer {
116116
this.app, this.settings, this.reactRenderer, this.backlinksCacheManager,
117117
this.metadataService, this.plugin, () => this.refresh(), this.vaultAdapter);
118118

119+
const tripleStore = this.resolveTripleStore();
120+
119121
this.buttonGroupsBuilder = new ButtonGroupsBuilder({
120122
app: this.app,
121123
settings: this.settings,
@@ -133,6 +135,7 @@ export class UniversalLayoutRenderer {
133135
labelToAliasService: services.labelToAlias,
134136
assetConversionService: services.assetConversion,
135137
criticalityZoneService: services.criticalityZone,
138+
tripleStore,
136139
metadataExtractor: this.metadataExtractor,
137140
logger: this.logger,
138141
refresh: () => this.refresh(),
@@ -155,6 +158,17 @@ export class UniversalLayoutRenderer {
155158
});
156159
}
157160

161+
private resolveTripleStore() {
162+
const pluginAny = this.plugin as unknown as Record<string, unknown>;
163+
if (
164+
pluginAny.sparql &&
165+
typeof (pluginAny.sparql as Record<string, unknown>).getTripleStore === "function"
166+
) {
167+
return (pluginAny.sparql as { getTripleStore(): import("exocortex").ITripleStore }).getTripleStore();
168+
}
169+
return undefined;
170+
}
171+
158172
private resolveServices() {
159173
return {
160174
taskCreation: container.resolve(TaskCreationService),

0 commit comments

Comments
 (0)