@mawaru/sdk 0.5.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/_schemas/ai-manifest.test.ts +135 -0
- package/_schemas/ai-manifest.ts +76 -0
- package/_schemas/graph.test.ts +872 -0
- package/_schemas/graph.ts +439 -0
- package/_schemas/hook-manifest.test.ts +45 -0
- package/_schemas/hook-manifest.ts +25 -0
- package/_schemas/node.test.ts +76 -0
- package/_schemas/node.ts +54 -0
- package/_schemas/port-spec.test.ts +657 -0
- package/_schemas/port-spec.ts +405 -0
- package/_schemas/program-manifest.test.ts +126 -0
- package/_schemas/program-manifest.ts +43 -0
- package/dist/_schemas/ai-manifest.d.ts +25 -0
- package/dist/_schemas/ai-manifest.js +67 -0
- package/dist/_schemas/graph.d.ts +226 -0
- package/dist/_schemas/graph.js +372 -0
- package/dist/_schemas/hook-manifest.d.ts +18 -0
- package/dist/_schemas/hook-manifest.js +21 -0
- package/dist/_schemas/node.d.ts +47 -0
- package/dist/_schemas/node.js +42 -0
- package/dist/_schemas/port-spec.d.ts +62 -0
- package/dist/_schemas/port-spec.js +336 -0
- package/dist/_schemas/program-manifest.d.ts +10 -0
- package/dist/_schemas/program-manifest.js +41 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +54 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/init.d.ts +6 -0
- package/dist/init.js +81 -0
- package/dist/typegen.d.ts +14 -0
- package/dist/typegen.js +206 -0
- package/dist/validate.d.ts +6 -0
- package/dist/validate.js +130 -0
- package/docs/development.md +59 -0
- package/index.ts +9 -0
- package/package.json +47 -0
- package/skills/create-loop/SKILL.md +87 -0
- package/templates/CLAUDE.md +23 -0
- package/templates/README.md +12 -0
- package/templates/echo/config.json +23 -0
- package/templates/echo/main.ts +4 -0
- package/templates/mawaru-runner.yml +71 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
aiConfigSchema,
|
|
4
|
+
humanConfigSchema,
|
|
5
|
+
programConfigSchema,
|
|
6
|
+
refsDefaultsOf,
|
|
7
|
+
saveGraphBodySchema,
|
|
8
|
+
waitConfigSchema,
|
|
9
|
+
} from "./graph.js"
|
|
10
|
+
import type { GraphPort } from "./node.js"
|
|
11
|
+
import { envelopeSchemaOf } from "./port-spec.js"
|
|
12
|
+
|
|
13
|
+
// テスト用の決定的な uuid(common は node 型定義を持たないため crypto は使わない)
|
|
14
|
+
let seq = 0
|
|
15
|
+
const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
|
|
16
|
+
|
|
17
|
+
describe("waitConfigSchema", () => {
|
|
18
|
+
it("duration: 正の整数秒を受け付ける", () => {
|
|
19
|
+
const parsed = waitConfigSchema.parse({
|
|
20
|
+
wait_type: "duration",
|
|
21
|
+
duration_seconds: 90,
|
|
22
|
+
})
|
|
23
|
+
expect(parsed).toEqual({ wait_type: "duration", duration_seconds: 90 })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("duration: 0・負数・非整数を拒否する", () => {
|
|
27
|
+
for (const duration_seconds of [0, -1, 1.5]) {
|
|
28
|
+
expect(() =>
|
|
29
|
+
waitConfigSchema.parse({ wait_type: "duration", duration_seconds }),
|
|
30
|
+
).toThrow()
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("until: ISO 日時(Z / オフセット)を受け付ける", () => {
|
|
35
|
+
for (const until_at of [
|
|
36
|
+
"2026-07-10T09:00:00.000Z",
|
|
37
|
+
"2026-07-10T09:00:00+09:00",
|
|
38
|
+
]) {
|
|
39
|
+
expect(waitConfigSchema.parse({ wait_type: "until", until_at })).toEqual({
|
|
40
|
+
wait_type: "until",
|
|
41
|
+
until_at,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("until: 日時でない文字列を拒否する", () => {
|
|
47
|
+
expect(() =>
|
|
48
|
+
waitConfigSchema.parse({ wait_type: "until", until_at: "2026-07-10" }),
|
|
49
|
+
).toThrow()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("cron: 非空の式を受け付ける(式の妥当性検証は保存時)", () => {
|
|
53
|
+
expect(
|
|
54
|
+
waitConfigSchema.parse({ wait_type: "cron", cron_expr: "0 9 * * 1-5" }),
|
|
55
|
+
).toEqual({ wait_type: "cron", cron_expr: "0 9 * * 1-5" })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("cron: 空文字を拒否する", () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
waitConfigSchema.parse({ wait_type: "cron", cron_expr: "" }),
|
|
61
|
+
).toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("種別と値カラムの取り違えを拒否する", () => {
|
|
65
|
+
// duration に until_at だけ、など判別後の必須欠落
|
|
66
|
+
expect(() =>
|
|
67
|
+
waitConfigSchema.parse({
|
|
68
|
+
wait_type: "duration",
|
|
69
|
+
until_at: "2026-07-10T09:00:00.000Z",
|
|
70
|
+
}),
|
|
71
|
+
).toThrow()
|
|
72
|
+
expect(() =>
|
|
73
|
+
waitConfigSchema.parse({ wait_type: "until", duration_seconds: 60 }),
|
|
74
|
+
).toThrow()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("未知の wait_type を拒否する", () => {
|
|
78
|
+
expect(() => waitConfigSchema.parse({ wait_type: "forever" })).toThrow()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// ---- グラフ一括保存の契約(ports 配列+port_id 接続。ポートのテーブル化.md) ----
|
|
83
|
+
|
|
84
|
+
const port = (io: "in" | "out", key: string): GraphPort => ({
|
|
85
|
+
id: uuid(),
|
|
86
|
+
io,
|
|
87
|
+
key,
|
|
88
|
+
name: key,
|
|
89
|
+
schema: { type: "object" },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// ai の out 宣言は封筒型 { data, description } 固定なので、デフォルトノードは
|
|
93
|
+
// data=テキストの封筒を持つ
|
|
94
|
+
const textEnvelope = envelopeSchemaOf({ type: "string" })
|
|
95
|
+
|
|
96
|
+
const makeNode = (ports?: GraphPort[]) => ({
|
|
97
|
+
id: uuid(),
|
|
98
|
+
kind: "ai" as const,
|
|
99
|
+
name: "ノード",
|
|
100
|
+
ports: ports ?? [
|
|
101
|
+
port("in", "in"),
|
|
102
|
+
{ ...port("out", "main"), schema: textEnvelope },
|
|
103
|
+
],
|
|
104
|
+
position_x: 0,
|
|
105
|
+
position_y: 0,
|
|
106
|
+
})
|
|
107
|
+
// kind 別のテストノード({...makeNode(), kind: "human"} 等)も受けられるよう広げる
|
|
108
|
+
type TestNode = Omit<ReturnType<typeof makeNode>, "kind"> & { kind: string }
|
|
109
|
+
|
|
110
|
+
const inOf = (node: TestNode): GraphPort => {
|
|
111
|
+
const p = node.ports.find((p) => p.io === "in")
|
|
112
|
+
if (!p) throw new Error("in port not found")
|
|
113
|
+
return p
|
|
114
|
+
}
|
|
115
|
+
const outOf = (node: TestNode): GraphPort => {
|
|
116
|
+
const p = node.ports.find((p) => p.io === "out")
|
|
117
|
+
if (!p) throw new Error("out port not found")
|
|
118
|
+
return p
|
|
119
|
+
}
|
|
120
|
+
const connect = (from: TestNode, to: TestNode) => ({
|
|
121
|
+
from_port_id: outOf(from).id,
|
|
122
|
+
to_port_id: inOf(to).id,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("programConfigSchema", () => {
|
|
126
|
+
it("program_dir を受け付ける(ref は省略可・日本語可)", () => {
|
|
127
|
+
expect(programConfigSchema.parse({ program_dir: "sendReply" })).toEqual({
|
|
128
|
+
program_dir: "sendReply",
|
|
129
|
+
})
|
|
130
|
+
expect(
|
|
131
|
+
programConfigSchema.parse({ program_dir: "返信を送る", ref: "develop" })
|
|
132
|
+
.ref,
|
|
133
|
+
).toBe("develop")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("空の program_dir を拒否する", () => {
|
|
137
|
+
expect(() => programConfigSchema.parse({ program_dir: "" })).toThrow()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("ディレクトリ名でないもの(パス区切り・. ..)を拒否する", () => {
|
|
141
|
+
for (const program_dir of ["a/b", "..\\x", ".", ".."]) {
|
|
142
|
+
expect(() => programConfigSchema.parse({ program_dir })).toThrow()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe("aiConfigSchema", () => {
|
|
148
|
+
it("ai_dir を受け付ける(ref は省略可・日本語可)", () => {
|
|
149
|
+
expect(aiConfigSchema.parse({ ai_dir: "draftReply" })).toEqual({
|
|
150
|
+
ai_dir: "draftReply",
|
|
151
|
+
})
|
|
152
|
+
expect(
|
|
153
|
+
aiConfigSchema.parse({ ai_dir: "返信下書き", ref: "develop" }).ref,
|
|
154
|
+
).toBe("develop")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("空の ai_dir を拒否する", () => {
|
|
158
|
+
expect(() => aiConfigSchema.parse({ ai_dir: "" })).toThrow()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("ディレクトリ名でないもの(パス区切り・. ..)を拒否する", () => {
|
|
162
|
+
for (const ai_dir of ["a/b", "..\\x", ".", ".."]) {
|
|
163
|
+
expect(() => aiConfigSchema.parse({ ai_dir })).toThrow()
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe("humanConfigSchema", () => {
|
|
169
|
+
it("既定値を補う(fixed・any・units 空=tenant の誰でも)", () => {
|
|
170
|
+
expect(humanConfigSchema.parse({ assignment: {} })).toEqual({
|
|
171
|
+
assignment: { strategy: "fixed", approval_mode: "any", units: [] },
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("fixed:1ユニット(複数人の and/or)を受け付ける", () => {
|
|
176
|
+
const u1 = uuid()
|
|
177
|
+
const u2 = uuid()
|
|
178
|
+
const parsed = humanConfigSchema.parse({
|
|
179
|
+
assignment: {
|
|
180
|
+
strategy: "fixed",
|
|
181
|
+
approval_mode: "all",
|
|
182
|
+
units: [[u1, u2]],
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
expect(parsed.assignment.units).toEqual([[u1, u2]])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("fixed:ユニットが2つ以上は拒否する", () => {
|
|
189
|
+
expect(() =>
|
|
190
|
+
humanConfigSchema.parse({
|
|
191
|
+
assignment: { strategy: "fixed", units: [[uuid()], [uuid()]] },
|
|
192
|
+
}),
|
|
193
|
+
).toThrow()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("round_robin:2ユニット以上を受け付ける(個人・グループ混在可)", () => {
|
|
197
|
+
const parsed = humanConfigSchema.parse({
|
|
198
|
+
assignment: {
|
|
199
|
+
strategy: "round_robin",
|
|
200
|
+
units: [[uuid()], [uuid(), uuid()]],
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
expect(parsed.assignment.units).toHaveLength(2)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("round_robin:ユニット1つ以下は拒否する(交代の意味がない)", () => {
|
|
207
|
+
for (const units of [[], [[uuid()]]]) {
|
|
208
|
+
expect(() =>
|
|
209
|
+
humanConfigSchema.parse({
|
|
210
|
+
assignment: { strategy: "round_robin", units },
|
|
211
|
+
}),
|
|
212
|
+
).toThrow()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("空ユニット・ユニット内の user_id 重複を拒否する(ユニットをまたぐ重複は許す)", () => {
|
|
217
|
+
const u1 = uuid()
|
|
218
|
+
expect(() =>
|
|
219
|
+
humanConfigSchema.parse({ assignment: { units: [[]] } }),
|
|
220
|
+
).toThrow()
|
|
221
|
+
expect(() =>
|
|
222
|
+
humanConfigSchema.parse({
|
|
223
|
+
assignment: { strategy: "fixed", units: [[u1, u1]] },
|
|
224
|
+
}),
|
|
225
|
+
).toThrow()
|
|
226
|
+
// またぐ重複は許す(「Aは毎回・相手が交代」のような形を排除しない)
|
|
227
|
+
expect(() =>
|
|
228
|
+
humanConfigSchema.parse({
|
|
229
|
+
assignment: { strategy: "round_robin", units: [[u1], [u1, uuid()]] },
|
|
230
|
+
}),
|
|
231
|
+
).not.toThrow()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("uuid でない user_id を拒否する", () => {
|
|
235
|
+
expect(() =>
|
|
236
|
+
humanConfigSchema.parse({ assignment: { units: [["not-a-uuid"]] } }),
|
|
237
|
+
).toThrow()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe("saveGraphBodySchema", () => {
|
|
242
|
+
it("ports 配列を持つ node と port_id 接続を受け付ける(既定値も補われる)", () => {
|
|
243
|
+
const a = makeNode()
|
|
244
|
+
const b = makeNode()
|
|
245
|
+
const parsed = saveGraphBodySchema.parse({
|
|
246
|
+
nodes: [a, b],
|
|
247
|
+
connections: [
|
|
248
|
+
{
|
|
249
|
+
from_port_id: outOf(a).id,
|
|
250
|
+
to_port_id: inOf(b).id,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
})
|
|
254
|
+
expect(parsed.nodes[0]?.hooks).toEqual([])
|
|
255
|
+
expect(parsed.connections[0]).toEqual({
|
|
256
|
+
from_port_id: outOf(a).id,
|
|
257
|
+
to_port_id: inOf(b).id,
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it("node id の重複を拒否する", () => {
|
|
262
|
+
const a = makeNode()
|
|
263
|
+
expect(() =>
|
|
264
|
+
saveGraphBodySchema.parse({
|
|
265
|
+
nodes: [a, { ...makeNode(), id: a.id }],
|
|
266
|
+
connections: [],
|
|
267
|
+
}),
|
|
268
|
+
).toThrow(/node の id が重複/)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("port id の重複(ノードを跨いでも)を拒否する", () => {
|
|
272
|
+
const shared = port("out", "main")
|
|
273
|
+
const a = makeNode([port("in", "in"), shared])
|
|
274
|
+
const b = makeNode([port("in", "in"), { ...shared, key: "other" }])
|
|
275
|
+
expect(() =>
|
|
276
|
+
saveGraphBodySchema.parse({ nodes: [a, b], connections: [] }),
|
|
277
|
+
).toThrow(/port の id が重複/)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it("同一 node 内の (io, key) 重複を拒否する", () => {
|
|
281
|
+
const a = makeNode([
|
|
282
|
+
port("in", "in"),
|
|
283
|
+
port("out", "main"),
|
|
284
|
+
port("out", "main"),
|
|
285
|
+
])
|
|
286
|
+
expect(() =>
|
|
287
|
+
saveGraphBodySchema.parse({ nodes: [a], connections: [] }),
|
|
288
|
+
).toThrow(/key が重複/)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("in ポートがちょうど1つでない node を拒否する(0個・2個)", () => {
|
|
292
|
+
const zero = makeNode([port("out", "main")])
|
|
293
|
+
expect(() =>
|
|
294
|
+
saveGraphBodySchema.parse({ nodes: [zero], connections: [] }),
|
|
295
|
+
).toThrow(/in ポート/)
|
|
296
|
+
|
|
297
|
+
const two = makeNode([
|
|
298
|
+
port("in", "in"),
|
|
299
|
+
port("in", "in2"),
|
|
300
|
+
port("out", "main"),
|
|
301
|
+
])
|
|
302
|
+
expect(() =>
|
|
303
|
+
saveGraphBodySchema.parse({ nodes: [two], connections: [] }),
|
|
304
|
+
).toThrow(/in ポート/)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it("out ポート0個の node を拒否する(最低1ポート)", () => {
|
|
308
|
+
const noOut = makeNode([port("in", "in")])
|
|
309
|
+
expect(() =>
|
|
310
|
+
saveGraphBodySchema.parse({ nodes: [noOut], connections: [] }),
|
|
311
|
+
).toThrow(/out ポート/)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("グラフ内に存在しない port への接続を拒否する", () => {
|
|
315
|
+
const a = makeNode()
|
|
316
|
+
expect(() =>
|
|
317
|
+
saveGraphBodySchema.parse({
|
|
318
|
+
nodes: [a],
|
|
319
|
+
connections: [{ from_port_id: outOf(a).id, to_port_id: uuid() }],
|
|
320
|
+
}),
|
|
321
|
+
).toThrow(/接続先/)
|
|
322
|
+
expect(() =>
|
|
323
|
+
saveGraphBodySchema.parse({
|
|
324
|
+
nodes: [a],
|
|
325
|
+
connections: [{ from_port_id: uuid(), to_port_id: inOf(a).id }],
|
|
326
|
+
}),
|
|
327
|
+
).toThrow(/接続元/)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("向き違反(from が in ポート・to が out ポート)を拒否する", () => {
|
|
331
|
+
const a = makeNode()
|
|
332
|
+
const b = makeNode()
|
|
333
|
+
expect(() =>
|
|
334
|
+
saveGraphBodySchema.parse({
|
|
335
|
+
nodes: [a, b],
|
|
336
|
+
connections: [
|
|
337
|
+
// from に in ポート
|
|
338
|
+
{ from_port_id: inOf(a).id, to_port_id: inOf(b).id },
|
|
339
|
+
],
|
|
340
|
+
}),
|
|
341
|
+
).toThrow(/接続元/)
|
|
342
|
+
expect(() =>
|
|
343
|
+
saveGraphBodySchema.parse({
|
|
344
|
+
nodes: [a, b],
|
|
345
|
+
connections: [
|
|
346
|
+
// to に out ポート
|
|
347
|
+
{ from_port_id: outOf(a).id, to_port_id: outOf(b).id },
|
|
348
|
+
],
|
|
349
|
+
}),
|
|
350
|
+
).toThrow(/接続先/)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it("同じ出力ポートから複数の接続(fan-out)を許す", () => {
|
|
354
|
+
const a = makeNode()
|
|
355
|
+
const b = makeNode()
|
|
356
|
+
const c = makeNode()
|
|
357
|
+
expect(() =>
|
|
358
|
+
saveGraphBodySchema.parse({
|
|
359
|
+
nodes: [a, b, c],
|
|
360
|
+
connections: [
|
|
361
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(b).id },
|
|
362
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(c).id },
|
|
363
|
+
],
|
|
364
|
+
}),
|
|
365
|
+
).not.toThrow()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("完全に同じ接続(同じ from→to の2本)を拒否する", () => {
|
|
369
|
+
const a = makeNode()
|
|
370
|
+
const b = makeNode()
|
|
371
|
+
expect(() =>
|
|
372
|
+
saveGraphBodySchema.parse({
|
|
373
|
+
nodes: [a, b],
|
|
374
|
+
connections: [
|
|
375
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(b).id },
|
|
376
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(b).id },
|
|
377
|
+
],
|
|
378
|
+
}),
|
|
379
|
+
).toThrow(/同じ接続/)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it("同じ in ポートへの複数の接続(差し戻し)は許す", () => {
|
|
383
|
+
const a = makeNode()
|
|
384
|
+
const b = makeNode()
|
|
385
|
+
const c = makeNode()
|
|
386
|
+
expect(() =>
|
|
387
|
+
saveGraphBodySchema.parse({
|
|
388
|
+
nodes: [a, b, c],
|
|
389
|
+
connections: [
|
|
390
|
+
{ from_port_id: outOf(a).id, to_port_id: inOf(c).id },
|
|
391
|
+
{ from_port_id: outOf(b).id, to_port_id: inOf(c).id },
|
|
392
|
+
],
|
|
393
|
+
}),
|
|
394
|
+
).not.toThrow()
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ---- kind 別ポート構成と型の接続バリデーション(ポートのkind別仕様.md) ----
|
|
399
|
+
|
|
400
|
+
const aiEnvelope = textEnvelope
|
|
401
|
+
|
|
402
|
+
const humanNode = () => ({
|
|
403
|
+
...makeNode([
|
|
404
|
+
port("in", "in"),
|
|
405
|
+
port("out", "approve"),
|
|
406
|
+
port("out", "reject"),
|
|
407
|
+
]),
|
|
408
|
+
kind: "human" as const,
|
|
409
|
+
})
|
|
410
|
+
const aiNode = () => ({
|
|
411
|
+
...makeNode([
|
|
412
|
+
port("in", "in"),
|
|
413
|
+
{ ...port("out", "main"), schema: aiEnvelope },
|
|
414
|
+
]),
|
|
415
|
+
kind: "ai" as const,
|
|
416
|
+
})
|
|
417
|
+
const programNode = (inSchema: GraphPort["schema"] = {}) => ({
|
|
418
|
+
...makeNode([{ ...port("in", "in"), schema: inSchema }, port("out", "main")]),
|
|
419
|
+
kind: "program" as const,
|
|
420
|
+
})
|
|
421
|
+
const outByKey = (node: TestNode, key: string): GraphPort => {
|
|
422
|
+
const p = node.ports.find((p) => p.io === "out" && p.key === key)
|
|
423
|
+
if (!p) throw new Error(`out port not found: ${key}`)
|
|
424
|
+
return p
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
describe("saveGraphBodySchema(kind 別ポート構成)", () => {
|
|
428
|
+
it("human の旧 ok/ng 構成を拒否し、in/approve/reject を受け付ける", () => {
|
|
429
|
+
expect(() =>
|
|
430
|
+
saveGraphBodySchema.parse({ nodes: [humanNode()], connections: [] }),
|
|
431
|
+
).not.toThrow()
|
|
432
|
+
const legacy = {
|
|
433
|
+
...makeNode([port("in", "in"), port("out", "ok"), port("out", "ng")]),
|
|
434
|
+
kind: "human" as const,
|
|
435
|
+
}
|
|
436
|
+
expect(() =>
|
|
437
|
+
saveGraphBodySchema.parse({ nodes: [legacy], connections: [] }),
|
|
438
|
+
).toThrow(/human のポート構成/)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it("ai は out を複数持てる(key 自由。AI の複数出口=分岐)", () => {
|
|
442
|
+
const multi = {
|
|
443
|
+
...makeNode([
|
|
444
|
+
port("in", "in"),
|
|
445
|
+
{ ...port("out", "spam"), schema: aiEnvelope },
|
|
446
|
+
{ ...port("out", "sales"), schema: aiEnvelope },
|
|
447
|
+
{ ...port("out", "support"), schema: aiEnvelope },
|
|
448
|
+
]),
|
|
449
|
+
kind: "ai" as const,
|
|
450
|
+
}
|
|
451
|
+
expect(() =>
|
|
452
|
+
saveGraphBodySchema.parse({ nodes: [multi], connections: [] }),
|
|
453
|
+
).not.toThrow()
|
|
454
|
+
// in の key は "in" 固定のまま
|
|
455
|
+
const badIn = {
|
|
456
|
+
...makeNode([
|
|
457
|
+
port("in", "input"),
|
|
458
|
+
{ ...port("out", "main"), schema: aiEnvelope },
|
|
459
|
+
]),
|
|
460
|
+
kind: "ai" as const,
|
|
461
|
+
}
|
|
462
|
+
expect(() =>
|
|
463
|
+
saveGraphBodySchema.parse({ nodes: [badIn], connections: [] }),
|
|
464
|
+
).toThrow(/ai の in ポート/)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it("program は out を複数持てる(key 自由)", () => {
|
|
468
|
+
const prog = {
|
|
469
|
+
...makeNode([
|
|
470
|
+
port("in", "in"),
|
|
471
|
+
port("out", "success"),
|
|
472
|
+
port("out", "failure"),
|
|
473
|
+
]),
|
|
474
|
+
kind: "program" as const,
|
|
475
|
+
}
|
|
476
|
+
expect(() =>
|
|
477
|
+
saveGraphBodySchema.parse({ nodes: [prog], connections: [] }),
|
|
478
|
+
).not.toThrow()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it("ai の出力宣言が封筒型 { data, description } でないと拒否する(全出口に適用)", () => {
|
|
482
|
+
const bad = makeNode([
|
|
483
|
+
port("in", "in"),
|
|
484
|
+
{ ...port("out", "main"), schema: { type: "object" } },
|
|
485
|
+
])
|
|
486
|
+
expect(() =>
|
|
487
|
+
saveGraphBodySchema.parse({ nodes: [bad], connections: [] }),
|
|
488
|
+
).toThrow(/封筒型 \{ data, description \}/)
|
|
489
|
+
// main 以外の出口も封筒型を強制する
|
|
490
|
+
const badExtra = makeNode([
|
|
491
|
+
port("in", "in"),
|
|
492
|
+
{ ...port("out", "main"), schema: aiEnvelope },
|
|
493
|
+
{ ...port("out", "extra"), schema: { type: "object" } },
|
|
494
|
+
])
|
|
495
|
+
expect(() =>
|
|
496
|
+
saveGraphBodySchema.parse({ nodes: [badExtra], connections: [] }),
|
|
497
|
+
).toThrow(/封筒型 \{ data, description \}/)
|
|
498
|
+
// data は任意の型でよい(カタログ外の object も通る)
|
|
499
|
+
const custom = makeNode([
|
|
500
|
+
port("in", "in"),
|
|
501
|
+
{
|
|
502
|
+
...port("out", "main"),
|
|
503
|
+
schema: envelopeSchemaOf({
|
|
504
|
+
type: "object",
|
|
505
|
+
properties: { title: { type: "string" } },
|
|
506
|
+
required: ["title"],
|
|
507
|
+
}),
|
|
508
|
+
},
|
|
509
|
+
])
|
|
510
|
+
expect(() =>
|
|
511
|
+
saveGraphBodySchema.parse({ nodes: [custom], connections: [] }),
|
|
512
|
+
).not.toThrow()
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe("saveGraphBodySchema(型の接続バリデーション)", () => {
|
|
517
|
+
it("human の reject → ai は許可、ai 以外は拒否", () => {
|
|
518
|
+
const human = humanNode()
|
|
519
|
+
const ai = aiNode()
|
|
520
|
+
const prog = programNode()
|
|
521
|
+
expect(() =>
|
|
522
|
+
saveGraphBodySchema.parse({
|
|
523
|
+
nodes: [human, ai],
|
|
524
|
+
connections: [
|
|
525
|
+
{
|
|
526
|
+
from_port_id: outByKey(human, "reject").id,
|
|
527
|
+
to_port_id: inOf(ai).id,
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
}),
|
|
531
|
+
).not.toThrow()
|
|
532
|
+
expect(() =>
|
|
533
|
+
saveGraphBodySchema.parse({
|
|
534
|
+
nodes: [human, prog],
|
|
535
|
+
connections: [
|
|
536
|
+
{
|
|
537
|
+
from_port_id: outByKey(human, "reject").id,
|
|
538
|
+
to_port_id: inOf(prog).id,
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
}),
|
|
542
|
+
).toThrow(/reject.*AI ノードのみ/)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it("human の in はどの型でも接続できる(封筒型の制約は課さない)", () => {
|
|
546
|
+
const ai = aiNode()
|
|
547
|
+
const human = humanNode()
|
|
548
|
+
expect(() =>
|
|
549
|
+
saveGraphBodySchema.parse({
|
|
550
|
+
nodes: [ai, human],
|
|
551
|
+
connections: [connect(ai, human)],
|
|
552
|
+
}),
|
|
553
|
+
).not.toThrow()
|
|
554
|
+
|
|
555
|
+
// program out が封筒型でない({ type: "object" } のまま)でも接続できる
|
|
556
|
+
// (input 全体を data として表示する)
|
|
557
|
+
const human2 = humanNode()
|
|
558
|
+
const progPlain = {
|
|
559
|
+
...makeNode([
|
|
560
|
+
port("in", "in"),
|
|
561
|
+
{ ...port("out", "main"), schema: { type: "object" } },
|
|
562
|
+
]),
|
|
563
|
+
kind: "program" as const,
|
|
564
|
+
}
|
|
565
|
+
expect(() =>
|
|
566
|
+
saveGraphBodySchema.parse({
|
|
567
|
+
nodes: [progPlain, human2],
|
|
568
|
+
connections: [connect(progPlain, human2)],
|
|
569
|
+
}),
|
|
570
|
+
).not.toThrow()
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it("program の in への接続は 伝播型 ⊆ 宣言スキーマ(human 経由の伝播込み)", () => {
|
|
574
|
+
// ai(封筒型) → human(approve は data 型に剥がれる) → program(string を要求) は通る
|
|
575
|
+
const ai = aiNode()
|
|
576
|
+
const human = humanNode()
|
|
577
|
+
const prog = programNode({ type: "string" })
|
|
578
|
+
expect(() =>
|
|
579
|
+
saveGraphBodySchema.parse({
|
|
580
|
+
nodes: [ai, human, prog],
|
|
581
|
+
connections: [
|
|
582
|
+
connect(ai, human),
|
|
583
|
+
{
|
|
584
|
+
from_port_id: outByKey(human, "approve").id,
|
|
585
|
+
to_port_id: inOf(prog).id,
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
}),
|
|
589
|
+
).not.toThrow()
|
|
590
|
+
|
|
591
|
+
// program の in は接続鏡映(sticky):宣言と型が違っても保存エラーにせず、
|
|
592
|
+
// 接続元の具体型が in に鏡映される(repo の config.json へ書き戻され、
|
|
593
|
+
// handler との型整合は SDK の typegen が担う。ポート編集のrepo書き戻し同期.md)
|
|
594
|
+
const prog2 = programNode({
|
|
595
|
+
type: "object",
|
|
596
|
+
properties: { data: { type: "number" } },
|
|
597
|
+
required: ["data"],
|
|
598
|
+
})
|
|
599
|
+
const ai2 = aiNode()
|
|
600
|
+
const human2 = humanNode()
|
|
601
|
+
expect(() =>
|
|
602
|
+
saveGraphBodySchema.parse({
|
|
603
|
+
nodes: [ai2, human2, prog2],
|
|
604
|
+
connections: [
|
|
605
|
+
connect(ai2, human2),
|
|
606
|
+
{
|
|
607
|
+
from_port_id: outByKey(human2, "approve").id,
|
|
608
|
+
to_port_id: inOf(prog2).id,
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
}),
|
|
612
|
+
).not.toThrow()
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it("ai の in への接続は常に許可(coerce)", () => {
|
|
616
|
+
const prog = programNode()
|
|
617
|
+
const ai = aiNode()
|
|
618
|
+
expect(() =>
|
|
619
|
+
saveGraphBodySchema.parse({
|
|
620
|
+
nodes: [prog, ai],
|
|
621
|
+
connections: [connect(prog, ai)],
|
|
622
|
+
}),
|
|
623
|
+
).not.toThrow()
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
describe("saveGraphBodySchema(hooks)", () => {
|
|
628
|
+
const hook = (dir = "監査ログ") => ({
|
|
629
|
+
hook_dir: dir,
|
|
630
|
+
on_input: true,
|
|
631
|
+
on_output: false,
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it("program ノードの hooks 配列を受け付ける(省略時は [] が補われる)", () => {
|
|
635
|
+
const withHooks = {
|
|
636
|
+
...programNode(),
|
|
637
|
+
hooks: [hook(), { ...hook("通知"), ref: "main" }],
|
|
638
|
+
}
|
|
639
|
+
const parsed = saveGraphBodySchema.parse({
|
|
640
|
+
nodes: [withHooks],
|
|
641
|
+
connections: [],
|
|
642
|
+
})
|
|
643
|
+
expect(parsed.nodes[0]?.hooks).toHaveLength(2)
|
|
644
|
+
expect(parsed.nodes[0]?.hooks?.[1]?.ref).toBe("main")
|
|
645
|
+
|
|
646
|
+
const without = saveGraphBodySchema.parse({
|
|
647
|
+
nodes: [programNode()],
|
|
648
|
+
connections: [],
|
|
649
|
+
})
|
|
650
|
+
expect(without.nodes[0]?.hooks).toEqual([])
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it("program 以外のノードへの hooks を拒否する", () => {
|
|
654
|
+
for (const node of [makeNode(), humanNode()]) {
|
|
655
|
+
expect(() =>
|
|
656
|
+
saveGraphBodySchema.parse({
|
|
657
|
+
nodes: [{ ...node, hooks: [hook()] }],
|
|
658
|
+
connections: [],
|
|
659
|
+
}),
|
|
660
|
+
).toThrow(/program ノード/)
|
|
661
|
+
}
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it("hook_dir のパス区切り・. / .. を拒否する", () => {
|
|
665
|
+
for (const dir of ["a/b", "a\\b", ".", ".."]) {
|
|
666
|
+
expect(() =>
|
|
667
|
+
saveGraphBodySchema.parse({
|
|
668
|
+
nodes: [{ ...programNode(), hooks: [hook(dir)] }],
|
|
669
|
+
connections: [],
|
|
670
|
+
}),
|
|
671
|
+
).toThrow(/hook_dir/)
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it("同一 node 内の hook_dir 重複を拒否する", () => {
|
|
676
|
+
expect(() =>
|
|
677
|
+
saveGraphBodySchema.parse({
|
|
678
|
+
nodes: [{ ...programNode(), hooks: [hook(), hook()] }],
|
|
679
|
+
connections: [],
|
|
680
|
+
}),
|
|
681
|
+
).toThrow(/hook_dir が重複/)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it("on_signal を受け付ける(省略時は false が補われる)", () => {
|
|
685
|
+
const parsed = saveGraphBodySchema.parse({
|
|
686
|
+
nodes: [
|
|
687
|
+
{
|
|
688
|
+
...programNode(),
|
|
689
|
+
hooks: [hook(), { ...hook("PR同期"), on_signal: true }],
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
connections: [],
|
|
693
|
+
})
|
|
694
|
+
expect(parsed.nodes[0]?.hooks?.[0]?.on_signal).toBe(false)
|
|
695
|
+
expect(parsed.nodes[0]?.hooks?.[1]?.on_signal).toBe(true)
|
|
696
|
+
})
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
// ---- refs(ループ変数の定義スキーマ。ループ変数(refs)のJSON Schema化.md) ----
|
|
700
|
+
|
|
701
|
+
describe("saveGraphBodySchema(refs:ループ変数の定義スキーマ)", () => {
|
|
702
|
+
it("object JSON Schema 形の refs(properties / required / default)を受け付ける", () => {
|
|
703
|
+
const refs = {
|
|
704
|
+
type: "object",
|
|
705
|
+
properties: {
|
|
706
|
+
thread_ts: { type: "string" },
|
|
707
|
+
channel: { type: "string", default: "#nitte-cs" },
|
|
708
|
+
retry_limit: { type: "number", default: 3 },
|
|
709
|
+
},
|
|
710
|
+
required: ["thread_ts"],
|
|
711
|
+
}
|
|
712
|
+
const parsed = saveGraphBodySchema.parse({
|
|
713
|
+
nodes: [makeNode()],
|
|
714
|
+
connections: [],
|
|
715
|
+
refs,
|
|
716
|
+
})
|
|
717
|
+
expect(parsed.refs).toEqual(refs)
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it("省略時は空の object スキーマが補われる", () => {
|
|
721
|
+
const parsed = saveGraphBodySchema.parse({
|
|
722
|
+
nodes: [makeNode()],
|
|
723
|
+
connections: [],
|
|
724
|
+
})
|
|
725
|
+
expect(parsed.refs).toEqual({ type: "object", properties: {} })
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it("properties のキーはポート key 規則(^[a-z][a-z0-9_]*$)に従う", () => {
|
|
729
|
+
for (const key of ["Reply", "reply-to", "1st", ""]) {
|
|
730
|
+
expect(() =>
|
|
731
|
+
saveGraphBodySchema.parse({
|
|
732
|
+
nodes: [makeNode()],
|
|
733
|
+
connections: [],
|
|
734
|
+
refs: { type: "object", properties: { [key]: { type: "string" } } },
|
|
735
|
+
}),
|
|
736
|
+
).toThrow()
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
it("required が properties に無いキーを含むと拒否する", () => {
|
|
741
|
+
expect(() =>
|
|
742
|
+
saveGraphBodySchema.parse({
|
|
743
|
+
nodes: [makeNode()],
|
|
744
|
+
connections: [],
|
|
745
|
+
refs: {
|
|
746
|
+
type: "object",
|
|
747
|
+
properties: { thread_ts: { type: "string" } },
|
|
748
|
+
required: ["reply_to"],
|
|
749
|
+
},
|
|
750
|
+
}),
|
|
751
|
+
).toThrow(/required/)
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it("object 以外の type・旧「値の袋」形は拒否する", () => {
|
|
755
|
+
for (const refs of [
|
|
756
|
+
{ type: "string" },
|
|
757
|
+
{ reply_to: "someone@example.com" },
|
|
758
|
+
]) {
|
|
759
|
+
expect(() =>
|
|
760
|
+
saveGraphBodySchema.parse({
|
|
761
|
+
nodes: [makeNode()],
|
|
762
|
+
connections: [],
|
|
763
|
+
refs,
|
|
764
|
+
}),
|
|
765
|
+
).toThrow()
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it("旧 node 単位の refs は受け付けず strip される", () => {
|
|
770
|
+
const parsed = saveGraphBodySchema.parse({
|
|
771
|
+
nodes: [
|
|
772
|
+
{
|
|
773
|
+
...programNode(),
|
|
774
|
+
refs: [{ key: "reply_to", schema: { type: "string" } }],
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
connections: [],
|
|
778
|
+
})
|
|
779
|
+
expect(Object.keys(parsed.nodes[0] ?? {})).not.toContain("refs")
|
|
780
|
+
})
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
// start / end は恒等パススルー(in+out(main))。end は in を複数持てる
|
|
784
|
+
const startNode = (): TestNode => ({
|
|
785
|
+
...makeNode([port("in", "in"), port("out", "main")]),
|
|
786
|
+
kind: "start",
|
|
787
|
+
})
|
|
788
|
+
const endNode = (inKeys: string[] = ["in"]): TestNode => ({
|
|
789
|
+
...makeNode([...inKeys.map((k) => port("in", k)), port("out", "main")]),
|
|
790
|
+
kind: "end",
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
describe("saveGraphBodySchema:開始/終了ノード(start/end kind)", () => {
|
|
794
|
+
it("start/end を含むグラフを受け付ける", () => {
|
|
795
|
+
const parsed = saveGraphBodySchema.parse({
|
|
796
|
+
nodes: [startNode(), endNode()],
|
|
797
|
+
connections: [],
|
|
798
|
+
})
|
|
799
|
+
expect(parsed.nodes.map((n) => n.kind).sort()).toEqual(["end", "start"])
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it("start は最大1つ・end は最大1つ", () => {
|
|
803
|
+
expect(() =>
|
|
804
|
+
saveGraphBodySchema.parse({
|
|
805
|
+
nodes: [startNode(), startNode()],
|
|
806
|
+
connections: [],
|
|
807
|
+
}),
|
|
808
|
+
).toThrow(/start/)
|
|
809
|
+
expect(() =>
|
|
810
|
+
saveGraphBodySchema.parse({
|
|
811
|
+
nodes: [endNode(), endNode()],
|
|
812
|
+
connections: [],
|
|
813
|
+
}),
|
|
814
|
+
).toThrow(/end/)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it("end は in を複数持てる/start・他 kind は in 1つ固定", () => {
|
|
818
|
+
expect(() =>
|
|
819
|
+
saveGraphBodySchema.parse({
|
|
820
|
+
nodes: [endNode(["in", "in_2"])],
|
|
821
|
+
connections: [],
|
|
822
|
+
}),
|
|
823
|
+
).not.toThrow()
|
|
824
|
+
const badStart: TestNode = {
|
|
825
|
+
...startNode(),
|
|
826
|
+
ports: [port("in", "in"), port("in", "in_2"), port("out", "main")],
|
|
827
|
+
}
|
|
828
|
+
expect(() =>
|
|
829
|
+
saveGraphBodySchema.parse({ nodes: [badStart], connections: [] }),
|
|
830
|
+
).toThrow(/in ポートはちょうど1つ/)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it("start への接続・end からの接続を拒否する", () => {
|
|
834
|
+
const s = startNode()
|
|
835
|
+
const e = endNode()
|
|
836
|
+
const other = makeNode()
|
|
837
|
+
expect(() =>
|
|
838
|
+
saveGraphBodySchema.parse({
|
|
839
|
+
nodes: [s, other],
|
|
840
|
+
connections: [connect(other, s)],
|
|
841
|
+
}),
|
|
842
|
+
).toThrow(/開始ノード/)
|
|
843
|
+
expect(() =>
|
|
844
|
+
saveGraphBodySchema.parse({
|
|
845
|
+
nodes: [e, other],
|
|
846
|
+
connections: [connect(e, other)],
|
|
847
|
+
}),
|
|
848
|
+
).toThrow(/終了ノード/)
|
|
849
|
+
})
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
describe("refsDefaultsOf(定義スキーマ→デフォルト値の袋)", () => {
|
|
853
|
+
it("default を持つプロパティだけを値の袋に集める", () => {
|
|
854
|
+
const defaults = refsDefaultsOf({
|
|
855
|
+
type: "object",
|
|
856
|
+
properties: {
|
|
857
|
+
thread_ts: { type: "string" },
|
|
858
|
+
channel: { type: "string", default: "#nitte-cs" },
|
|
859
|
+
flags: { type: "object", default: { dry_run: true } },
|
|
860
|
+
},
|
|
861
|
+
required: ["thread_ts"],
|
|
862
|
+
})
|
|
863
|
+
expect(defaults).toEqual({
|
|
864
|
+
channel: "#nitte-cs",
|
|
865
|
+
flags: { dry_run: true },
|
|
866
|
+
})
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it("properties が空なら空の袋を返す", () => {
|
|
870
|
+
expect(refsDefaultsOf({ type: "object", properties: {} })).toEqual({})
|
|
871
|
+
})
|
|
872
|
+
})
|