@united-workforce/cli 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/__tests__/config-text-renderer.test.d.ts +2 -0
- package/dist/__tests__/config-text-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/config-text-renderer.test.js +137 -0
- package/dist/__tests__/config-text-renderer.test.js.map +1 -0
- package/dist/__tests__/issue-180-workflow-ref-removed.test.js +1 -1
- package/dist/__tests__/thread-agent-failure-suspended.test.d.ts +2 -0
- package/dist/__tests__/thread-agent-failure-suspended.test.d.ts.map +1 -0
- package/dist/__tests__/thread-agent-failure-suspended.test.js +332 -0
- package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -0
- package/dist/__tests__/thread-join.test.d.ts +2 -0
- package/dist/__tests__/thread-join.test.d.ts.map +1 -0
- package/dist/__tests__/thread-join.test.js +77 -0
- package/dist/__tests__/thread-join.test.js.map +1 -0
- package/dist/__tests__/thread-poke.test.js +4 -1
- package/dist/__tests__/thread-poke.test.js.map +1 -1
- package/dist/__tests__/workflow-paths.test.d.ts +2 -0
- package/dist/__tests__/workflow-paths.test.d.ts.map +1 -0
- package/dist/__tests__/workflow-paths.test.js +261 -0
- package/dist/__tests__/workflow-paths.test.js.map +1 -0
- package/dist/cli.js +18 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +69 -3
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/thread.d.ts +12 -0
- package/dist/commands/thread.d.ts.map +1 -1
- package/dist/commands/thread.js +183 -8
- package/dist/commands/thread.js.map +1 -1
- package/dist/commands/workflow.d.ts +1 -1
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +24 -4
- package/dist/commands/workflow.js.map +1 -1
- package/dist/output-mappers.d.ts.map +1 -1
- package/dist/output-mappers.js +1 -1
- package/dist/output-mappers.js.map +1 -1
- package/dist/store.d.ts +11 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +20 -1
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-text-renderer.test.ts +156 -0
- package/src/__tests__/issue-180-workflow-ref-removed.test.ts +1 -1
- package/src/__tests__/thread-agent-failure-suspended.test.ts +406 -0
- package/src/__tests__/thread-join.test.ts +103 -0
- package/src/__tests__/thread-poke.test.ts +4 -1
- package/src/__tests__/workflow-paths.test.ts +337 -0
- package/src/cli.ts +19 -0
- package/src/commands/config.ts +74 -3
- package/src/commands/thread.ts +233 -8
- package/src/commands/workflow.ts +29 -4
- package/src/output-mappers.ts +2 -1
- package/src/store.ts +25 -1
package/src/commands/thread.ts
CHANGED
|
@@ -61,7 +61,13 @@ import {
|
|
|
61
61
|
} from "../store.js";
|
|
62
62
|
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
|
63
63
|
import { validateWorkflow } from "../validate-semantic.js";
|
|
64
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
getConfigPath,
|
|
66
|
+
getNestedValue,
|
|
67
|
+
loadConfig,
|
|
68
|
+
loadWorkflowPaths,
|
|
69
|
+
parseDotPath,
|
|
70
|
+
} from "./config.js";
|
|
65
71
|
import {
|
|
66
72
|
type ChainState,
|
|
67
73
|
collectOrderedSteps,
|
|
@@ -168,6 +174,13 @@ async function resolveActiveThreadStatus(
|
|
|
168
174
|
return "running";
|
|
169
175
|
}
|
|
170
176
|
|
|
177
|
+
// Check the persisted entry status first — agent failure suspends the thread
|
|
178
|
+
// via markThreadSuspended() without producing a $SUSPEND output in CAS.
|
|
179
|
+
const entry = getThread(uwf.varStore, threadId);
|
|
180
|
+
if (entry !== null && entry.status === "suspended") {
|
|
181
|
+
return "suspended";
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
const chain = walkChain(uwf, head);
|
|
172
185
|
const { lastOutput } = resolveEvaluateArgs(uwf, chain);
|
|
173
186
|
if (readSuspendReason(lastOutput) !== null) {
|
|
@@ -227,9 +240,20 @@ function buildResumePrompt(graphPrompt: string, supplement: string | null): stri
|
|
|
227
240
|
return `${graphPrompt}\n\n${supplement}`;
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Error thrown by failStep so that callers can catch it, persist
|
|
245
|
+
* thread state (e.g. suspend), and then re-throw / exit.
|
|
246
|
+
*/
|
|
247
|
+
class StepFailureError extends Error {
|
|
248
|
+
constructor(message: string) {
|
|
249
|
+
super(message);
|
|
250
|
+
this.name = "StepFailureError";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
230
254
|
function failStep(plog: ProcessLogger, message: string): never {
|
|
231
255
|
plog.log(PL_STEP_ERROR, message, null);
|
|
232
|
-
|
|
256
|
+
throw new StepFailureError(message);
|
|
233
257
|
}
|
|
234
258
|
|
|
235
259
|
/**
|
|
@@ -346,6 +370,49 @@ async function findWorkflowInParents(startDir: string, name: string): Promise<st
|
|
|
346
370
|
return null;
|
|
347
371
|
}
|
|
348
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Search for a workflow by name directly in a directory (not inside .workflows/).
|
|
375
|
+
* Used for workflowPaths resolution — each path dir contains YAMLs at top level.
|
|
376
|
+
* Checks flat files (<name>.yaml/.yml) and folder layout (<name>/index.yaml/.yml).
|
|
377
|
+
*/
|
|
378
|
+
async function findWorkflowInPath(dir: string, name: string): Promise<string | null> {
|
|
379
|
+
// Check flat YAML files
|
|
380
|
+
for (const ext of [".yaml", ".yml"]) {
|
|
381
|
+
const result = await workflowFileExists(dir, name, ext);
|
|
382
|
+
if (result !== null) {
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Check folder-based layout (<name>/index.yaml)
|
|
387
|
+
for (const indexName of ["index.yaml", "index.yml"]) {
|
|
388
|
+
const candidate = resolvePath(dir, name, indexName);
|
|
389
|
+
try {
|
|
390
|
+
await access(candidate);
|
|
391
|
+
return candidate;
|
|
392
|
+
} catch {
|
|
393
|
+
/* not found */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Search workflowPaths directories for a workflow by name.
|
|
401
|
+
* Searches each directory in order; first match wins.
|
|
402
|
+
*/
|
|
403
|
+
async function findWorkflowInPaths(
|
|
404
|
+
dirs: ReadonlyArray<string>,
|
|
405
|
+
name: string,
|
|
406
|
+
): Promise<string | null> {
|
|
407
|
+
for (const dir of dirs) {
|
|
408
|
+
const found = await findWorkflowInPath(dir, name);
|
|
409
|
+
if (found !== null) {
|
|
410
|
+
return found;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
349
416
|
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
|
|
350
417
|
let text: string;
|
|
351
418
|
try {
|
|
@@ -421,6 +488,13 @@ async function resolveWorkflowCasRef(
|
|
|
421
488
|
return materializeLocalWorkflow(uwf, localPath);
|
|
422
489
|
}
|
|
423
490
|
|
|
491
|
+
// Strategy 3.5: workflowPaths global directories
|
|
492
|
+
const workflowPaths = loadWorkflowPaths(uwf.storageRoot);
|
|
493
|
+
const pathsFile = await findWorkflowInPaths(workflowPaths, trimmed);
|
|
494
|
+
if (pathsFile !== null) {
|
|
495
|
+
return materializeLocalWorkflow(uwf, pathsFile);
|
|
496
|
+
}
|
|
497
|
+
|
|
424
498
|
// Strategy 4: Global registry fallback
|
|
425
499
|
const registry = loadWorkflowRegistry(uwf.varStore);
|
|
426
500
|
const hash = resolveWorkflowHash(registry, trimmed);
|
|
@@ -1378,7 +1452,18 @@ export async function cmdThreadPoke(
|
|
|
1378
1452
|
// Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
|
|
1379
1453
|
// the active thread head). After the agent returns, we rewrite that node's prev so
|
|
1380
1454
|
// that the new head replaces the old head instead of appending after it.
|
|
1381
|
-
|
|
1455
|
+
let agentResult: AdapterOutput;
|
|
1456
|
+
try {
|
|
1457
|
+
agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
if (e instanceof StepFailureError) {
|
|
1460
|
+
// Fatal agent failure in poke — persist suspended state before propagating
|
|
1461
|
+
const uwfErr = await createUwfStore(storageRoot);
|
|
1462
|
+
const errEntry = getThread(uwfErr.varStore, threadId) ?? entry;
|
|
1463
|
+
setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
|
|
1464
|
+
}
|
|
1465
|
+
throw e;
|
|
1466
|
+
}
|
|
1382
1467
|
const agentStepHash = agentResult.stepHash as CasRef;
|
|
1383
1468
|
|
|
1384
1469
|
plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
|
|
@@ -1507,6 +1592,95 @@ export async function cmdThreadExec(
|
|
|
1507
1592
|
}
|
|
1508
1593
|
}
|
|
1509
1594
|
|
|
1595
|
+
const JOIN_POLL_INTERVAL_MS = 1000;
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Block until a running thread finishes (marker disappears), then return the
|
|
1599
|
+
* final thread state in the same `StepOutput[]` format that `cmdThreadExec`
|
|
1600
|
+
* produces.
|
|
1601
|
+
*
|
|
1602
|
+
* - If the thread is currently running → poll until it stops, then return
|
|
1603
|
+
* its final state.
|
|
1604
|
+
* - If the thread is not running → return its current state immediately.
|
|
1605
|
+
*
|
|
1606
|
+
* An optional `timeoutMs` aborts the wait with an error when exceeded.
|
|
1607
|
+
*/
|
|
1608
|
+
export async function cmdThreadJoin(
|
|
1609
|
+
storageRoot: string,
|
|
1610
|
+
threadId: ThreadId,
|
|
1611
|
+
timeoutMs: number | null,
|
|
1612
|
+
): Promise<StepOutput[]> {
|
|
1613
|
+
const uwf = await createUwfStore(storageRoot);
|
|
1614
|
+
const entry = getThread(uwf.varStore, threadId);
|
|
1615
|
+
if (entry === null) {
|
|
1616
|
+
fail(`thread not found: ${threadId}`);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Wait for running marker to disappear
|
|
1620
|
+
const deadline = timeoutMs !== null ? Date.now() + timeoutMs : null;
|
|
1621
|
+
|
|
1622
|
+
while ((await isThreadRunning(storageRoot, threadId)) !== null) {
|
|
1623
|
+
if (deadline !== null && Date.now() >= deadline) {
|
|
1624
|
+
fail(`join timed out after ${timeoutMs}ms — thread ${threadId} is still running`);
|
|
1625
|
+
}
|
|
1626
|
+
await new Promise<void>((resolve) => {
|
|
1627
|
+
setTimeout(resolve, JOIN_POLL_INTERVAL_MS);
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Thread is no longer running — read final state.
|
|
1632
|
+
// Re-open the store to get the latest state written by the worker.
|
|
1633
|
+
const freshUwf = await createUwfStore(storageRoot);
|
|
1634
|
+
const freshEntry = getThread(freshUwf.varStore, threadId);
|
|
1635
|
+
if (freshEntry === null) {
|
|
1636
|
+
fail(`thread disappeared after join: ${threadId}`);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const activeHead = freshEntry.head;
|
|
1640
|
+
const workflowHash = resolveWorkflowFromHead(freshUwf, activeHead);
|
|
1641
|
+
if (workflowHash === null) {
|
|
1642
|
+
fail(`failed to resolve workflow from head: ${activeHead}`);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// Build the StepOutput matching exec's format
|
|
1646
|
+
if (freshEntry.status === "end" || freshEntry.status === "cancelled") {
|
|
1647
|
+
return [
|
|
1648
|
+
{
|
|
1649
|
+
workflow: workflowHash,
|
|
1650
|
+
thread: threadId,
|
|
1651
|
+
head: activeHead,
|
|
1652
|
+
status: freshEntry.status,
|
|
1653
|
+
currentRole: null,
|
|
1654
|
+
suspendedRole: null,
|
|
1655
|
+
suspendMessage: null,
|
|
1656
|
+
done: true,
|
|
1657
|
+
background: null,
|
|
1658
|
+
error: null,
|
|
1659
|
+
},
|
|
1660
|
+
];
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Active thread — resolve detailed status
|
|
1664
|
+
const status = await resolveActiveThreadStatus(storageRoot, threadId, freshUwf, activeHead);
|
|
1665
|
+
const currentRole = resolveCurrentRole(freshUwf, activeHead, workflowHash);
|
|
1666
|
+
const suspendFields = resolveSuspendFieldsForShow(freshEntry, status, freshUwf, activeHead);
|
|
1667
|
+
|
|
1668
|
+
return [
|
|
1669
|
+
{
|
|
1670
|
+
workflow: workflowHash,
|
|
1671
|
+
thread: threadId,
|
|
1672
|
+
head: activeHead,
|
|
1673
|
+
status,
|
|
1674
|
+
currentRole,
|
|
1675
|
+
suspendedRole: suspendFields.suspendedRole,
|
|
1676
|
+
suspendMessage: suspendFields.suspendMessage,
|
|
1677
|
+
done: false,
|
|
1678
|
+
background: null,
|
|
1679
|
+
error: null,
|
|
1680
|
+
},
|
|
1681
|
+
];
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1510
1684
|
async function resolveActiveThreadWorkflowHash(
|
|
1511
1685
|
storageRoot: string,
|
|
1512
1686
|
threadId: ThreadId,
|
|
@@ -1761,6 +1935,51 @@ async function cmdThreadStepOnce(
|
|
|
1761
1935
|
});
|
|
1762
1936
|
|
|
1763
1937
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
|
1938
|
+
|
|
1939
|
+
// Wrap agent execution in a try-catch: when the agent command crashes
|
|
1940
|
+
// (non-zero exit, unparseable output, invalid CAS node, etc.), failStep throws
|
|
1941
|
+
// StepFailureError. We catch it to persist suspended state before re-throwing
|
|
1942
|
+
// so the CLI still exits non-zero.
|
|
1943
|
+
try {
|
|
1944
|
+
return await executeAndProcessAgentStep(
|
|
1945
|
+
storageRoot,
|
|
1946
|
+
threadId,
|
|
1947
|
+
headHash,
|
|
1948
|
+
workflowHash,
|
|
1949
|
+
workflow,
|
|
1950
|
+
role,
|
|
1951
|
+
edgePrompt,
|
|
1952
|
+
effectiveCwd,
|
|
1953
|
+
agent,
|
|
1954
|
+
plog,
|
|
1955
|
+
);
|
|
1956
|
+
} catch (e) {
|
|
1957
|
+
if (e instanceof StepFailureError) {
|
|
1958
|
+
// Fatal agent failure — persist suspended state before propagating
|
|
1959
|
+
const uwfErr = await createUwfStore(storageRoot);
|
|
1960
|
+
const errEntry = getThread(uwfErr.varStore, threadId) ?? createThreadIndexEntry(headHash);
|
|
1961
|
+
setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
|
|
1962
|
+
}
|
|
1963
|
+
throw e;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Execute the agent command and process the result. Separated from cmdThreadStepOnce
|
|
1969
|
+
* so that fatal failures (StepFailureError) can be caught and handled by the caller.
|
|
1970
|
+
*/
|
|
1971
|
+
async function executeAndProcessAgentStep(
|
|
1972
|
+
storageRoot: string,
|
|
1973
|
+
threadId: ThreadId,
|
|
1974
|
+
headHash: CasRef,
|
|
1975
|
+
workflowHash: CasRef,
|
|
1976
|
+
workflow: WorkflowPayload,
|
|
1977
|
+
role: string,
|
|
1978
|
+
edgePrompt: string,
|
|
1979
|
+
effectiveCwd: string,
|
|
1980
|
+
agent: AgentConfig,
|
|
1981
|
+
plog: ProcessLogger,
|
|
1982
|
+
): Promise<StepOutput> {
|
|
1764
1983
|
const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
|
|
1765
1984
|
const newHead = agentResult.stepHash as CasRef;
|
|
1766
1985
|
|
|
@@ -1778,22 +1997,28 @@ async function cmdThreadStepOnce(
|
|
|
1778
1997
|
// next exec (until eventual success records `previousAttempts` linking the
|
|
1779
1998
|
// failed step hashes).
|
|
1780
1999
|
if (agentResult.isError === true) {
|
|
2000
|
+
const errorMsg = agentResult.errorMessage ?? "agent reported error";
|
|
1781
2001
|
plog.log(
|
|
1782
2002
|
PL_AGENT_ERROR,
|
|
1783
|
-
`agent reported recoverable failure stepHash=${newHead} message=${
|
|
2003
|
+
`agent reported recoverable failure stepHash=${newHead} message=${errorMsg}`,
|
|
1784
2004
|
null,
|
|
1785
2005
|
);
|
|
2006
|
+
|
|
2007
|
+
// Persist suspended state so `thread list --status suspended` reflects the failure.
|
|
2008
|
+
const priorEntry = getThread(uwfAfter.varStore, threadId) ?? createThreadIndexEntry(headHash);
|
|
2009
|
+
setThread(uwfAfter.varStore, threadId, markThreadSuspended(priorEntry, role, errorMsg));
|
|
2010
|
+
|
|
1786
2011
|
return {
|
|
1787
2012
|
workflow: workflowHash,
|
|
1788
2013
|
thread: threadId,
|
|
1789
2014
|
head: headHash,
|
|
1790
|
-
status: "
|
|
2015
|
+
status: "suspended",
|
|
1791
2016
|
currentRole: role,
|
|
1792
|
-
suspendedRole:
|
|
1793
|
-
suspendMessage:
|
|
2017
|
+
suspendedRole: role,
|
|
2018
|
+
suspendMessage: errorMsg,
|
|
1794
2019
|
done: false,
|
|
1795
2020
|
background: null,
|
|
1796
|
-
error: { stepHash: newHead, message:
|
|
2021
|
+
error: { stepHash: newHead, message: errorMsg },
|
|
1797
2022
|
};
|
|
1798
2023
|
}
|
|
1799
2024
|
|
package/src/commands/workflow.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createIncludeTag } from "../include.js";
|
|
|
10
10
|
import {
|
|
11
11
|
createUwfStore,
|
|
12
12
|
discoverProjectWorkflows,
|
|
13
|
+
discoverWorkflowPathsEntries,
|
|
13
14
|
findRegistryName,
|
|
14
15
|
loadWorkflowRegistry,
|
|
15
16
|
resolveProjectWorkflowFile,
|
|
@@ -24,8 +25,9 @@ import {
|
|
|
24
25
|
parseWorkflowPayload,
|
|
25
26
|
} from "../validate.js";
|
|
26
27
|
import { validateWorkflow } from "../validate-semantic.js";
|
|
28
|
+
import { loadWorkflowPaths } from "./config.js";
|
|
27
29
|
|
|
28
|
-
export type WorkflowOrigin = "local" | "global";
|
|
30
|
+
export type WorkflowOrigin = "local" | "paths" | "global";
|
|
29
31
|
|
|
30
32
|
export type WorkflowListEntry = {
|
|
31
33
|
name: string;
|
|
@@ -160,6 +162,9 @@ export async function cmdWorkflowAdd(
|
|
|
160
162
|
storageRoot: string,
|
|
161
163
|
filePath: string,
|
|
162
164
|
): Promise<WorkflowAddOutput> {
|
|
165
|
+
process.stderr.write(
|
|
166
|
+
"warning: `uwf workflow add` is deprecated. Use workflowPaths in ~/.uwf/config.yaml instead. See issue #360.\n",
|
|
167
|
+
);
|
|
163
168
|
let text: string;
|
|
164
169
|
try {
|
|
165
170
|
text = await readFile(filePath, "utf8");
|
|
@@ -295,6 +300,14 @@ async function resolveWorkflowCasRefForShow(
|
|
|
295
300
|
return materializeLocalWorkflowForShow(uwf, localPath);
|
|
296
301
|
}
|
|
297
302
|
|
|
303
|
+
// Strategy 3.5: workflowPaths global directories
|
|
304
|
+
const workflowPaths = loadWorkflowPaths(uwf.storageRoot);
|
|
305
|
+
const pathsEntries = await discoverWorkflowPathsEntries(workflowPaths);
|
|
306
|
+
const pathsFile = resolveProjectWorkflowFile(pathsEntries, trimmed);
|
|
307
|
+
if (pathsFile !== null) {
|
|
308
|
+
return materializeLocalWorkflowForShow(uwf, pathsFile);
|
|
309
|
+
}
|
|
310
|
+
|
|
298
311
|
// Strategy 4: Global registry fallback
|
|
299
312
|
const registry = loadWorkflowRegistry(uwf.varStore);
|
|
300
313
|
const hash = resolveWorkflowHash(registry, trimmed);
|
|
@@ -344,18 +357,30 @@ export async function cmdWorkflowList(
|
|
|
344
357
|
): Promise<WorkflowListEntry[]> {
|
|
345
358
|
const uwf = await createUwfStore(storageRoot);
|
|
346
359
|
const localEntries = await discoverProjectWorkflows(projectRoot);
|
|
360
|
+
const workflowPaths = loadWorkflowPaths(storageRoot);
|
|
361
|
+
const pathsEntries = await discoverWorkflowPathsEntries(workflowPaths);
|
|
347
362
|
const registry = loadWorkflowRegistry(uwf.varStore);
|
|
348
363
|
|
|
349
364
|
const result: WorkflowListEntry[] = [];
|
|
350
|
-
const
|
|
365
|
+
const seenNames = new Set<string>();
|
|
351
366
|
|
|
367
|
+
// Layer 1: local .workflows/ (highest priority)
|
|
352
368
|
for (const entry of localEntries) {
|
|
353
|
-
|
|
369
|
+
seenNames.add(entry.name);
|
|
354
370
|
result.push({ name: entry.name, hash: "(local)", origin: "local" });
|
|
355
371
|
}
|
|
356
372
|
|
|
373
|
+
// Layer 2: workflowPaths directories
|
|
374
|
+
for (const entry of pathsEntries) {
|
|
375
|
+
if (!seenNames.has(entry.name)) {
|
|
376
|
+
seenNames.add(entry.name);
|
|
377
|
+
result.push({ name: entry.name, hash: "(paths)", origin: "paths" });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Layer 3: global registry (lowest priority)
|
|
357
382
|
for (const [name, hash] of Object.entries(registry)) {
|
|
358
|
-
if (!
|
|
383
|
+
if (!seenNames.has(name)) {
|
|
359
384
|
result.push({ name, hash, origin: "global" });
|
|
360
385
|
}
|
|
361
386
|
}
|
package/src/output-mappers.ts
CHANGED
|
@@ -229,7 +229,8 @@ export function toWorkflowListPayload(entries: WorkflowListEntry[]): WorkflowLis
|
|
|
229
229
|
items: entries.map((e) => ({
|
|
230
230
|
name: e.name,
|
|
231
231
|
hash: e.hash,
|
|
232
|
-
source:
|
|
232
|
+
source:
|
|
233
|
+
e.origin === "local" ? ".workflows" : e.origin === "paths" ? "workflowPaths" : "registry",
|
|
233
234
|
description: "",
|
|
234
235
|
})),
|
|
235
236
|
};
|
package/src/store.ts
CHANGED
|
@@ -56,7 +56,7 @@ async function findIndexWorkflow(
|
|
|
56
56
|
* Scan a single directory for workflow entries (flat YAML files + folder/index.yaml).
|
|
57
57
|
* Returns discovered entries. Returns empty array if directory does not exist.
|
|
58
58
|
*/
|
|
59
|
-
async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
|
|
59
|
+
export async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
|
|
60
60
|
let dirents: Dirent[];
|
|
61
61
|
try {
|
|
62
62
|
dirents = await readdir(dir, { withFileTypes: true });
|
|
@@ -159,6 +159,30 @@ export function getDefaultStorageRoot(): string {
|
|
|
159
159
|
return join(homedir(), ".uwf");
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Discover workflows from workflowPaths directories.
|
|
164
|
+
* Each directory is scanned directly for YAML files (like scanWorkflowDir).
|
|
165
|
+
* Earlier dirs in the list take priority on name collisions.
|
|
166
|
+
*/
|
|
167
|
+
export async function discoverWorkflowPathsEntries(
|
|
168
|
+
dirs: ReadonlyArray<string>,
|
|
169
|
+
): Promise<ProjectWorkflowEntry[]> {
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
const result: ProjectWorkflowEntry[] = [];
|
|
172
|
+
|
|
173
|
+
for (const dir of dirs) {
|
|
174
|
+
const entries = await scanWorkflowDir(dir);
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (!seen.has(entry.name)) {
|
|
177
|
+
seen.add(entry.name);
|
|
178
|
+
result.push(entry);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
162
186
|
/**
|
|
163
187
|
* Resolve storage root.
|
|
164
188
|
* Priority: `UWF_HOME` → default.
|