@mawaru/sdk 0.5.0 → 0.8.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.
@@ -8,7 +8,7 @@ import {
8
8
  waitConfigSchema,
9
9
  } from "./graph.js"
10
10
  import type { GraphPort } from "./node.js"
11
- import { envelopeSchemaOf } from "./port-spec.js"
11
+ import { envelopeSchemaOf, resolvePortSchemas } from "./port-spec.js"
12
12
 
13
13
  // テスト用の決定的な uuid(common は node 型定義を持たないため crypto は使わない)
14
14
  let seq = 0
@@ -93,14 +93,21 @@ const port = (io: "in" | "out", key: string): GraphPort => ({
93
93
  // data=テキストの封筒を持つ
94
94
  const textEnvelope = envelopeSchemaOf({ type: "string" })
95
95
 
96
+ // 既定は ai ノード。ai の in は 上流データの in + 差し戻しの instruction の2口
97
+ const aiPorts = (): GraphPort[] => [
98
+ port("in", "in"),
99
+ port("in", "instruction"),
100
+ { ...port("out", "main"), schema: textEnvelope },
101
+ ]
102
+
103
+ // ai 以外に流用するとき用(in は1つ)
104
+ const simplePorts = (): GraphPort[] => [port("in", "in"), port("out", "main")]
105
+
96
106
  const makeNode = (ports?: GraphPort[]) => ({
97
107
  id: uuid(),
98
108
  kind: "ai" as const,
99
109
  name: "ノード",
100
- ports: ports ?? [
101
- port("in", "in"),
102
- { ...port("out", "main"), schema: textEnvelope },
103
- ],
110
+ ports: ports ?? aiPorts(),
104
111
  position_x: 0,
105
112
  position_y: 0,
106
113
  })
@@ -172,6 +179,24 @@ describe("humanConfigSchema", () => {
172
179
  })
173
180
  })
174
181
 
182
+ // 承認ビューは不透明なキー(program_dir / ai_dir と同じ)。実在するか・そのテナントが
183
+ // 選べるかは mawaru 側のカタログ(非公開)と保存 API が見るので、ここは形だけ
184
+ it("承認ビューとそのパラメータを受け付ける", () => {
185
+ const parsed = humanConfigSchema.parse({
186
+ assignment: {},
187
+ view: "acme/invoice",
188
+ view_config: { threshold: 1000 },
189
+ })
190
+ expect(parsed.view).toBe("acme/invoice")
191
+ expect(parsed.view_config).toEqual({ threshold: 1000 })
192
+ })
193
+
194
+ it("空のビューキーは拒否する", () => {
195
+ expect(() =>
196
+ humanConfigSchema.parse({ assignment: {}, view: "" }),
197
+ ).toThrow()
198
+ })
199
+
175
200
  it("fixed:1ユニット(複数人の and/or)を受け付ける", () => {
176
201
  const u1 = uuid()
177
202
  const u2 = uuid()
@@ -258,6 +283,52 @@ describe("saveGraphBodySchema", () => {
258
283
  })
259
284
  })
260
285
 
