@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,439 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import {
|
|
3
|
+
graphPortSchema,
|
|
4
|
+
jsonSchemaSchema,
|
|
5
|
+
nodeKindSchema,
|
|
6
|
+
portKeySchema,
|
|
7
|
+
} from "./node.js"
|
|
8
|
+
import {
|
|
9
|
+
isAiEnvelopeSchema,
|
|
10
|
+
isSchemaSubset,
|
|
11
|
+
kindPortsViolation,
|
|
12
|
+
resolvePortSchemas,
|
|
13
|
+
} from "./port-spec.js"
|
|
14
|
+
|
|
15
|
+
// refs=ループ変数の袋(key→任意の JSON 値。上流出力の参照(refs).md)。
|
|
16
|
+
// run 開始時に定義から複製され、全 step に全量差し込まれる。
|
|
17
|
+
// key はポート key と同じ規則(^[a-z][a-z0-9_]*$)。実行系ボディ
|
|
18
|
+
// (run 起動の上書き・step end のパッチ)が使う契約
|
|
19
|
+
export const refsBagSchema = z.record(portKeySchema, z.unknown())
|
|
20
|
+
export type RefsBag = z.infer<typeof refsBagSchema>
|
|
21
|
+
|
|
22
|
+
// ループ変数の定義=袋全体を表す 1 つの object JSON Schema
|
|
23
|
+
// (ループ変数(refs)のJSON Schema化.md)。properties のキーが変数名、
|
|
24
|
+
// default が静的なデフォルト値、required は「run 起動時に値が確定して
|
|
25
|
+
// いなければならないキー」(実行中に handler が書くキーは required にしない)
|
|
26
|
+
export const loopRefsSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
type: z.literal("object"),
|
|
29
|
+
properties: z.record(portKeySchema, jsonSchemaSchema).default({}),
|
|
30
|
+
required: z.array(portKeySchema).optional(),
|
|
31
|
+
})
|
|
32
|
+
.superRefine((refs, ctx) => {
|
|
33
|
+
for (const key of refs.required ?? []) {
|
|
34
|
+
if (!(key in refs.properties)) {
|
|
35
|
+
ctx.addIssue({
|
|
36
|
+
code: "custom",
|
|
37
|
+
path: ["required"],
|
|
38
|
+
message: `required のキー ${key} が properties にありません`,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
export type LoopRefs = z.infer<typeof loopRefsSchema>
|
|
44
|
+
|
|
45
|
+
export const EMPTY_LOOP_REFS: LoopRefs = { type: "object", properties: {} }
|
|
46
|
+
|
|
47
|
+
// 定義スキーマ→デフォルト値の袋(default を持つプロパティだけ集める)。
|
|
48
|
+
// run 起動時のマージの土台と、実行フォームの前埋めの両方が使う
|
|
49
|
+
export const refsDefaultsOf = (refs: LoopRefs): RefsBag =>
|
|
50
|
+
Object.fromEntries(
|
|
51
|
+
Object.entries(refs.properties)
|
|
52
|
+
.filter(([, schema]) => "default" in schema)
|
|
53
|
+
.map(([key, schema]) => [key, schema.default]),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// パレットから作成できる kind(guardrail は後段の強化バックログ。
|
|
57
|
+
// start/end は loop 作成時に seed される構造ノードなのでパレットには出さない)
|
|
58
|
+
export const editableNodeKindSchema = nodeKindSchema.extract([
|
|
59
|
+
"ai",
|
|
60
|
+
"human",
|
|
61
|
+
"program",
|
|
62
|
+
"wait",
|
|
63
|
+
])
|
|
64
|
+
export type EditableNodeKind = z.infer<typeof editableNodeKindSchema>
|
|
65
|
+
|
|
66
|
+
// グラフ一括保存で受け付ける kind(パレット作成の4種+構造ノード start/end)。
|
|
67
|
+
// start/end はパレットに出さないが、seed 済みなので全置換ボディには常に含まれる
|
|
68
|
+
export const graphNodeKindSchema = nodeKindSchema.extract([
|
|
69
|
+
"ai",
|
|
70
|
+
"human",
|
|
71
|
+
"program",
|
|
72
|
+
"wait",
|
|
73
|
+
"start",
|
|
74
|
+
"end",
|
|
75
|
+
])
|
|
76
|
+
export type GraphNodeKind = z.infer<typeof graphNodeKindSchema>
|
|
77
|
+
|
|
78
|
+
// Wait ノードの kind 別設定(node_waits の wait_type に対応する判別ユニオン)。
|
|
79
|
+
// duration=経過秒、until=絶対時刻(ISO)、cron=cron 式(次回発火まで1回待つ)。
|
|
80
|
+
// cron 式の妥当性は保存時に backend の cron パーサで検証する(ここは非空のみ)
|
|
81
|
+
export const waitConfigSchema = z.discriminatedUnion("wait_type", [
|
|
82
|
+
z.object({
|
|
83
|
+
wait_type: z.literal("duration"),
|
|
84
|
+
duration_seconds: z.number().int().positive(),
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
wait_type: z.literal("until"),
|
|
88
|
+
until_at: z.iso.datetime({ offset: true }),
|
|
89
|
+
}),
|
|
90
|
+
z.object({
|
|
91
|
+
wait_type: z.literal("cron"),
|
|
92
|
+
cron_expr: z.string().min(1),
|
|
93
|
+
}),
|
|
94
|
+
])
|
|
95
|
+
export type WaitConfig = z.infer<typeof waitConfigSchema>
|
|
96
|
+
|
|
97
|
+
// Program ノードの kind 別設定(node_programs に対応)。実行先の repo はノード設定でなく
|
|
98
|
+
// installation から `${account_login}/${GITHUB_REPO_NAME}` に固定(backend)。program_dir は nodes/program/ 直下のディレクトリ名
|
|
99
|
+
// (例: sendReply。日本語可。エントリは main.ts 固定。リポジトリ規約 v2 は
|
|
100
|
+
// docs/tasks/wip/プログラムのリポジトリ規約v2(ディレクトリ+config.json).md)。
|
|
101
|
+
// ref 省略時はデフォルトブランチ
|
|
102
|
+
export const programConfigSchema = z.object({
|
|
103
|
+
program_dir: z
|
|
104
|
+
.string()
|
|
105
|
+
.min(1)
|
|
106
|
+
.refine(
|
|
107
|
+
(v) => !v.includes("/") && !v.includes("\\") && v !== "." && v !== "..",
|
|
108
|
+
"program_dir は nodes/program/ 直下のディレクトリ名で指定してください",
|
|
109
|
+
),
|
|
110
|
+
ref: z.string().min(1).optional(),
|
|
111
|
+
})
|
|
112
|
+
export type ProgramConfig = z.infer<typeof programConfigSchema>
|
|
113
|
+
|
|
114
|
+
// AI ノードの kind 別設定(node_ais に対応)。定義の本体(prompt/model/skills/スキーマ/env)
|
|
115
|
+
// は実行 repo の nodes/ai/<dir>/config.json が正(aiManifestSchema)で、ノードは参照だけ持つ
|
|
116
|
+
// (programConfigSchema と同じ関係。実行repoの構造見直し)。ref 省略時はデフォルトブランチ
|
|
117
|
+
export const aiConfigSchema = z.object({
|
|
118
|
+
ai_dir: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.refine(
|
|
122
|
+
(v) => !v.includes("/") && !v.includes("\\") && v !== "." && v !== "..",
|
|
123
|
+
"ai_dir は nodes/ai/ 直下のディレクトリ名で指定してください",
|
|
124
|
+
),
|
|
125
|
+
ref: z.string().min(1).optional(),
|
|
126
|
+
})
|
|
127
|
+
export type AiConfig = z.infer<typeof aiConfigSchema>
|
|
128
|
+
|
|
129
|
+
// Human ノードの kind 別設定(node_humans + node_human_assignees に対応)。
|
|
130
|
+
// 担当 = ユニット(1人以上のメンバー)の順序付きリスト + 承認モード + 選出戦略。
|
|
131
|
+
// units[i] は user_id の配列(1人=個人・複数=グループ)。units 空 = tenant の誰でも。
|
|
132
|
+
// approval_mode はノード単位(any=誰か1人 / all=全員。差し戻しは1人で成立=veto)。
|
|
133
|
+
// メンバーが tenant の membership であることの検証は保存 API 側で行う
|
|
134
|
+
// (docs/tasks/backlog/Human担当者の高度化(複数人承認とラウンドロビン).md)
|
|
135
|
+
export const humanConfigSchema = z.object({
|
|
136
|
+
assignment: z
|
|
137
|
+
.object({
|
|
138
|
+
strategy: z.enum(["fixed", "round_robin"]).default("fixed"),
|
|
139
|
+
approval_mode: z.enum(["any", "all"]).default("any"),
|
|
140
|
+
units: z.array(z.array(z.uuid()).min(1)).default([]),
|
|
141
|
+
})
|
|
142
|
+
.superRefine((assignment, ctx) => {
|
|
143
|
+
if (assignment.strategy === "fixed" && assignment.units.length > 1) {
|
|
144
|
+
ctx.addIssue({
|
|
145
|
+
code: "custom",
|
|
146
|
+
path: ["units"],
|
|
147
|
+
message: "fixed のユニットは1つまでです",
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
assignment.strategy === "round_robin" &&
|
|
152
|
+
assignment.units.length < 2
|
|
153
|
+
) {
|
|
154
|
+
ctx.addIssue({
|
|
155
|
+
code: "custom",
|
|
156
|
+
path: ["units"],
|
|
157
|
+
message: "round_robin にはユニットが2つ以上必要です",
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
// ユニット内の重複は拒否(またぐ重複は「Aは毎回・相手が交代」の形があるので許す)
|
|
161
|
+
assignment.units.forEach((unit, i) => {
|
|
162
|
+
if (new Set(unit).size !== unit.length) {
|
|
163
|
+
ctx.addIssue({
|
|
164
|
+
code: "custom",
|
|
165
|
+
path: ["units", i],
|
|
166
|
+
message: "ユニット内で user_id が重複しています",
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
}),
|
|
171
|
+
})
|
|
172
|
+
export type HumanConfig = z.infer<typeof humanConfigSchema>
|
|
173
|
+
|
|
174
|
+
// ノードにアタッチされた IOHook(node_hooks に対応。アタッチできるのは program ノードのみ)。
|
|
175
|
+
// on_input / on_output / on_signal はアタッチ時に repo の config.json(hookManifestSchema)から
|
|
176
|
+
// エディタが焼き込むスナップショット。ref 省略時はデフォルトブランチ
|
|
177
|
+
// (docs/tasks/wip/NodeIOHooks(ノードへのIOフックのアタッチ).md・
|
|
178
|
+
// NodeIOHooksのonSignal(外部シグナルでのフック発火).md)
|
|
179
|
+
export const graphNodeHookSchema = z.object({
|
|
180
|
+
hook_dir: z
|
|
181
|
+
.string()
|
|
182
|
+
.min(1)
|
|
183
|
+
.refine(
|
|
184
|
+
(v) => !v.includes("/") && !v.includes("\\") && v !== "." && v !== "..",
|
|
185
|
+
"hook_dir は nodes/hook/ 直下のディレクトリ名で指定してください",
|
|
186
|
+
),
|
|
187
|
+
ref: z.string().min(1).optional(),
|
|
188
|
+
on_input: z.boolean(),
|
|
189
|
+
on_output: z.boolean(),
|
|
190
|
+
// 既存グラフ(on_signal 導入前の保存物)の互換のため省略時 false
|
|
191
|
+
on_signal: z.boolean().default(false),
|
|
192
|
+
})
|
|
193
|
+
export type GraphNodeHook = z.infer<typeof graphNodeHookSchema>
|
|
194
|
+
|
|
195
|
+
// 新規ノードの id・ポートの id もクライアントが採番する(一時IDの解決を不要にする)
|
|
196
|
+
export const graphNodeSchema = z.object({
|
|
197
|
+
id: z.uuid(),
|
|
198
|
+
kind: graphNodeKindSchema,
|
|
199
|
+
name: z.string().min(1),
|
|
200
|
+
ports: z.array(graphPortSchema),
|
|
201
|
+
hooks: z.array(graphNodeHookSchema).default([]),
|
|
202
|
+
position_x: z.number(),
|
|
203
|
+
position_y: z.number(),
|
|
204
|
+
// kind 別設定(自 kind のときだけ意味を持つ。省略時は保存側がデフォルトを補う)
|
|
205
|
+
wait: waitConfigSchema.optional(),
|
|
206
|
+
program: programConfigSchema.optional(),
|
|
207
|
+
ai: aiConfigSchema.optional(),
|
|
208
|
+
human: humanConfigSchema.optional(),
|
|
209
|
+
})
|
|
210
|
+
export type GraphNode = z.infer<typeof graphNodeSchema>
|
|
211
|
+
|
|
212
|
+
export const graphConnectionSchema = z.object({
|
|
213
|
+
from_port_id: z.uuid(),
|
|
214
|
+
to_port_id: z.uuid(),
|
|
215
|
+
})
|
|
216
|
+
export type GraphConnection = z.infer<typeof graphConnectionSchema>
|
|
217
|
+
|
|
218
|
+
// PUT /tenants/:tenantId/loops/:loopId/graph のボディ(グラフ一括保存)。
|
|
219
|
+
// refs=ループ変数の定義スキーマ(loops.refs)。回路と同じ「エディタの保存」で全置換する。
|
|
220
|
+
// 開始/終了は専用 kind のノード(start/end)で表す(loops のカラムは廃止。
|
|
221
|
+
// 開始・終了ノードの定義.md)。exactly-one と削除/追加禁止は状態依存なので put.ts が担い、
|
|
222
|
+
// ここでは stateless な「各 kind 最大1つ」だけ見る
|
|
223
|
+
export const saveGraphBodySchema = z
|
|
224
|
+
.object({
|
|
225
|
+
nodes: z.array(graphNodeSchema),
|
|
226
|
+
connections: z.array(graphConnectionSchema),
|
|
227
|
+
refs: loopRefsSchema.default(EMPTY_LOOP_REFS),
|
|
228
|
+
})
|
|
229
|
+
.superRefine((graph, ctx) => {
|
|
230
|
+
const nodeIds = new Set(graph.nodes.map((n) => n.id))
|
|
231
|
+
if (nodeIds.size !== graph.nodes.length) {
|
|
232
|
+
ctx.addIssue({
|
|
233
|
+
code: "custom",
|
|
234
|
+
path: ["nodes"],
|
|
235
|
+
message: "node の id が重複しています",
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// start / end は各 loop に最大1つ(seed により常にちょうど1つだが、契約としては最大1)
|
|
240
|
+
for (const kind of ["start", "end"] as const) {
|
|
241
|
+
if (graph.nodes.filter((n) => n.kind === kind).length > 1) {
|
|
242
|
+
ctx.addIssue({
|
|
243
|
+
code: "custom",
|
|
244
|
+
path: ["nodes"],
|
|
245
|
+
message: `${kind} ノードは1つまでです`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ポートの形:id は全体で一意、(io, key) は node 内で一意、
|
|
251
|
+
// in ポートちょうど1つ(join 導入まで)+ out ポート1つ以上
|
|
252
|
+
const portById = new Map<string, z.infer<typeof graphPortSchema>>()
|
|
253
|
+
const nodeByPortId = new Map<string, z.infer<typeof graphNodeSchema>>()
|
|
254
|
+
graph.nodes.forEach((node, i) => {
|
|
255
|
+
const ioKeys = new Set<string>()
|
|
256
|
+
let inCount = 0
|
|
257
|
+
let outCount = 0
|
|
258
|
+
node.ports.forEach((port, j) => {
|
|
259
|
+
if (portById.has(port.id)) {
|
|
260
|
+
ctx.addIssue({
|
|
261
|
+
code: "custom",
|
|
262
|
+
path: ["nodes", i, "ports", j, "id"],
|
|
263
|
+
message: "port の id が重複しています",
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
portById.set(port.id, port)
|
|
267
|
+
nodeByPortId.set(port.id, node)
|
|
268
|
+
|
|
269
|
+
const ioKey = `${port.io}:${port.key}`
|
|
270
|
+
if (ioKeys.has(ioKey)) {
|
|
271
|
+
ctx.addIssue({
|
|
272
|
+
code: "custom",
|
|
273
|
+
path: ["nodes", i, "ports", j, "key"],
|
|
274
|
+
message: "同じ io のポートで key が重複しています",
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
ioKeys.add(ioKey)
|
|
278
|
+
|
|
279
|
+
if (port.io === "in") inCount++
|
|
280
|
+
else outCount++
|
|
281
|
+
})
|
|
282
|
+
// end だけ「in は1つ以上」(複数ルートを別々の in で集約する)。
|
|
283
|
+
// start・他 kind は従来どおり1つ固定
|
|
284
|
+
if (node.kind === "end" ? inCount < 1 : inCount !== 1) {
|
|
285
|
+
ctx.addIssue({
|
|
286
|
+
code: "custom",
|
|
287
|
+
path: ["nodes", i, "ports"],
|
|
288
|
+
message:
|
|
289
|
+
node.kind === "end"
|
|
290
|
+
? "in ポートが1つ以上必要です"
|
|
291
|
+
: "in ポートはちょうど1つ必要です",
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
if (outCount < 1) {
|
|
295
|
+
ctx.addIssue({
|
|
296
|
+
code: "custom",
|
|
297
|
+
path: ["nodes", i, "ports"],
|
|
298
|
+
message: "out ポートが最低1つ必要です",
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// フックをアタッチできるのは program ノードのみ
|
|
303
|
+
if (node.kind !== "program" && node.hooks.length > 0) {
|
|
304
|
+
ctx.addIssue({
|
|
305
|
+
code: "custom",
|
|
306
|
+
path: ["nodes", i, "hooks"],
|
|
307
|
+
message: "フックをアタッチできるのは program ノードだけです",
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// フックの重複アタッチ(unique(node_id, hook_dir) の事前検証)
|
|
312
|
+
const hookDirs = new Set<string>()
|
|
313
|
+
node.hooks.forEach((hook, j) => {
|
|
314
|
+
if (hookDirs.has(hook.hook_dir)) {
|
|
315
|
+
ctx.addIssue({
|
|
316
|
+
code: "custom",
|
|
317
|
+
path: ["nodes", i, "hooks", j, "hook_dir"],
|
|
318
|
+
message: "同じノードに hook_dir が重複しています",
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
hookDirs.add(hook.hook_dir)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// kind 別のポート構成(program 以外は数・key・io 固定。ポートのkind別仕様.md)
|
|
325
|
+
const violation = kindPortsViolation(node.kind, node.ports)
|
|
326
|
+
if (violation) {
|
|
327
|
+
ctx.addIssue({
|
|
328
|
+
code: "custom",
|
|
329
|
+
path: ["nodes", i, "ports"],
|
|
330
|
+
message: violation,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// AI の出力宣言は封筒型 { data: T, description: string }(T は任意の JSON Schema)。
|
|
335
|
+
// 複数出口でも全出口に強制する(どの出口も human に接続できる不変条件)
|
|
336
|
+
if (node.kind === "ai") {
|
|
337
|
+
node.ports.forEach((port, j) => {
|
|
338
|
+
if (port.io === "out" && !isAiEnvelopeSchema(port.schema)) {
|
|
339
|
+
ctx.addIssue({
|
|
340
|
+
code: "custom",
|
|
341
|
+
path: ["nodes", i, "ports", j],
|
|
342
|
+
message:
|
|
343
|
+
"AI の出力は封筒型 { data, description } で宣言してください",
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// 接続:両端がグラフ内の port を指し、from=out / to=in。
|
|
351
|
+
// 同じ out ポートから複数の in への fan-out は許す(並行分岐)。
|
|
352
|
+
// 禁止するのは完全重複(同じ from→to の2本)だけ
|
|
353
|
+
const usedPairs = new Set<string>()
|
|
354
|
+
graph.connections.forEach((conn, i) => {
|
|
355
|
+
const from = portById.get(conn.from_port_id)
|
|
356
|
+
if (!from || from.io !== "out") {
|
|
357
|
+
ctx.addIssue({
|
|
358
|
+
code: "custom",
|
|
359
|
+
path: ["connections", i, "from_port_id"],
|
|
360
|
+
message: "接続元がグラフ内の out ポートではありません",
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
const to = portById.get(conn.to_port_id)
|
|
364
|
+
if (!to || to.io !== "in") {
|
|
365
|
+
ctx.addIssue({
|
|
366
|
+
code: "custom",
|
|
367
|
+
path: ["connections", i, "to_port_id"],
|
|
368
|
+
message: "接続先がグラフ内の in ポートではありません",
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
const pair = `${conn.from_port_id}→${conn.to_port_id}`
|
|
372
|
+
if (usedPairs.has(pair)) {
|
|
373
|
+
ctx.addIssue({
|
|
374
|
+
code: "custom",
|
|
375
|
+
path: ["connections", i, "to_port_id"],
|
|
376
|
+
message: "同じ接続が重複しています",
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
usedPairs.add(pair)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// 型の接続バリデーション(ポートのkind別仕様.md §接続バリデーション)。
|
|
383
|
+
// 導出ポートは接続から型を解決した上で判定する。ai の in・wait の in は常に許可
|
|
384
|
+
const resolved = resolvePortSchemas(graph.nodes, graph.connections)
|
|
385
|
+
graph.connections.forEach((conn, i) => {
|
|
386
|
+
const from = portById.get(conn.from_port_id)
|
|
387
|
+
const to = portById.get(conn.to_port_id)
|
|
388
|
+
const fromNode = nodeByPortId.get(conn.from_port_id)
|
|
389
|
+
const toNode = nodeByPortId.get(conn.to_port_id)
|
|
390
|
+
if (!from || !to || !fromNode || !toNode) return // 構造違反は検出済み
|
|
391
|
+
|
|
392
|
+
// start には引き込めない(run payload だけが入口)、end からは引き出せない(終端)
|
|
393
|
+
if (toNode.kind === "start") {
|
|
394
|
+
ctx.addIssue({
|
|
395
|
+
code: "custom",
|
|
396
|
+
path: ["connections", i, "to_port_id"],
|
|
397
|
+
message: "開始ノードには接続できません",
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
if (fromNode.kind === "end") {
|
|
401
|
+
ctx.addIssue({
|
|
402
|
+
code: "custom",
|
|
403
|
+
path: ["connections", i, "from_port_id"],
|
|
404
|
+
message: "終了ノードからは接続できません",
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// human の reject の接続先は ai ノードのみ(kind 制約)
|
|
409
|
+
if (
|
|
410
|
+
fromNode.kind === "human" &&
|
|
411
|
+
from.key === "reject" &&
|
|
412
|
+
toNode.kind !== "ai"
|
|
413
|
+
) {
|
|
414
|
+
ctx.addIssue({
|
|
415
|
+
code: "custom",
|
|
416
|
+
path: ["connections", i, "to_port_id"],
|
|
417
|
+
message: "差し戻し(reject)の接続先は AI ノードのみです",
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const fromSchema = resolved.get(conn.from_port_id) ?? from.schema
|
|
422
|
+
|
|
423
|
+
// human の in はどの型でも接続できる(上流が AI なら封筒 { data, description }、
|
|
424
|
+
// それ以外は input 全体を data として表示する。型制約は課さない)
|
|
425
|
+
|
|
426
|
+
// program の in への接続は 伝播型 ⊆ 宣言スキーマ。in は接続鏡映(sticky)なので
|
|
427
|
+
// 具体型の接続では自明に通り、接続元 any × 宣言維持のときだけ従来の意味を持つ
|
|
428
|
+
const toSchema = resolved.get(conn.to_port_id) ?? to.schema
|
|
429
|
+
if (toNode.kind === "program" && !isSchemaSubset(fromSchema, toSchema)) {
|
|
430
|
+
ctx.addIssue({
|
|
431
|
+
code: "custom",
|
|
432
|
+
path: ["connections", i, "to_port_id"],
|
|
433
|
+
message:
|
|
434
|
+
"接続元の型が program の入力スキーマに適合しません(伝播型 ⊆ 宣言スキーマ)",
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
export type SaveGraphBody = z.infer<typeof saveGraphBodySchema>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { hookManifestSchema } from "./hook-manifest.js"
|
|
3
|
+
|
|
4
|
+
describe("hookManifestSchema", () => {
|
|
5
|
+
it("name / description / on / env を受け付ける", () => {
|
|
6
|
+
const manifest = hookManifestSchema.parse({
|
|
7
|
+
name: "監査ログ",
|
|
8
|
+
description: "in/out を通過したデータをスプレッドシートに追記する",
|
|
9
|
+
on: ["input", "output"],
|
|
10
|
+
env: ["SHEETS_API_KEY"],
|
|
11
|
+
})
|
|
12
|
+
expect(manifest.name).toBe("監査ログ")
|
|
13
|
+
expect(manifest.on).toEqual(["input", "output"])
|
|
14
|
+
expect(manifest.env).toEqual(["SHEETS_API_KEY"])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("name / description / env は省略できる(env は [] が補われる)", () => {
|
|
18
|
+
const manifest = hookManifestSchema.parse({ on: ["input"] })
|
|
19
|
+
expect(manifest.name).toBeUndefined()
|
|
20
|
+
expect(manifest.env).toEqual([])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("on は input / output / signal のみ・1件以上・重複なし", () => {
|
|
24
|
+
expect(() => hookManifestSchema.parse({ on: [] })).toThrow()
|
|
25
|
+
expect(() => hookManifestSchema.parse({ on: ["start"] })).toThrow()
|
|
26
|
+
expect(() => hookManifestSchema.parse({ on: ["input", "input"] })).toThrow()
|
|
27
|
+
expect(() => hookManifestSchema.parse({})).toThrow()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("on: signal を受け付ける(onSignal=外部シグナルでの発火)", () => {
|
|
31
|
+
expect(hookManifestSchema.parse({ on: ["signal"] }).on).toEqual(["signal"])
|
|
32
|
+
expect(
|
|
33
|
+
hookManifestSchema.parse({ on: ["input", "signal", "output"] }).on,
|
|
34
|
+
).toEqual(["input", "signal", "output"])
|
|
35
|
+
expect(() =>
|
|
36
|
+
hookManifestSchema.parse({ on: ["signal", "signal"] }),
|
|
37
|
+
).toThrow()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("env は非空文字列のみ", () => {
|
|
41
|
+
expect(() =>
|
|
42
|
+
hookManifestSchema.parse({ on: ["input"], env: [""] }),
|
|
43
|
+
).toThrow()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
// nodes/hook/<dir>/config.json(NodeIOHooks)。フック自身が発火イベント(on)と
|
|
4
|
+
// 必要 env を宣言し、エディタがアタッチ時に読んで node_hooks へ焼き込む
|
|
5
|
+
// (programManifestSchema と同型の「repo が正・編集時に取り込む」方式。
|
|
6
|
+
// docs/tasks/wip/NodeIOHooks(ノードへのIOフックのアタッチ).md)。
|
|
7
|
+
// フックは任意のノードに付くため inputs / outputs のスキーマ宣言は持たない(unknown を受ける)。
|
|
8
|
+
// signal は外部シグナル(signals API)での発火(NodeIOHooksのonSignal(外部シグナルでのフック発火).md)
|
|
9
|
+
export const hookEventSchema = z.enum(["input", "output", "signal"])
|
|
10
|
+
export type HookEvent = z.infer<typeof hookEventSchema>
|
|
11
|
+
|
|
12
|
+
export const hookManifestSchema = z.object({
|
|
13
|
+
// 表示名(省略時はディレクトリ名)
|
|
14
|
+
name: z.string().min(1).optional(),
|
|
15
|
+
description: z.string().optional(),
|
|
16
|
+
// 発火イベントの宣言。エンジンはこれを見て dispatch を絞る
|
|
17
|
+
// (onInput の無いフックのために Actions run を無駄撃ちしない)
|
|
18
|
+
on: z
|
|
19
|
+
.array(hookEventSchema)
|
|
20
|
+
.min(1)
|
|
21
|
+
.refine((on) => new Set(on).size === on.length, "on が重複しています"),
|
|
22
|
+
// 実行に必要な secret 名の宣言(runner が宣言分だけを子プロセスへ渡す。program と同じ)
|
|
23
|
+
env: z.array(z.string().min(1)).default([]),
|
|
24
|
+
})
|
|
25
|
+
export type HookManifest = z.infer<typeof hookManifestSchema>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { graphPortSchema, nodeKindSchema } from "./node.js"
|
|
3
|
+
|
|
4
|
+
describe("nodeKindSchema", () => {
|
|
5
|
+
it("5種類の kind を受け付ける", () => {
|
|
6
|
+
for (const kind of ["ai", "human", "program", "guardrail", "wait"]) {
|
|
7
|
+
expect(nodeKindSchema.parse(kind)).toBe(kind)
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("不正な kind を拒否する", () => {
|
|
12
|
+
expect(() => nodeKindSchema.parse("loop")).toThrow()
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe("graphPortSchema", () => {
|
|
17
|
+
const base = {
|
|
18
|
+
id: "0f0e0d0c-0b0a-4a4b-8c8d-0e0f10111213",
|
|
19
|
+
io: "out",
|
|
20
|
+
key: "ok",
|
|
21
|
+
name: "OK",
|
|
22
|
+
schema: { type: "object" },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it("in / out のポートを受け付ける", () => {
|
|
26
|
+
expect(graphPortSchema.parse(base)).toEqual(base)
|
|
27
|
+
expect(graphPortSchema.parse({ ...base, io: "in", key: "in" })).toEqual({
|
|
28
|
+
...base,
|
|
29
|
+
io: "in",
|
|
30
|
+
key: "in",
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("io が in / out 以外を拒否する", () => {
|
|
35
|
+
expect(() => graphPortSchema.parse({ ...base, io: "inout" })).toThrow()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("key の規則違反(大文字・ハイフン・数字始まり)を拒否する", () => {
|
|
39
|
+
for (const bad of ["OK", "ng-port", "1st"]) {
|
|
40
|
+
expect(() => graphPortSchema.parse({ ...base, key: bad })).toThrow()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("name(表示名)は自由テキスト。空文字だけ拒否する", () => {
|
|
45
|
+
expect(graphPortSchema.parse({ ...base, name: "承認 OK!" }).name).toBe(
|
|
46
|
+
"承認 OK!",
|
|
47
|
+
)
|
|
48
|
+
expect(() => graphPortSchema.parse({ ...base, name: "" })).toThrow()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("id が uuid でないものを拒否する(クライアント採番)", () => {
|
|
52
|
+
expect(() => graphPortSchema.parse({ ...base, id: "port-1" })).toThrow()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("side / offset(境界上の表示位置)を受け付ける。省略も可", () => {
|
|
56
|
+
const positioned = { ...base, side: "top", offset: 0.25 }
|
|
57
|
+
expect(graphPortSchema.parse(positioned)).toEqual(positioned)
|
|
58
|
+
expect(graphPortSchema.parse(base)).toEqual(base)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("offset の範囲外・4値以外の side を拒否する", () => {
|
|
62
|
+
for (const offset of [-0.1, 1.5]) {
|
|
63
|
+
expect(() =>
|
|
64
|
+
graphPortSchema.parse({ ...base, side: "top", offset }),
|
|
65
|
+
).toThrow()
|
|
66
|
+
}
|
|
67
|
+
expect(() =>
|
|
68
|
+
graphPortSchema.parse({ ...base, side: "middle", offset: 0.5 }),
|
|
69
|
+
).toThrow()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("side / offset の片方だけの指定を拒否する", () => {
|
|
73
|
+
expect(() => graphPortSchema.parse({ ...base, side: "top" })).toThrow()
|
|
74
|
+
expect(() => graphPortSchema.parse({ ...base, offset: 0.5 })).toThrow()
|
|
75
|
+
})
|
|
76
|
+
})
|
package/_schemas/node.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export const nodeKindSchema = z.enum([
|
|
4
|
+
"ai",
|
|
5
|
+
"human",
|
|
6
|
+
"program",
|
|
7
|
+
"guardrail",
|
|
8
|
+
"wait",
|
|
9
|
+
// 開始・終了ノード(恒等パススルー。run は必ず start から始まり end で終わる。
|
|
10
|
+
// docs/tasks/wip/開始・終了ノードの定義.md)
|
|
11
|
+
"start",
|
|
12
|
+
"end",
|
|
13
|
+
])
|
|
14
|
+
export type NodeKind = z.infer<typeof nodeKindSchema>
|
|
15
|
+
|
|
16
|
+
// ポート key(input/output 用の参照キー)の規則(db.md §2)。
|
|
17
|
+
// name(ユーザーが見る表示名)は自由テキスト
|
|
18
|
+
export const PORT_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
|
|
19
|
+
export const portKeySchema = z.string().regex(PORT_KEY_PATTERN)
|
|
20
|
+
|
|
21
|
+
// 単一出口の既定ポート key/入力ポート key(db.md §2)
|
|
22
|
+
export const DEFAULT_OUTPUT_PORT = "main"
|
|
23
|
+
export const DEFAULT_INPUT_PORT = "in"
|
|
24
|
+
|
|
25
|
+
// JSON Schema そのもの(中身の妥当性はここでは検証しない)
|
|
26
|
+
export const jsonSchemaSchema = z.record(z.string(), z.unknown())
|
|
27
|
+
export type JsonSchema = z.infer<typeof jsonSchemaSchema>
|
|
28
|
+
|
|
29
|
+
export const portIoSchema = z.enum(["in", "out"])
|
|
30
|
+
export type PortIo = z.infer<typeof portIoSchema>
|
|
31
|
+
|
|
32
|
+
// ポートの表示位置=ノード境界上の「辺+辺上の比率」(top/bottom は左→右、
|
|
33
|
+
// left/right は上→下)。エディタの見た目のみで実行・接続の意味には関与しない。
|
|
34
|
+
// 未指定は自動配置(先頭=右辺中央・以降=下辺等間隔)
|
|
35
|
+
export const portSideSchema = z.enum(["top", "right", "bottom", "left"])
|
|
36
|
+
export type PortSide = z.infer<typeof portSideSchema>
|
|
37
|
+
|
|
38
|
+
// グラフ保存契約のポート(ports テーブルの1行。id はクライアント採番。
|
|
39
|
+
// docs/tasks/wip/ポートのテーブル化.md)
|
|
40
|
+
export const graphPortSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
id: z.uuid(),
|
|
43
|
+
io: portIoSchema,
|
|
44
|
+
key: portKeySchema,
|
|
45
|
+
name: z.string().min(1),
|
|
46
|
+
schema: jsonSchemaSchema,
|
|
47
|
+
side: portSideSchema.optional(),
|
|
48
|
+
offset: z.number().min(0).max(1).optional(),
|
|
49
|
+
})
|
|
50
|
+
.refine((p) => (p.side === undefined) === (p.offset === undefined), {
|
|
51
|
+
message: "side と offset は両方指定するか両方省略します",
|
|
52
|
+
path: ["side"],
|
|
53
|
+
})
|
|
54
|
+
export type GraphPort = z.infer<typeof graphPortSchema>
|