@loomwork/scaffold 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +54 -2
- package/dist/codegen.d.ts +23 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +62 -0
- package/dist/codegen.js.map +1 -0
- package/dist/emit.d.ts +18 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +74 -0
- package/dist/emit.js.map +1 -0
- package/dist/flowTemplates.d.ts +23 -0
- package/dist/flowTemplates.d.ts.map +1 -0
- package/dist/flowTemplates.js +348 -0
- package/dist/flowTemplates.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/names.d.ts +16 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +45 -0
- package/dist/names.js.map +1 -0
- package/dist/plugin.d.ts +39 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +1807 -0
- package/dist/plugin.js.map +1 -0
- package/dist/templates.d.ts +42 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +536 -0
- package/dist/templates.js.map +1 -0
- package/package.json +48 -3
- package/src/codegen.ts +69 -0
- package/src/emit.ts +72 -0
- package/src/flowTemplates.ts +353 -0
- package/src/index.ts +30 -0
- package/src/names.ts +43 -0
- package/src/plugin.ts +1906 -0
- package/src/templates.ts +566 -0
package/src/codegen.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { tsStringLiteral } from "./emit";
|
|
2
|
+
|
|
3
|
+
/** kebab/snake/space → PascalCase, for deriving export names. */
|
|
4
|
+
export function pascal(name: string): string {
|
|
5
|
+
return name
|
|
6
|
+
.split(/[-_\s]+/)
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** kebab/snake/space → camelCase, for deriving a valid function identifier. */
|
|
13
|
+
export function camel(name: string): string {
|
|
14
|
+
const p = pascal(name);
|
|
15
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* {@link pascal}, asserted to be a plain ASCII identifier. The template functions put it into
|
|
20
|
+
* generated source as an export name, so a name whose derivation is not an identifier
|
|
21
|
+
* (`a"; x(); //`, a leading digit) throws instead of producing code.
|
|
22
|
+
*/
|
|
23
|
+
export function pascalIdent(name: string): string {
|
|
24
|
+
const p = pascal(name);
|
|
25
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(p)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`scaffold: ${JSON.stringify(name)} does not derive a valid identifier (got ${JSON.stringify(p)}); use lowercase kebab-case starting with a letter`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate the text of `loom.gen.ts` — a static module that imports the discovered
|
|
35
|
+
* flow / agent / tool / skill / schedule / memory modules and re-exports them as
|
|
36
|
+
* `flowModules` / `agentModules` / `toolModules` / `skillModules` / `scheduleModules`
|
|
37
|
+
* / `memoryModules`, for `defineConfig({ ...all six })`.
|
|
38
|
+
*
|
|
39
|
+
* This is the replay-safe form of "auto-wire from the filesystem": the imports are
|
|
40
|
+
* STATIC (fixed at build time by `loom gen`), so the set is identical on every
|
|
41
|
+
* resume / remote worker. Each `*Paths` is module specifiers relative to the
|
|
42
|
+
* generated file (extension-less), e.g. `./flows/greet/flow`, `./agents/triage`.
|
|
43
|
+
*/
|
|
44
|
+
export function generateRegistration(
|
|
45
|
+
flowPaths: string[],
|
|
46
|
+
agentPaths: string[] = [],
|
|
47
|
+
toolPaths: string[] = [],
|
|
48
|
+
skillPaths: string[] = [],
|
|
49
|
+
schedulePaths: string[] = [],
|
|
50
|
+
memoryPaths: string[] = [],
|
|
51
|
+
projectionPaths: string[] = []
|
|
52
|
+
): string {
|
|
53
|
+
const group = (paths: string[], prefix: string, exportName: string): string[] => {
|
|
54
|
+
const imports = paths.map((p, i) => `import * as ${prefix}${i} from ${tsStringLiteral(p)};`);
|
|
55
|
+
const refs = paths.map((_, i) => `${prefix}${i}`).join(", ");
|
|
56
|
+
return [...imports, "", `export const ${exportName} = [${refs}];`, ""];
|
|
57
|
+
};
|
|
58
|
+
return [
|
|
59
|
+
"// AUTO-GENERATED by `loom gen`. Do not edit by hand.",
|
|
60
|
+
"// Static modules for defineConfig — flows/agents/tools/skills/schedules/memories/projections.",
|
|
61
|
+
...group(flowPaths, "_f", "flowModules"),
|
|
62
|
+
...group(agentPaths, "_a", "agentModules"),
|
|
63
|
+
...group(toolPaths, "_t", "toolModules"),
|
|
64
|
+
...group(skillPaths, "_s", "skillModules"),
|
|
65
|
+
...group(schedulePaths, "_sc", "scheduleModules"),
|
|
66
|
+
...group(memoryPaths, "_m", "memoryModules"),
|
|
67
|
+
...group(projectionPaths, "_p", "projectionModules"),
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ── Escaping for every place a generator interpolates a spec- or model-derived string ──
|
|
2
|
+
//
|
|
3
|
+
// The ChangeSpec is produced by a MODEL (appgen-model) or by free-form user text
|
|
4
|
+
// (parseIntent), so every string in it is untrusted. Generated TypeScript lives in the
|
|
5
|
+
// user's project, which makes any unescaped interpolation a code-injection channel
|
|
6
|
+
// (a newline ends a `//` comment, `*/` ends a block comment, `"` ends a string literal,
|
|
7
|
+
// `${` opens a template substitution). These helpers are the ONLY sanctioned way to put
|
|
8
|
+
// such text into generated source; defence is at the sink, whatever the validators did.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every character that must never reach generated source or a plan field raw: C0/DEL/C1
|
|
12
|
+
* controls (Cc), ALL format characters (Cf: soft hyphen, zero-width and bidi marks and
|
|
13
|
+
* overrides and isolates, BOM, interlinear annotation FFF9-FFFB, the Unicode "tag"
|
|
14
|
+
* characters U+E0000-E007F used for ASCII smuggling, musical-notation formatting
|
|
15
|
+
* U+1D173-1D17A), line and paragraph separators (Zl, Zp), lone surrogates (Cs), the
|
|
16
|
+
* combining grapheme joiner U+034F (a Mn that renders as nothing) and the unassigned
|
|
17
|
+
* U+2065 inside the bidi block. The `u` flag is what makes the astral ranges match.
|
|
18
|
+
*/
|
|
19
|
+
const UNSAFE_SOURCE = "[\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\\p{Cs}\\u034f\\u2065]";
|
|
20
|
+
const UNSAFE_CHARS = new RegExp(UNSAFE_SOURCE, "gu");
|
|
21
|
+
const UNSAFE_ONE = new RegExp(UNSAFE_SOURCE, "u");
|
|
22
|
+
|
|
23
|
+
/** Does `s` contain any character that must never reach generated source or a plan field? */
|
|
24
|
+
export function hasUnsafeChars(s: string): boolean {
|
|
25
|
+
return UNSAFE_ONE.test(s);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `\uXXXX` for a BMP code point, a `\uD8xx\uDCxx` surrogate pair for an astral one. */
|
|
29
|
+
function unicodeEscape(c: string): string {
|
|
30
|
+
const cp = c.codePointAt(0) ?? 0;
|
|
31
|
+
const u = (n: number): string => `\\u${n.toString(16).padStart(4, "0")}`;
|
|
32
|
+
if (cp <= 0xffff) return u(cp);
|
|
33
|
+
const v = cp - 0x10000;
|
|
34
|
+
return u(0xd800 + (v >> 10)) + u(0xdc00 + (v & 0x3ff));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Text for a single-line `// ...` comment: every unsafe character becomes a space. */
|
|
38
|
+
export function commentText(s: string): string {
|
|
39
|
+
return s.replace(UNSAFE_CHARS, " ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Text for a block comment: single-line as above, and a comment delimiter can never appear. */
|
|
43
|
+
export function blockCommentText(s: string): string {
|
|
44
|
+
return commentText(s).replace(/\*\//g, "* /").replace(/\/\*/g, "/ *");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A complete, quoted TypeScript string literal. `JSON.stringify` escapes quotes,
|
|
49
|
+
* backslashes, C0 controls and lone surrogates; every other unsafe character (U+2028/2029,
|
|
50
|
+
* bidi, zero-width, tag characters, ...) is escaped explicitly - astral ones as a surrogate
|
|
51
|
+
* pair - so the literal is inert and reads the same in every editor.
|
|
52
|
+
*/
|
|
53
|
+
export function tsStringLiteral(s: string): string {
|
|
54
|
+
return JSON.stringify(s).replace(UNSAFE_CHARS, unicodeEscape);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The CONTENT (no surrounding backticks) of a template literal: `\`, backtick and `${` escaped. */
|
|
58
|
+
export function templateLiteralText(s: string): string {
|
|
59
|
+
return tsStringLiteral(s)
|
|
60
|
+
.slice(1, -1)
|
|
61
|
+
.replace(/\\"/g, '"')
|
|
62
|
+
.replace(/`/g, "\\`")
|
|
63
|
+
.replace(/\$\{/g, "\\${");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One line of markdown prose: no line breaks, and markup/HTML metacharacters neutralised. */
|
|
67
|
+
export function markdownText(s: string): string {
|
|
68
|
+
return commentText(s)
|
|
69
|
+
.replace(/\s+/g, " ")
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/[\\`*_{}[\]<>#|!&~]/g, (c) => `\\${c}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { pascalIdent } from "./codegen";
|
|
2
|
+
import { commentText, templateLiteralText, tsStringLiteral } from "./emit";
|
|
3
|
+
|
|
4
|
+
// ── Non-agentic flow templates (`loom new flow <name> --template approval|timer|saga`) ──
|
|
5
|
+
//
|
|
6
|
+
// The echo flow is the default. These three are the plain-workflow on-ramp: a human gate, a
|
|
7
|
+
// durable timer, and compensation. Each ships a sibling test that runs on a fresh scaffold
|
|
8
|
+
// (`@loomwork/testing`'s time travel drives the timers; nothing sleeps for real).
|
|
9
|
+
|
|
10
|
+
export const FLOW_TEMPLATES = ["echo", "approval", "timer", "saga"] as const;
|
|
11
|
+
export type FlowTemplate = (typeof FLOW_TEMPLATES)[number];
|
|
12
|
+
|
|
13
|
+
export function isFlowTemplate(value: string): value is FlowTemplate {
|
|
14
|
+
return (FLOW_TEMPLATES as readonly string[]).includes(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── approval ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Suspend on a typed resume gate (`resumeSchemas`), with a 24h expiry. */
|
|
20
|
+
export function approvalFlowFile(name: string): string {
|
|
21
|
+
const P = pascalIdent(name);
|
|
22
|
+
return [
|
|
23
|
+
'import { flow$, handler$, step$ } from "@loomwork/core";',
|
|
24
|
+
'import type { FlowRunner, RegisteredFlow } from "@loomwork/workflow-runtime";',
|
|
25
|
+
'import { z } from "zod";',
|
|
26
|
+
"",
|
|
27
|
+
"// The gate's contract. `resume()` parses the payload against `resumeSchemas` BEFORE it is",
|
|
28
|
+
"// appended, so a wrongly-shaped approval is refused instead of becoming a permanent fact.",
|
|
29
|
+
`const ${P}Decision = z.object({ approved: z.boolean(), approvedBy: z.string() });`,
|
|
30
|
+
"",
|
|
31
|
+
`const ${P}Apply = step$({`,
|
|
32
|
+
` id: ${tsStringLiteral(`${name}-apply`)},`,
|
|
33
|
+
` name: "${P} apply",`,
|
|
34
|
+
" inputSchema: z.object({ target: z.string() }),",
|
|
35
|
+
" outputSchema: z.object({ applied: z.boolean() }),",
|
|
36
|
+
" handler: handler$(async (_i: { target: string }) => ({ applied: true })),",
|
|
37
|
+
"});",
|
|
38
|
+
"",
|
|
39
|
+
`type ${P}Out = { status: "applied" | "rejected" | "expired"; approvedBy?: string };`,
|
|
40
|
+
"",
|
|
41
|
+
`const runner: FlowRunner<{ target: string }, ${P}Out> = async (ctx, input) => {`,
|
|
42
|
+
" // Durable checkpoint: the execution parks in storage (it survives a restart) until someone",
|
|
43
|
+
" // answers the gate or the timeout passes. A timeout resumes with a TIMER_FIRED payload",
|
|
44
|
+
" // ({ wakeAt }), which is not a decision - so parse what came back.",
|
|
45
|
+
" const answer = await ctx.suspend<unknown>({",
|
|
46
|
+
' on: "ApprovalGranted",',
|
|
47
|
+
" correlationKey: input.target,",
|
|
48
|
+
" timeout: 24 * 60 * 60 * 1000, // 24h",
|
|
49
|
+
" });",
|
|
50
|
+
` const parsed = ${P}Decision.safeParse(answer);`,
|
|
51
|
+
' if (!parsed.success) return { status: "expired" };',
|
|
52
|
+
" const decision = parsed.data;",
|
|
53
|
+
' if (!decision.approved) return { status: "rejected", approvedBy: decision.approvedBy };',
|
|
54
|
+
` await ctx.run(${P}Apply, { target: input.target });`,
|
|
55
|
+
' return { status: "applied", approvedBy: decision.approvedBy };',
|
|
56
|
+
"};",
|
|
57
|
+
"",
|
|
58
|
+
`export const ${P}Flow: RegisteredFlow = {`,
|
|
59
|
+
" definition: flow$({",
|
|
60
|
+
` id: ${tsStringLiteral(name)},`,
|
|
61
|
+
` name: "${P}",`,
|
|
62
|
+
' version: "1.0.0",',
|
|
63
|
+
" inputSchema: z.object({ target: z.string() }),",
|
|
64
|
+
' outputSchema: z.object({ status: z.enum(["applied", "rejected", "expired"]), approvedBy: z.string().optional() }),',
|
|
65
|
+
` steps: [${P}Apply],`,
|
|
66
|
+
` resumeSchemas: { ApprovalGranted: ${P}Decision },`,
|
|
67
|
+
" }),",
|
|
68
|
+
" runner: runner as FlowRunner<unknown, unknown>,",
|
|
69
|
+
"};",
|
|
70
|
+
"",
|
|
71
|
+
"// WHO may approve is not decided here. In production wire `security.resumeAuthenticator`",
|
|
72
|
+
"// (and an `executionAuthorizer`) in defineConfig - see app/security.ts in a scaffolded app -",
|
|
73
|
+
"// so `resume()` only accepts a verified approver. The test below runs permissive on purpose.",
|
|
74
|
+
"",
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function approvalTestFile(name: string): string {
|
|
79
|
+
const P = pascalIdent(name);
|
|
80
|
+
return [
|
|
81
|
+
'import { advanceTime, createTestApp, resumeWith } from "@loomwork/testing";',
|
|
82
|
+
'import { describe, expect, it } from "vitest";',
|
|
83
|
+
`import { ${P}Flow } from "./flow";`,
|
|
84
|
+
"",
|
|
85
|
+
`describe(${tsStringLiteral(name)}, () => {`,
|
|
86
|
+
' it("applies once approved", async () => {',
|
|
87
|
+
` const app = createTestApp({ flows: [${P}Flow] });`,
|
|
88
|
+
" try {",
|
|
89
|
+
` const handle = await app.runtime.start(${P}Flow, { target: "prod-db" });`,
|
|
90
|
+
" await handle.waitForSuspend();",
|
|
91
|
+
" const resumed = await resumeWith(app.runtime, handle.executionId, {",
|
|
92
|
+
' eventType: "ApprovalGranted",',
|
|
93
|
+
' payload: { approved: true, approvedBy: "ada" },',
|
|
94
|
+
" });",
|
|
95
|
+
" const state = await resumed.waitForCompletion();",
|
|
96
|
+
' expect(state.variables._output).toEqual({ status: "applied", approvedBy: "ada" });',
|
|
97
|
+
" } finally {",
|
|
98
|
+
" await app.close();",
|
|
99
|
+
" }",
|
|
100
|
+
" });",
|
|
101
|
+
"",
|
|
102
|
+
' it("expires after 24h with no answer (time travel, no real waiting)", async () => {',
|
|
103
|
+
` const app = createTestApp({ clock: true, flows: [${P}Flow] });`,
|
|
104
|
+
" try {",
|
|
105
|
+
` const handle = await app.runtime.start(${P}Flow, { target: "prod-db" });`,
|
|
106
|
+
" await handle.waitForSuspend();",
|
|
107
|
+
" await advanceTime(app, 24 * 60 * 60 * 1000);",
|
|
108
|
+
" const state = await app.runtime.loadState(handle.executionId);",
|
|
109
|
+
' expect(state?.status).toBe("completed");',
|
|
110
|
+
' expect(state?.variables._output).toEqual({ status: "expired" });',
|
|
111
|
+
" } finally {",
|
|
112
|
+
" await app.close();",
|
|
113
|
+
" }",
|
|
114
|
+
" });",
|
|
115
|
+
"});",
|
|
116
|
+
"",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── timer ─────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** A durable `ctx.sleep` between two steps. */
|
|
123
|
+
export function timerFlowFile(name: string): string {
|
|
124
|
+
const P = pascalIdent(name);
|
|
125
|
+
return [
|
|
126
|
+
'import { flow$, handler$, step$ } from "@loomwork/core";',
|
|
127
|
+
'import type { FlowRunner, RegisteredFlow } from "@loomwork/workflow-runtime";',
|
|
128
|
+
'import { z } from "zod";',
|
|
129
|
+
"",
|
|
130
|
+
`const ${P}Remind = step$({`,
|
|
131
|
+
` id: ${tsStringLiteral(`${name}-remind`)},`,
|
|
132
|
+
` name: "${P} remind",`,
|
|
133
|
+
" inputSchema: z.object({ who: z.string() }),",
|
|
134
|
+
" outputSchema: z.object({ reminded: z.string() }),",
|
|
135
|
+
" handler: handler$(async (i: { who: string }) => ({ reminded: i.who })),",
|
|
136
|
+
"});",
|
|
137
|
+
"",
|
|
138
|
+
"const runner: FlowRunner<{ who: string }, { reminded: string }> = async (ctx, input) => {",
|
|
139
|
+
" // A DURABLE sleep: the execution parks in storage and a TimerService wakes it. It survives a",
|
|
140
|
+
" // restart and holds no process memory. For an absolute instant use `ctx.sleepUntil(epochMs)`.",
|
|
141
|
+
" await ctx.sleep(24 * 60 * 60 * 1000); // 1 day",
|
|
142
|
+
` return (await ctx.run(${P}Remind, { who: input.who })) as { reminded: string };`,
|
|
143
|
+
"};",
|
|
144
|
+
"",
|
|
145
|
+
`export const ${P}Flow: RegisteredFlow = {`,
|
|
146
|
+
" definition: flow$({",
|
|
147
|
+
` id: ${tsStringLiteral(name)},`,
|
|
148
|
+
` name: "${P}",`,
|
|
149
|
+
' version: "1.0.0",',
|
|
150
|
+
" inputSchema: z.object({ who: z.string() }),",
|
|
151
|
+
" outputSchema: z.object({ reminded: z.string() }),",
|
|
152
|
+
` steps: [${P}Remind],`,
|
|
153
|
+
" }),",
|
|
154
|
+
" runner: runner as FlowRunner<unknown, unknown>,",
|
|
155
|
+
"};",
|
|
156
|
+
"",
|
|
157
|
+
"// Timers only fire where a TimerService runs: in defineConfig pass",
|
|
158
|
+
"// `timers: { store: new InMemoryTimerStore() }` (or the Sqlite/Postgres store beside your",
|
|
159
|
+
"// event store). A scaffolded app does not wire one yet.",
|
|
160
|
+
"",
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function timerTestFile(name: string): string {
|
|
165
|
+
const P = pascalIdent(name);
|
|
166
|
+
return [
|
|
167
|
+
'import { advanceTime, createTestApp } from "@loomwork/testing";',
|
|
168
|
+
'import { describe, expect, it } from "vitest";',
|
|
169
|
+
`import { ${P}Flow } from "./flow";`,
|
|
170
|
+
"",
|
|
171
|
+
"const DAY = 24 * 60 * 60 * 1000;",
|
|
172
|
+
"",
|
|
173
|
+
`describe(${tsStringLiteral(name)}, () => {`,
|
|
174
|
+
' it("waits a day, then reminds (time travel, no real waiting)", async () => {',
|
|
175
|
+
" // `clock: true` gives the app a controllable clock and a timer store; advanceTime moves the",
|
|
176
|
+
" // clock and fires what became due through the real TimerService.",
|
|
177
|
+
` const app = createTestApp({ clock: true, flows: [${P}Flow] });`,
|
|
178
|
+
" try {",
|
|
179
|
+
` const handle = await app.runtime.start(${P}Flow, { who: "ada" });`,
|
|
180
|
+
" await handle.waitForSuspend();",
|
|
181
|
+
"",
|
|
182
|
+
" await advanceTime(app, DAY - 1); // not due yet",
|
|
183
|
+
' expect((await app.runtime.loadState(handle.executionId))?.status).toBe("suspended");',
|
|
184
|
+
"",
|
|
185
|
+
" await advanceTime(app, 1); // due",
|
|
186
|
+
" const state = await app.runtime.loadState(handle.executionId);",
|
|
187
|
+
' expect(state?.status).toBe("completed");',
|
|
188
|
+
' expect(state?.variables._output).toEqual({ reminded: "ada" });',
|
|
189
|
+
" } finally {",
|
|
190
|
+
" await app.close();",
|
|
191
|
+
" }",
|
|
192
|
+
" });",
|
|
193
|
+
"});",
|
|
194
|
+
"",
|
|
195
|
+
].join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── saga ──────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/** reserve -> charge -> ship with a compensator per forward step (`@loomwork/saga` `withSaga`). */
|
|
201
|
+
export function sagaFlowFile(name: string): string {
|
|
202
|
+
const P = pascalIdent(name);
|
|
203
|
+
return [
|
|
204
|
+
'import { flow$, step$ } from "@loomwork/core";',
|
|
205
|
+
'import { withSaga } from "@loomwork/saga";',
|
|
206
|
+
'import type { FlowRunner, RegisteredFlow } from "@loomwork/workflow-runtime";',
|
|
207
|
+
'import { z } from "zod";',
|
|
208
|
+
"",
|
|
209
|
+
"// Saga steps are TOOL steps: each names a `toolId` whose effect you register on the app",
|
|
210
|
+
"// (`app.effectRuntime.register(toolId, new ToolEffect(...))`). A compensator runs under a",
|
|
211
|
+
"// derived step id, so it must resolve through its `toolId`, not through an inline `handler$`.",
|
|
212
|
+
`export const ${P}Tools = ["reserve", "unreserve", "charge", "refund", "ship"].map((t) => \`${templateLiteralText(name)}-\${t}\`);`,
|
|
213
|
+
"",
|
|
214
|
+
"// Under `security.strict` (the scaffolded app) a flow may only run the tools it is granted:",
|
|
215
|
+
`// in app/security.ts add \`...${P}Grants\` to the \`GrantTable({ ... })\`, e.g.`,
|
|
216
|
+
`// import { ${P}Grants } from "./flows/${commentText(name)}/flow";`,
|
|
217
|
+
`// export const authorizer = new GrantTable({ greet: ["greet-step"], ...${P}Grants });`,
|
|
218
|
+
`export const ${P}Grants = { ${tsStringLiteral(name)}: ${P}Tools };`,
|
|
219
|
+
"",
|
|
220
|
+
"const empty = z.object({});",
|
|
221
|
+
"const stepOf = (tool: string) =>",
|
|
222
|
+
" step$({",
|
|
223
|
+
` id: \`${templateLiteralText(name)}-\${tool}\`,`,
|
|
224
|
+
" name: tool,",
|
|
225
|
+
` toolId: \`${templateLiteralText(name)}-\${tool}\`,`,
|
|
226
|
+
" inputSchema: empty,",
|
|
227
|
+
" outputSchema: empty,",
|
|
228
|
+
" });",
|
|
229
|
+
"",
|
|
230
|
+
"// Forward steps and their compensators (undo what the forward step did).",
|
|
231
|
+
'const reserve = stepOf("reserve");',
|
|
232
|
+
'const unreserve = stepOf("unreserve");',
|
|
233
|
+
'const charge = stepOf("charge");',
|
|
234
|
+
'const refund = stepOf("refund");',
|
|
235
|
+
'const ship = stepOf("ship");',
|
|
236
|
+
"",
|
|
237
|
+
"const runner: FlowRunner<{ orderId: string }, { ok: boolean }> = (ctx) =>",
|
|
238
|
+
" // On any failure inside the scope, the registered compensators run in REVERSE as ordinary",
|
|
239
|
+
" // replay-safe steps. A suspension is not a failure: it never rolls back.",
|
|
240
|
+
" withSaga(ctx, async (saga) => {",
|
|
241
|
+
" await saga.run(reserve, {}, unreserve);",
|
|
242
|
+
" await saga.run(charge, {}, refund);",
|
|
243
|
+
" await ctx.run(ship, {}); // if the carrier fails, refund then unreserve run",
|
|
244
|
+
" return { ok: true };",
|
|
245
|
+
" });",
|
|
246
|
+
"",
|
|
247
|
+
`export const ${P}Flow: RegisteredFlow = {`,
|
|
248
|
+
" definition: flow$({",
|
|
249
|
+
` id: ${tsStringLiteral(name)},`,
|
|
250
|
+
` name: "${P}",`,
|
|
251
|
+
' version: "1.0.0",',
|
|
252
|
+
" inputSchema: z.object({ orderId: z.string() }),",
|
|
253
|
+
" outputSchema: z.object({ ok: z.boolean() }),",
|
|
254
|
+
" steps: [reserve, unreserve, charge, refund, ship],",
|
|
255
|
+
" }),",
|
|
256
|
+
" runner: runner as FlowRunner<unknown, unknown>,",
|
|
257
|
+
"};",
|
|
258
|
+
"",
|
|
259
|
+
].join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function sagaTestFile(name: string): string {
|
|
263
|
+
const P = pascalIdent(name);
|
|
264
|
+
return [
|
|
265
|
+
'import { ToolEffect } from "@loomwork/effect-runtime";',
|
|
266
|
+
'import { createTestApp } from "@loomwork/testing";',
|
|
267
|
+
'import { describe, expect, it } from "vitest";',
|
|
268
|
+
`import { ${P}Flow, ${P}Tools } from "./flow";`,
|
|
269
|
+
"",
|
|
270
|
+
"// Stand-ins for the real side effects: record each call, fail `ship` on demand.",
|
|
271
|
+
"function build(failShipping: boolean) {",
|
|
272
|
+
" const ledger: string[] = [];",
|
|
273
|
+
` const app = createTestApp({ flows: [${P}Flow] });`,
|
|
274
|
+
` for (const toolId of ${P}Tools) {`,
|
|
275
|
+
` const label = toolId.slice(${tsStringLiteral(`${name}-`)}.length);`,
|
|
276
|
+
" app.effectRuntime.register(",
|
|
277
|
+
" toolId,",
|
|
278
|
+
" new ToolEffect(async () => {",
|
|
279
|
+
' if (label === "ship" && failShipping) throw new Error("carrier unavailable");',
|
|
280
|
+
" ledger.push(label);",
|
|
281
|
+
" return {};",
|
|
282
|
+
" })",
|
|
283
|
+
" );",
|
|
284
|
+
" }",
|
|
285
|
+
" return { app, ledger };",
|
|
286
|
+
"}",
|
|
287
|
+
"",
|
|
288
|
+
`describe(${tsStringLiteral(name)}, () => {`,
|
|
289
|
+
' it("keeps the work when every step succeeds", async () => {',
|
|
290
|
+
" const { app, ledger } = build(false);",
|
|
291
|
+
" try {",
|
|
292
|
+
` const handle = await app.runtime.start(${P}Flow, { orderId: "o-1" });`,
|
|
293
|
+
" const state = await handle.waitForCompletion();",
|
|
294
|
+
" expect(state.variables._output).toEqual({ ok: true });",
|
|
295
|
+
' expect(ledger).toEqual(["reserve", "charge", "ship"]);',
|
|
296
|
+
" } finally {",
|
|
297
|
+
" await app.close();",
|
|
298
|
+
" }",
|
|
299
|
+
" });",
|
|
300
|
+
"",
|
|
301
|
+
' it("undoes the work in reverse when a later step fails", async () => {',
|
|
302
|
+
" const { app, ledger } = build(true);",
|
|
303
|
+
" try {",
|
|
304
|
+
` const handle = await app.runtime.start(${P}Flow, { orderId: "o-1" });`,
|
|
305
|
+
" await handle.waitForCompletion().catch(() => undefined); // the flow itself fails",
|
|
306
|
+
' expect(ledger).toEqual(["reserve", "charge", "refund", "unreserve"]);',
|
|
307
|
+
" } finally {",
|
|
308
|
+
" await app.close();",
|
|
309
|
+
" }",
|
|
310
|
+
" });",
|
|
311
|
+
"});",
|
|
312
|
+
"",
|
|
313
|
+
].join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* What a developer must still do by hand after `loom new flow <name> --template <t>` - printed by
|
|
318
|
+
* the CLI, because the templates cannot edit the app's own config.
|
|
319
|
+
*/
|
|
320
|
+
export function templateNotes(template: FlowTemplate, name: string): string[] {
|
|
321
|
+
const P = pascalIdent(name);
|
|
322
|
+
switch (template) {
|
|
323
|
+
case "echo":
|
|
324
|
+
return [];
|
|
325
|
+
case "approval":
|
|
326
|
+
return [
|
|
327
|
+
"approval: wire `timers: { store }` in defineConfig so the 24h timeout can fire (the test provides one), and `security.resumeAuthenticator` to decide who may approve.",
|
|
328
|
+
];
|
|
329
|
+
case "timer":
|
|
330
|
+
return [
|
|
331
|
+
"timer: wire `timers: { store }` in defineConfig so the sleep can wake (the test provides one).",
|
|
332
|
+
];
|
|
333
|
+
case "saga":
|
|
334
|
+
return [
|
|
335
|
+
`saga: the steps are tool steps. Under \`security.strict\` add \`...${P}Grants\` (from ./flows/${commentText(name)}/flow) to the GrantTable in app/security.ts, and register an effect per tool id in \`${P}Tools\` on app.effectRuntime (the test stubs them).`,
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The flow + test contents for a non-echo template. */
|
|
341
|
+
export function templateFiles(
|
|
342
|
+
template: Exclude<FlowTemplate, "echo">,
|
|
343
|
+
name: string
|
|
344
|
+
): { flow: string; test: string } {
|
|
345
|
+
switch (template) {
|
|
346
|
+
case "approval":
|
|
347
|
+
return { flow: approvalFlowFile(name), test: approvalTestFile(name) };
|
|
348
|
+
case "timer":
|
|
349
|
+
return { flow: timerFlowFile(name), test: timerTestFile(name) };
|
|
350
|
+
case "saga":
|
|
351
|
+
return { flow: sagaFlowFile(name), test: sagaTestFile(name) };
|
|
352
|
+
}
|
|
353
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { pascal, pascalIdent, generateRegistration } from "./codegen";
|
|
2
|
+
export {
|
|
3
|
+
blockCommentText,
|
|
4
|
+
commentText,
|
|
5
|
+
hasUnsafeChars,
|
|
6
|
+
markdownText,
|
|
7
|
+
templateLiteralText,
|
|
8
|
+
tsStringLiteral,
|
|
9
|
+
} from "./emit";
|
|
10
|
+
export {
|
|
11
|
+
flowFile,
|
|
12
|
+
flowTestFile,
|
|
13
|
+
toolFile,
|
|
14
|
+
agentFile,
|
|
15
|
+
newFile,
|
|
16
|
+
newFiles,
|
|
17
|
+
appFiles,
|
|
18
|
+
sentinelFile,
|
|
19
|
+
importedPackages,
|
|
20
|
+
THIRD_PARTY_SPECS,
|
|
21
|
+
} from "./templates";
|
|
22
|
+
export {
|
|
23
|
+
CONSTRUCT_NAME,
|
|
24
|
+
kebab,
|
|
25
|
+
constructNameProblem,
|
|
26
|
+
isValidPackageName,
|
|
27
|
+
packageNameFrom,
|
|
28
|
+
} from "./names";
|
|
29
|
+
export { pluginFiles, pluginNextSteps, listSeams, SEAM_KEYS } from "./plugin";
|
|
30
|
+
export { FLOW_TEMPLATES, isFlowTemplate, templateNotes, type FlowTemplate } from "./flowTemplates";
|
package/src/names.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Names a scaffold writes into a path or a `package.json` — validated before anything is created. */
|
|
2
|
+
|
|
3
|
+
/** A construct name (`loom new <kind> <name>`): kebab-case, becomes a directory / file name. */
|
|
4
|
+
export const CONSTRUCT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
5
|
+
|
|
6
|
+
/** Best-effort kebab-case form of an arbitrary string (`"Bad Name!"` → `"bad-name"`). */
|
|
7
|
+
export function kebab(input: string): string {
|
|
8
|
+
return input
|
|
9
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Why `name` is not an acceptable `loom new` name, or `undefined` when it is. A path
|
|
17
|
+
* separator or `..` is called out first: those escape the convention folder, which is a
|
|
18
|
+
* different mistake from a name that is merely not kebab-case.
|
|
19
|
+
*/
|
|
20
|
+
export function constructNameProblem(name: string): string | undefined {
|
|
21
|
+
if (/[\\/]/.test(name) || name === ".." || name === "." || /^[a-zA-Z]:/.test(name)) {
|
|
22
|
+
return `"${name}" is a path, not a name — a name may not contain path separators or "..", it would write outside the convention folder`;
|
|
23
|
+
}
|
|
24
|
+
if (CONSTRUCT_NAME.test(name)) return undefined;
|
|
25
|
+
const k = kebab(name);
|
|
26
|
+
const hint = CONSTRUCT_NAME.test(k) ? ` — did you mean "${k}"?` : "";
|
|
27
|
+
return `invalid name "${name}": use lowercase kebab-case matching ${CONSTRUCT_NAME.source}${hint}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** npm's rules for a package name, unscoped or `@scope/name` (lowercase, URL-safe, no leading `.`/`_`). */
|
|
31
|
+
export function isValidPackageName(name: string): boolean {
|
|
32
|
+
return (
|
|
33
|
+
name.length > 0 &&
|
|
34
|
+
name.length <= 214 &&
|
|
35
|
+
/^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/.test(name)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A valid npm name derived from a directory name, or `undefined` if nothing usable remains. */
|
|
40
|
+
export function packageNameFrom(dirName: string): string | undefined {
|
|
41
|
+
const k = kebab(dirName);
|
|
42
|
+
return isValidPackageName(k) ? k : undefined;
|
|
43
|
+
}
|