286
+ // fan-in(同じ in ポートへの流入2本以上)は同じ型でなければならない
287
+ // (docs/tasks/wip/ポートスキーマのany撲滅.md)
288
+ const programNode = (outSchema: GraphPort["schema"]) => ({
289
+ ...makeNode([
290
+ port("in", "in"),
291
+ { ...port("out", "main"), schema: outSchema },
292
+ ]),
293
+ kind: "program" as const,
294
+ })
295
+
296
+ it("fan-in:流入の型が同じなら受け付ける", () => {
297
+ const a = programNode({ type: "string" })
298
+ const b = programNode({ type: "string" })
299
+ const sink = { ...makeNode(simplePorts()), kind: "wait" as const }
300
+ expect(() =>
301
+ saveGraphBodySchema.parse({
302
+ nodes: [a, b, sink],
303
+ connections: [connect(a, sink), connect(b, sink)],
304
+ }),
305
+ ).not.toThrow()
306
+ })
307
+
308
+ it("fan-in:異なる具体型の流入を拒否する", () => {
309
+ const a = programNode({ type: "string" })
310
+ const b = programNode({ type: "number" })
311
+ const sink = { ...makeNode(simplePorts()), kind: "wait" as const }
312
+ expect(() =>
313
+ saveGraphBodySchema.parse({
314
+ nodes: [a, b, sink],
315
+ connections: [connect(a, sink), connect(b, sink)],
316
+ }),
317
+ ).toThrow(/異なる型を接続できません/)
318
+ })
319
+
320
+ it("fan-in:具体型1種類+未宣言 の混在は受け付ける(未宣言は判断材料にしない)", () => {
321
+ const a = programNode({ type: "string" })
322
+ const b = { ...makeNode(simplePorts()), kind: "wait" as const } // out main は導出=未宣言
323
+ const sink = { ...makeNode(simplePorts()), kind: "wait" as const }
324
+ expect(() =>
325
+ saveGraphBodySchema.parse({
326
+ nodes: [a, b, sink],
327
+ connections: [connect(a, sink), connect(b, sink)],
328
+ }),
329
+ ).not.toThrow()
330
+ })
331
+
261
332
  it("node id の重複を拒否する", () => {
262
333
  const a = makeNode()
263
334
  expect(() =>
@@ -410,6 +481,7 @@ const humanNode = () => ({
410
481
  const aiNode = () => ({
411
482
  ...makeNode([
412
483
  port("in", "in"),
484
+ port("in", "instruction"),
413
485
  { ...port("out", "main"), schema: aiEnvelope },
414
486
  ]),
415
487
  kind: "ai" as const,
@@ -418,6 +490,11 @@ const programNode = (inSchema: GraphPort["schema"] = {}) => ({
418
490
  ...makeNode([{ ...port("in", "in"), schema: inSchema }, port("out", "main")]),
419
491
  kind: "program" as const,
420
492
  })
493
+ const inByKey = (node: TestNode, key: string): GraphPort => {
494
+ const p = node.ports.find((p) => p.io === "in" && p.key === key)
495
+ if (!p) throw new Error(`in port not found: ${key}`)
496
+ return p
497
+ }
421
498
  const outByKey = (node: TestNode, key: string): GraphPort => {
422
499
  const p = node.ports.find((p) => p.io === "out" && p.key === key)
423
500
  if (!p) throw new Error(`out port not found: ${key}`)
@@ -442,6 +519,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
442
519
  const multi = {
443
520
  ...makeNode([
444
521
  port("in", "in"),
522
+ port("in", "instruction"),
445
523
  { ...port("out", "spam"), schema: aiEnvelope },
446
524
  { ...port("out", "sales"), schema: aiEnvelope },
447
525
  { ...port("out", "support"), schema: aiEnvelope },
@@ -451,10 +529,11 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
451
529
  expect(() =>
452
530
  saveGraphBodySchema.parse({ nodes: [multi], connections: [] }),
453
531
  ).not.toThrow()
454
- // in key "in" 固定のまま
532
+ // in の構成は in / instruction 固定のまま
455
533
  const badIn = {
456
534
  ...makeNode([
457
535
  port("in", "input"),
536
+ port("in", "instruction"),
458
537
  { ...port("out", "main"), schema: aiEnvelope },
459
538
  ]),
460
539
  kind: "ai" as const,
@@ -481,6 +560,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
481
560
  it("ai の出力宣言が封筒型 { data, description } でないと拒否する(全出口に適用)", () => {
482
561
  const bad = makeNode([
483
562
  port("in", "in"),
563
+ port("in", "instruction"),
484
564
  { ...port("out", "main"), schema: { type: "object" } },
485
565
  ])
486
566
  expect(() =>
@@ -489,6 +569,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
489
569
  // main 以外の出口も封筒型を強制する
490
570
  const badExtra = makeNode([
491
571
  port("in", "in"),
572
+ port("in", "instruction"),
492
573
  { ...port("out", "main"), schema: aiEnvelope },
493
574
  { ...port("out", "extra"), schema: { type: "object" } },
494
575
  ])
@@ -498,6 +579,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
498
579
  // data は任意の型でよい(カタログ外の object も通る)
499
580
  const custom = makeNode([
500
581
  port("in", "in"),
582
+ port("in", "instruction"),
501
583
  {
502
584
  ...port("out", "main"),
503
585
  schema: envelopeSchemaOf({
@@ -514,32 +596,57 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
514
596
  })
515
597
 
516
598
  describe("saveGraphBodySchema(型の接続バリデーション)", () => {
517
- it("human の reject → ai は許可、ai 以外は拒否", () => {
599
+ it("human の reject → ai の instruction は許可。ai の in や ai 以外は拒否", () => {
518
600
  const human = humanNode()
519
601
  const ai = aiNode()
520
602
  const prog = programNode()
603
+ const rejectPort = outByKey(human, "reject")
604
+ // 差し戻しは指示の口(instruction)へ
521
605
  expect(() =>
522
606
  saveGraphBodySchema.parse({
523
607
  nodes: [human, ai],
524
608
  connections: [
525
609
  {
526
- from_port_id: outByKey(human, "reject").id,
527
- to_port_id: inOf(ai).id,
610
+ from_port_id: rejectPort.id,
611
+ to_port_id: inByKey(ai, "instruction").id,
528
612
  },
529
613
  ],
530
614
  }),
531
615
  ).not.toThrow()
616
+ // 上流データの口(in)には繋げない(意味の違う入力を fan-in させない)
617
+ expect(() =>
618
+ saveGraphBodySchema.parse({
619
+ nodes: [human, ai],
620
+ connections: [
621
+ { from_port_id: rejectPort.id, to_port_id: inByKey(ai, "in").id },
622
+ ],
623
+ }),
624
+ ).toThrow(/instruction/)
625
+ // ai 以外にも繋げない
532
626
  expect(() =>
533
627
  saveGraphBodySchema.parse({
534
628
  nodes: [human, prog],
629
+ connections: [
630
+ { from_port_id: rejectPort.id, to_port_id: inOf(prog).id },
631
+ ],
632
+ }),
633
+ ).toThrow(/instruction/)
634
+ })
635
+
636
+ it("instruction ポートに繋げるのは human の reject だけ", () => {
637
+ const ai = aiNode()
638
+ const prog = programNode()
639
+ expect(() =>
640
+ saveGraphBodySchema.parse({
641
+ nodes: [prog, ai],
535
642
  connections: [
536
643
  {
537
- from_port_id: outByKey(human, "reject").id,
538
- to_port_id: inOf(prog).id,
644
+ from_port_id: outOf(prog).id,
645
+ to_port_id: inByKey(ai, "instruction").id,
539
646
  },
540
647
  ],
541
648
  }),
542
- ).toThrow(/reject.*AI ノードのみ/)
649
+ ).toThrow(/instruction ポートに接続できるのは/)
543
650
  })
544
651
 
545
652
  it("human の in はどの型でも接続できる(封筒型の制約は課さない)", () => {
@@ -870,3 +977,85 @@ describe("refsDefaultsOf(定義スキーマ→デフォルト値の袋)", ()
870
977
  expect(refsDefaultsOf({ type: "object", properties: {} })).toEqual({})
871
978
  })
872
979
  })
980
+
981
+ describe("saveGraphBodySchema:コンポーネントノード(component kind)", () => {
982
+ // コンポーネント=kind='component' の loop を配置して呼ぶノード。ポートは
983
+ // in/main 固定で、スキーマは子 loop の契約から保存時にサーバが焼き込む(宣言扱い)
984
+ const componentNode = (
985
+ inSchema: GraphPort["schema"] = { type: "object" },
986
+ ) => ({
987
+ ...makeNode([
988
+ { ...port("in", "in"), schema: inSchema },
989
+ port("out", "main"),
990
+ ]),
991
+ kind: "component" as const,
992
+ component: { component_loop_id: uuid() },
993
+ })
994
+
995
+ it("component ノード(in/main+component_loop_id)を受け付ける", () => {
996
+ expect(() =>
997
+ saveGraphBodySchema.parse({ nodes: [componentNode()], connections: [] }),
998
+ ).not.toThrow()
999
+ })
1000
+
1001
+ it("component_loop_id の無い component ノードを拒否する", () => {
1002
+ const node = { ...componentNode(), component: undefined }
1003
+ expect(() =>
1004
+ saveGraphBodySchema.parse({ nodes: [node], connections: [] }),
1005
+ ).toThrow(/component_loop_id/)
1006
+ })
1007
+
1008
+ it("ポート構成は in/main 固定(out の key 違い・複数 out を拒否)", () => {
1009
+ const badKey = {
1010
+ ...componentNode(),
1011
+ ports: [port("in", "in"), port("out", "result")],
1012
+ }
1013
+ expect(() =>
1014
+ saveGraphBodySchema.parse({ nodes: [badKey], connections: [] }),
1015
+ ).toThrow(/component のポート構成/)
1016
+ const multiOut = {
1017
+ ...componentNode(),
1018
+ ports: [port("in", "in"), port("out", "main"), port("out", "sub")],
1019
+ }
1020
+ expect(() =>
1021
+ saveGraphBodySchema.parse({ nodes: [multiOut], connections: [] }),
1022
+ ).toThrow(/component のポート構成/)
1023
+ })
1024
+
1025
+ it("component の in への接続は契約への適合を検証する(伝播型 ⊆ 契約)", () => {
1026
+ // 上流 program の out {type:"string"} → component の in {type:"number"} は不適合
1027
+ const prog = {
1028
+ ...makeNode([
1029
+ port("in", "in"),
1030
+ { ...port("out", "main"), schema: { type: "string" } },
1031
+ ]),
1032
+ kind: "program" as const,
1033
+ }
1034
+ const comp = componentNode({ type: "number" })
1035
+ expect(() =>
1036
+ saveGraphBodySchema.parse({
1037
+ nodes: [prog, comp],
1038
+ connections: [connect(prog, comp)],
1039
+ }),
1040
+ ).toThrow(/入力契約に適合しません/)
1041
+ // 適合(string → string)なら通る
1042
+ const okComp = componentNode({ type: "string" })
1043
+ expect(() =>
1044
+ saveGraphBodySchema.parse({
1045
+ nodes: [prog, okComp],
1046
+ connections: [connect(prog, okComp)],
1047
+ }),
1048
+ ).not.toThrow()
1049
+ })
1050
+
1051
+ it("component の in の宣言スキーマは接続で潰されない(導出/鏡映扱いにしない)", () => {
1052
+ const contract = {
1053
+ type: "object",
1054
+ properties: { pr_urls: { type: "array" } },
1055
+ required: ["pr_urls"],
1056
+ }
1057
+ const comp = componentNode(contract)
1058
+ const resolved = resolvePortSchemas([comp], [])
1059
+ expect(resolved.get(inOf(comp).id)).toEqual(contract)
1060
+ })
1061
+ })
package/_schemas/graph.ts CHANGED
@@ -6,10 +6,13 @@ import {
6
6
  portKeySchema,
7
7
  } from "./node.js"
8
8
  import {
9
+ AI_INSTRUCTION_PORT_KEY,
10
+ hasInboundConflict,
9
11
  isAiEnvelopeSchema,
10
12
  isSchemaSubset,
11
13
  kindPortsViolation,
12
14
  resolvePortSchemas,
15
+ UNDECLARED_SCHEMA,
13
16
  } from "./port-spec.js"
14
17
 
15
18
  // refs=ループ変数の袋(key→任意の JSON 値。上流出力の参照(refs).md)。
@@ -72,6 +75,7 @@ export const graphNodeKindSchema = nodeKindSchema.extract([
72
75
  "wait",
73
76
  "start",
74
77
  "end",
78
+ "component",
75
79
  ])
76
80
  export type GraphNodeKind = z.infer<typeof graphNodeKindSchema>
77
81
 
@@ -133,6 +137,12 @@ export type AiConfig = z.infer<typeof aiConfigSchema>
133
137
  // メンバーが tenant の membership であることの検証は保存 API 側で行う
134
138
  // (docs/tasks/backlog/Human担当者の高度化(複数人承認とラウンドロビン).md)
135
139
  export const humanConfigSchema = z.object({
140
+ // 承認 UI(承認ビュー)の識別子。program_dir / ai_dir と同じ「不透明なキー」で、
141
+ // どのキーが実在するか・そのテナントが選べるかは保存時に mawaru 側が検証する
142
+ // (カタログは非公開。@mawaru/common の approval-view.ts)。省略時は標準ビュー
143
+ view: z.string().min(1).optional(),
144
+ // ビューに渡すパラメータ(列・ラベル・閾値など)。形はビューごとに違うので任意の JSON
145
+ view_config: z.unknown().nullish(),
136
146
  assignment: z
137
147
  .object({
138
148
  strategy: z.enum(["fixed", "round_robin"]).default("fixed"),
@@ -171,6 +181,14 @@ export const humanConfigSchema = z.object({
171
181
  })
172
182
  export type HumanConfig = z.infer<typeof humanConfigSchema>
173
183
 
184
+ // コンポーネントノードの kind 別設定(node_components に対応)。呼ぶ先は
185
+ // kind='component' の loop。同 tenant・kind の検証と自 loop 参照の禁止は保存 API 側で行う
186
+ // (docs/tasks/wip/コンポーネント(ループの部品化と再利用).md)
187
+ export const componentConfigSchema = z.object({
188
+ component_loop_id: z.uuid(),
189
+ })
190
+ export type ComponentConfig = z.infer<typeof componentConfigSchema>
191
+
174
192
  // ノードにアタッチされた IOHook(node_hooks に対応。アタッチできるのは program ノードのみ)。
175
193
  // on_input / on_output / on_signal はアタッチ時に repo の config.json(hookManifestSchema)から
176
194
  // エディタが焼き込むスナップショット。ref 省略時はデフォルトブランチ
@@ -206,6 +224,7 @@ export const graphNodeSchema = z.object({
206
224
  program: programConfigSchema.optional(),
207
225
  ai: aiConfigSchema.optional(),
208
226
  human: humanConfigSchema.optional(),
227
+ component: componentConfigSchema.optional(),
209
228
  })
210
229
  export type GraphNode = z.infer<typeof graphNodeSchema>
211
230
 
@@ -279,16 +298,20 @@ export const saveGraphBodySchema = z
279
298
  if (port.io === "in") inCount++
280
299
  else outCount++
281
300
  })
282
- // end だけ「in は1つ以上」(複数ルートを別々の in で集約する)。
283
- // start・他 kind は従来どおり1つ固定
284
- if (node.kind === "end" ? inCount < 1 : inCount !== 1) {
301
+ // end は「in は1つ以上」(複数ルートを別々の in で集約する)。
302
+ // ai は2つ(上流データの in + 差し戻しの instruction)。他 kind 1つ固定。
303
+ // key の妥当性は kindPortsViolation が別途見る
304
+ const expectIn = node.kind === "ai" ? 2 : 1
305
+ const inCountOk =
306
+ node.kind === "end" ? inCount >= 1 : inCount === expectIn
307
+ if (!inCountOk) {
285
308
  ctx.addIssue({
286
309
  code: "custom",
287
310
  path: ["nodes", i, "ports"],
288
311
  message:
289
312
  node.kind === "end"
290
313
  ? "in ポートが1つ以上必要です"
291
- : "in ポートはちょうど1つ必要です",
314
+ : `in ポートはちょうど${expectIn}つ必要です`,
292
315
  })
293
316
  }
294
317
  if (outCount < 1) {
@@ -321,6 +344,16 @@ export const saveGraphBodySchema = z
321
344
  hookDirs.add(hook.hook_dir)
322
345
  })
323
346
 
347
+ // component は参照先が無いと成立しない(他 kind の設定はデフォルトで補えるが
348
+ // component_loop_id は補えない)
349
+ if (node.kind === "component" && node.component === undefined) {
350
+ ctx.addIssue({
351
+ code: "custom",
352
+ path: ["nodes", i, "component"],
353
+ message: "component ノードには component_loop_id が必要です",
354
+ })
355
+ }
356
+
324
357
  // kind 別のポート構成(program 以外は数・key・io 固定。ポートのkind別仕様.md)
325
358
  const violation = kindPortsViolation(node.kind, node.ports)
326
359
  if (violation) {
@@ -353,7 +386,7 @@ export const saveGraphBodySchema = z
353
386
  const usedPairs = new Set<string>()
354
387
  graph.connections.forEach((conn, i) => {
355
388
  const from = portById.get(conn.from_port_id)
356
- if (!from || from.io !== "out") {
389
+ if (from?.io !== "out") {
357
390
  ctx.addIssue({
358
391
  code: "custom",
359
392
  path: ["connections", i, "from_port_id"],
@@ -361,7 +394,7 @@ export const saveGraphBodySchema = z
361
394
  })
362
395
  }
363
396
  const to = portById.get(conn.to_port_id)
364
- if (!to || to.io !== "in") {
397
+ if (to?.io !== "in") {
365
398
  ctx.addIssue({
366
399
  code: "custom",
367
400
  path: ["connections", i, "to_port_id"],
@@ -382,6 +415,31 @@ export const saveGraphBodySchema = z
382
415
  // 型の接続バリデーション(ポートのkind別仕様.md §接続バリデーション)。
383
416
  // 導出ポートは接続から型を解決した上で判定する。ai の in・wait の in は常に許可
384
417
  const resolved = resolvePortSchemas(graph.nodes, graph.connections)
418
+
419
+ // fan-in(同じ in ポートへの流入が2本以上)は同じ型でなければならない。
420
+ // 型が割れていると伝播が未宣言に落ちて下流の型チェックが効かなくなる
421
+ // (docs/tasks/wip/ポートスキーマのany撲滅.md)
422
+ const inboundByPort = new Map<string, string[]>()
423
+ for (const conn of graph.connections) {
424
+ const list = inboundByPort.get(conn.to_port_id) ?? []
425
+ list.push(conn.from_port_id)
426
+ inboundByPort.set(conn.to_port_id, list)
427
+ }
428
+ for (const [toPortId, fromPortIds] of inboundByPort) {
429
+ if (fromPortIds.length < 2) continue
430
+ const schemas = fromPortIds.map(
431
+ (id) => resolved.get(id) ?? UNDECLARED_SCHEMA,
432
+ )
433
+ if (!hasInboundConflict(schemas)) continue
434
+ const to = portById.get(toPortId)
435
+ const toNode = nodeByPortId.get(toPortId)
436
+ ctx.addIssue({
437
+ code: "custom",
438
+ path: ["connections"],
439
+ message: `同じ入力ポート(${toNode?.name ?? "?"}.${to?.key ?? "?"})に異なる型を接続できません`,
440
+ })
441
+ }
442
+
385
443
  graph.connections.forEach((conn, i) => {
386
444
  const from = portById.get(conn.from_port_id)
387
445
  const to = portById.get(conn.to_port_id)
@@ -405,16 +463,33 @@ export const saveGraphBodySchema = z
405
463
  })
406
464
  }
407
465
 
408
- // human の reject の接続先は ai ノードのみ(kind 制約)
466
+ // human の reject の接続先は AI instruction ポートのみ。
467
+ // 差し戻しは「元のセッションに指示を与える」操作で、上流データの口(in)とは
468
+ // 意味が違う(docs/tasks/wip/ポートスキーマのany撲滅.md)
409
469
  if (
410
470
  fromNode.kind === "human" &&
411
471
  from.key === "reject" &&
412
- toNode.kind !== "ai"
472
+ !(toNode.kind === "ai" && to.key === AI_INSTRUCTION_PORT_KEY)
413
473
  ) {
414
474
  ctx.addIssue({
415
475
  code: "custom",
416
476
  path: ["connections", i, "to_port_id"],
417
- message: "差し戻し(reject)の接続先は AI ノードのみです",
477
+ message:
478
+ "差し戻し(reject)の接続先は AI ノードの instruction ポートのみです",
479
+ })
480
+ }
481
+
482
+ // instruction ポートに繋げるのは human の reject だけ(データを流し込ませない)
483
+ if (
484
+ toNode.kind === "ai" &&
485
+ to.key === AI_INSTRUCTION_PORT_KEY &&
486
+ !(fromNode.kind === "human" && from.key === "reject")
487
+ ) {
488
+ ctx.addIssue({
489
+ code: "custom",
490
+ path: ["connections", i, "to_port_id"],
491
+ message:
492
+ "instruction ポートに接続できるのは human の差し戻し(reject)だけです",
418
493
  })
419
494
  }
420
495
 
@@ -424,7 +499,8 @@ export const saveGraphBodySchema = z
424
499
  // それ以外は input 全体を data として表示する。型制約は課さない)
425
500
 
426
501
  // program の in への接続は 伝播型 ⊆ 宣言スキーマ。in は接続鏡映(sticky)なので
427
- // 具体型の接続では自明に通り、接続元 any × 宣言維持のときだけ従来の意味を持つ
502
+ // 具体型の接続では自明に通り、接続元が未宣言 × 宣言維持のときだけ従来の意味を持つ
503
+ // (未宣言は unknown=判断できないので通す。作りかけのグラフを保存できなくしない)
428
504
  const toSchema = resolved.get(conn.to_port_id) ?? to.schema
429
505
  if (toNode.kind === "program" && !isSchemaSubset(fromSchema, toSchema)) {
430
506
  ctx.addIssue({
@@ -434,6 +510,19 @@ export const saveGraphBodySchema = z
434
510
  "接続元の型が program の入力スキーマに適合しません(伝播型 ⊆ 宣言スキーマ)",
435
511
  })
436
512
  }
513
+
514
+ // component の in は子 loop の契約(焼き込み済み宣言)。program と同じ包含チェック
515
+ if (
516
+ toNode.kind === "component" &&
517
+ !isSchemaSubset(fromSchema, toSchema)
518
+ ) {
519
+ ctx.addIssue({
520
+ code: "custom",
521
+ path: ["connections", i, "to_port_id"],
522
+ message:
523
+ "接続元の型がコンポーネントの入力契約に適合しません(伝播型 ⊆ 契約)",
524
+ })
525
+ }
437
526
  })
438
527
  })
439
528
  export type SaveGraphBody = z.infer<typeof saveGraphBodySchema>
package/_schemas/node.ts CHANGED
@@ -10,6 +10,9 @@ export const nodeKindSchema = z.enum([
10
10
  // docs/tasks/wip/開始・終了ノードの定義.md)
11
11
  "start",
12
12
  "end",
13
+ // コンポーネント(kind='component' の loop を配置して呼ぶ。子 run・戻り値あり。
14
+ // docs/tasks/wip/コンポーネント(ループの部品化と再利用).md)
15
+ "component",
13
16
  ])
14
17
  export type NodeKind = z.infer<typeof nodeKindSchema>
15
18