@mawaru/sdk 0.5.0 → 0.9.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()
@@ -236,6 +261,37 @@ describe("humanConfigSchema", () => {
236
261
  humanConfigSchema.parse({ assignment: { units: [["not-a-uuid"]] } }),
237
262
  ).toThrow()
238
263
  })
264
+
265
+ it("run_starter:固定メンバーと混ぜて受け付ける(実行時に start_by で解決)", () => {
266
+ const u1 = uuid()
267
+ const parsed = humanConfigSchema.parse({
268
+ assignment: {
269
+ strategy: "fixed",
270
+ approval_mode: "all",
271
+ units: [["run_starter", u1]],
272
+ },
273
+ })
274
+ expect(parsed.assignment.units).toEqual([["run_starter", u1]])
275
+ })
276
+
277
+ it("run_starter:交代制(round_robin)との併用を拒否する", () => {
278
+ expect(() =>
279
+ humanConfigSchema.parse({
280
+ assignment: {
281
+ strategy: "round_robin",
282
+ units: [["run_starter"], [uuid()]],
283
+ },
284
+ }),
285
+ ).toThrow()
286
+ })
287
+
288
+ it("run_starter:ユニット内の重複を拒否する", () => {
289
+ expect(() =>
290
+ humanConfigSchema.parse({
291
+ assignment: { units: [["run_starter", "run_starter"]] },
292
+ }),
293
+ ).toThrow()
294
+ })
239
295
  })
240
296
 
241
297
  describe("saveGraphBodySchema", () => {
@@ -258,6 +314,52 @@ describe("saveGraphBodySchema", () => {
258
314
  })
259
315
  })
260
316
 
317
+ // fan-in(同じ in ポートへの流入2本以上)は同じ型でなければならない
318
+ // (docs/tasks/wip/ポートスキーマのany撲滅.md)
319
+ const programNode = (outSchema: GraphPort["schema"]) => ({
320
+ ...makeNode([
321
+ port("in", "in"),
322
+ { ...port("out", "main"), schema: outSchema },
323
+ ]),
324
+ kind: "program" as const,
325
+ })
326
+
327
+ it("fan-in:流入の型が同じなら受け付ける", () => {
328
+ const a = programNode({ type: "string" })
329
+ const b = programNode({ type: "string" })
330
+ const sink = { ...makeNode(simplePorts()), kind: "wait" as const }
331
+ expect(() =>
332
+ saveGraphBodySchema.parse({
333
+ nodes: [a, b, sink],
334
+ connections: [connect(a, sink), connect(b, sink)],
335
+ }),
336
+ ).not.toThrow()
337
+ })
338
+
339
+ it("fan-in:異なる具体型の流入を拒否する", () => {
340
+ const a = programNode({ type: "string" })
341
+ const b = programNode({ type: "number" })
342
+ const sink = { ...makeNode(simplePorts()), kind: "wait" as const }
343
+ expect(() =>
344
+ saveGraphBodySchema.parse({
345
+ nodes: [a, b, sink],
346
+ connections: [connect(a, sink), connect(b, sink)],
347
+ }),
348
+ ).toThrow(/異なる型を接続できません/)
349
+ })
350
+
351
+ it("fan-in:具体型1種類+未宣言 の混在は受け付ける(未宣言は判断材料にしない)", () => {
352
+ const a = programNode({ type: "string" })
353
+ const b = { ...makeNode(simplePorts()), kind: "wait" as const } // out main は導出=未宣言
354
+ const sink = { ...makeNode(simplePorts()), kind: "wait" as const }
355
+ expect(() =>
356
+ saveGraphBodySchema.parse({
357
+ nodes: [a, b, sink],
358
+ connections: [connect(a, sink), connect(b, sink)],
359
+ }),
360
+ ).not.toThrow()
361
+ })
362
+
261
363
  it("node id の重複を拒否する", () => {
262
364
  const a = makeNode()
263
365
  expect(() =>
@@ -410,6 +512,7 @@ const humanNode = () => ({
410
512
  const aiNode = () => ({
411
513
  ...makeNode([
412
514
  port("in", "in"),
515
+ port("in", "instruction"),
413
516
  { ...port("out", "main"), schema: aiEnvelope },
414
517
  ]),
415
518
  kind: "ai" as const,
@@ -418,6 +521,11 @@ const programNode = (inSchema: GraphPort["schema"] = {}) => ({
418
521
  ...makeNode([{ ...port("in", "in"), schema: inSchema }, port("out", "main")]),
419
522
  kind: "program" as const,
420
523
  })
524
+ const inByKey = (node: TestNode, key: string): GraphPort => {
525
+ const p = node.ports.find((p) => p.io === "in" && p.key === key)
526
+ if (!p) throw new Error(`in port not found: ${key}`)
527
+ return p
528
+ }
421
529
  const outByKey = (node: TestNode, key: string): GraphPort => {
422
530
  const p = node.ports.find((p) => p.io === "out" && p.key === key)
423
531
  if (!p) throw new Error(`out port not found: ${key}`)
@@ -442,6 +550,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
442
550
  const multi = {
443
551
  ...makeNode([
444
552
  port("in", "in"),
553
+ port("in", "instruction"),
445
554
  { ...port("out", "spam"), schema: aiEnvelope },
446
555
  { ...port("out", "sales"), schema: aiEnvelope },
447
556
  { ...port("out", "support"), schema: aiEnvelope },
@@ -451,10 +560,11 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
451
560
  expect(() =>
452
561
  saveGraphBodySchema.parse({ nodes: [multi], connections: [] }),
453
562
  ).not.toThrow()
454
- // in key "in" 固定のまま
563
+ // in の構成は in / instruction 固定のまま
455
564
  const badIn = {
456
565
  ...makeNode([
457
566
  port("in", "input"),
567
+ port("in", "instruction"),
458
568
  { ...port("out", "main"), schema: aiEnvelope },
459
569
  ]),
460
570
  kind: "ai" as const,
@@ -481,6 +591,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
481
591
  it("ai の出力宣言が封筒型 { data, description } でないと拒否する(全出口に適用)", () => {
482
592
  const bad = makeNode([
483
593
  port("in", "in"),
594
+ port("in", "instruction"),
484
595
  { ...port("out", "main"), schema: { type: "object" } },
485
596
  ])
486
597
  expect(() =>
@@ -489,6 +600,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
489
600
  // main 以外の出口も封筒型を強制する
490
601
  const badExtra = makeNode([
491
602
  port("in", "in"),
603
+ port("in", "instruction"),
492
604
  { ...port("out", "main"), schema: aiEnvelope },
493
605
  { ...port("out", "extra"), schema: { type: "object" } },
494
606
  ])
@@ -498,6 +610,7 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
498
610
  // data は任意の型でよい(カタログ外の object も通る)
499
611
  const custom = makeNode([
500
612
  port("in", "in"),
613
+ port("in", "instruction"),
501
614
  {
502
615
  ...port("out", "main"),
503
616
  schema: envelopeSchemaOf({
@@ -514,32 +627,57 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
514
627
  })
515
628
 
516
629
  describe("saveGraphBodySchema(型の接続バリデーション)", () => {
517
- it("human の reject → ai は許可、ai 以外は拒否", () => {
630
+ it("human の reject → ai の instruction は許可。ai の in や ai 以外は拒否", () => {
518
631
  const human = humanNode()
519
632
  const ai = aiNode()
520
633
  const prog = programNode()
634
+ const rejectPort = outByKey(human, "reject")
635
+ // 差し戻しは指示の口(instruction)へ
521
636
  expect(() =>
522
637
  saveGraphBodySchema.parse({
523
638
  nodes: [human, ai],
524
639
  connections: [
525
640
  {
526
- from_port_id: outByKey(human, "reject").id,
527
- to_port_id: inOf(ai).id,
641
+ from_port_id: rejectPort.id,
642
+ to_port_id: inByKey(ai, "instruction").id,
528
643
  },
529
644
  ],
530
645
  }),
531
646
  ).not.toThrow()
647
+ // 上流データの口(in)には繋げない(意味の違う入力を fan-in させない)
648
+ expect(() =>
649
+ saveGraphBodySchema.parse({
650
+ nodes: [human, ai],
651
+ connections: [
652
+ { from_port_id: rejectPort.id, to_port_id: inByKey(ai, "in").id },
653
+ ],
654
+ }),
655
+ ).toThrow(/instruction/)
656
+ // ai 以外にも繋げない
532
657
  expect(() =>
533
658
  saveGraphBodySchema.parse({
534
659
  nodes: [human, prog],
660
+ connections: [
661
+ { from_port_id: rejectPort.id, to_port_id: inOf(prog).id },
662
+ ],
663
+ }),
664
+ ).toThrow(/instruction/)
665
+ })
666
+
667
+ it("instruction ポートに繋げるのは human の reject だけ", () => {
668
+ const ai = aiNode()
669
+ const prog = programNode()
670
+ expect(() =>
671
+ saveGraphBodySchema.parse({
672
+ nodes: [prog, ai],
535
673
  connections: [
536
674
  {
537
- from_port_id: outByKey(human, "reject").id,
538
- to_port_id: inOf(prog).id,
675
+ from_port_id: outOf(prog).id,
676
+ to_port_id: inByKey(ai, "instruction").id,
539
677
  },
540
678
  ],
541
679
  }),
542
- ).toThrow(/reject.*AI ノードのみ/)
680
+ ).toThrow(/instruction ポートに接続できるのは/)
543
681
  })
544
682
 
545
683
  it("human の in はどの型でも接続できる(封筒型の制約は課さない)", () => {
@@ -870,3 +1008,85 @@ describe("refsDefaultsOf(定義スキーマ→デフォルト値の袋)", ()
870
1008
  expect(refsDefaultsOf({ type: "object", properties: {} })).toEqual({})
871
1009
  })
872
1010
  })
1011
+
1012
+ describe("saveGraphBodySchema:コンポーネントノード(component kind)", () => {
1013
+ // コンポーネント=kind='component' の loop を配置して呼ぶノード。ポートは
1014
+ // in/main 固定で、スキーマは子 loop の契約から保存時にサーバが焼き込む(宣言扱い)
1015
+ const componentNode = (
1016
+ inSchema: GraphPort["schema"] = { type: "object" },
1017
+ ) => ({
1018
+ ...makeNode([
1019
+ { ...port("in", "in"), schema: inSchema },
1020
+ port("out", "main"),
1021
+ ]),
1022
+ kind: "component" as const,
1023
+ component: { component_loop_id: uuid() },
1024
+ })
1025
+
1026
+ it("component ノード(in/main+component_loop_id)を受け付ける", () => {
1027
+ expect(() =>
1028
+ saveGraphBodySchema.parse({ nodes: [componentNode()], connections: [] }),
1029
+ ).not.toThrow()
1030
+ })
1031
+
1032
+ it("component_loop_id の無い component ノードを拒否する", () => {
1033
+ const node = { ...componentNode(), component: undefined }
1034
+ expect(() =>
1035
+ saveGraphBodySchema.parse({ nodes: [node], connections: [] }),
1036
+ ).toThrow(/component_loop_id/)
1037
+ })
1038
+
1039
+ it("ポート構成は in/main 固定(out の key 違い・複数 out を拒否)", () => {
1040
+ const badKey = {
1041
+ ...componentNode(),
1042
+ ports: [port("in", "in"), port("out", "result")],
1043
+ }
1044
+ expect(() =>
1045
+ saveGraphBodySchema.parse({ nodes: [badKey], connections: [] }),
1046
+ ).toThrow(/component のポート構成/)
1047
+ const multiOut = {
1048
+ ...componentNode(),
1049
+ ports: [port("in", "in"), port("out", "main"), port("out", "sub")],
1050
+ }
1051
+ expect(() =>
1052
+ saveGraphBodySchema.parse({ nodes: [multiOut], connections: [] }),
1053
+ ).toThrow(/component のポート構成/)
1054
+ })
1055
+
1056
+ it("component の in への接続は契約への適合を検証する(伝播型 ⊆ 契約)", () => {
1057
+ // 上流 program の out {type:"string"} → component の in {type:"number"} は不適合
1058
+ const prog = {
1059
+ ...makeNode([
1060
+ port("in", "in"),
1061
+ { ...port("out", "main"), schema: { type: "string" } },
1062
+ ]),
1063
+ kind: "program" as const,
1064
+ }
1065
+ const comp = componentNode({ type: "number" })
1066
+ expect(() =>
1067
+ saveGraphBodySchema.parse({
1068
+ nodes: [prog, comp],
1069
+ connections: [connect(prog, comp)],
1070
+ }),
1071
+ ).toThrow(/入力契約に適合しません/)
1072
+ // 適合(string → string)なら通る
1073
+ const okComp = componentNode({ type: "string" })
1074
+ expect(() =>
1075
+ saveGraphBodySchema.parse({
1076
+ nodes: [prog, okComp],
1077
+ connections: [connect(prog, okComp)],
1078
+ }),
1079
+ ).not.toThrow()
1080
+ })
1081
+
1082
+ it("component の in の宣言スキーマは接続で潰されない(導出/鏡映扱いにしない)", () => {
1083
+ const contract = {
1084
+ type: "object",
1085
+ properties: { pr_urls: { type: "array" } },
1086
+ required: ["pr_urls"],
1087
+ }
1088
+ const comp = componentNode(contract)
1089
+ const resolved = resolvePortSchemas([comp], [])
1090
+ expect(resolved.get(inOf(comp).id)).toEqual(contract)
1091
+ })
1092
+ })
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
 
@@ -126,18 +130,31 @@ export const aiConfigSchema = z.object({
126
130
  })
127
131
  export type AiConfig = z.infer<typeof aiConfigSchema>
128
132
 
133
+ // 担当ユニットのメンバー:user_id の名指し、または動的な担当「ループを実行した人」
134
+ // (run の起動者。実行時に runs.start_by で解決し、解決できなければその1人だけ落ちる。
135
+ // docs/tasks/wip/Human担当に「ループを実行した人」を追加する.md)
136
+ export const RUN_STARTER = "run_starter"
137
+ export const assignMemberSchema = z.union([z.uuid(), z.literal(RUN_STARTER)])
138
+ export type AssignMember = z.infer<typeof assignMemberSchema>
139
+
129
140
  // Human ノードの kind 別設定(node_humans + node_human_assignees に対応)。
130
141
  // 担当 = ユニット(1人以上のメンバー)の順序付きリスト + 承認モード + 選出戦略。
131
- // units[i] は user_id の配列(1人=個人・複数=グループ)。units 空 = tenant の誰でも。
142
+ // units[i] はメンバーの配列(1人=個人・複数=グループ)。units 空 = tenant の誰でも。
132
143
  // approval_mode はノード単位(any=誰か1人 / all=全員。差し戻しは1人で成立=veto)。
133
144
  // メンバーが tenant の membership であることの検証は保存 API 側で行う
134
145
  // (docs/tasks/backlog/Human担当者の高度化(複数人承認とラウンドロビン).md)
135
146
  export const humanConfigSchema = z.object({
147
+ // 承認 UI(承認ビュー)の識別子。program_dir / ai_dir と同じ「不透明なキー」で、
148
+ // どのキーが実在するか・そのテナントが選べるかは保存時に mawaru 側が検証する
149
+ // (カタログは非公開。@mawaru/common の approval-view.ts)。省略時は標準ビュー
150
+ view: z.string().min(1).optional(),
151
+ // ビューに渡すパラメータ(列・ラベル・閾値など)。形はビューごとに違うので任意の JSON
152
+ view_config: z.unknown().nullish(),
136
153
  assignment: z
137
154
  .object({
138
155
  strategy: z.enum(["fixed", "round_robin"]).default("fixed"),
139
156
  approval_mode: z.enum(["any", "all"]).default("any"),
140
- units: z.array(z.array(z.uuid()).min(1)).default([]),
157
+ units: z.array(z.array(assignMemberSchema).min(1)).default([]),
141
158
  })
142
159
  .superRefine((assignment, ctx) => {
143
160
  if (assignment.strategy === "fixed" && assignment.units.length > 1) {
@@ -157,13 +174,25 @@ export const humanConfigSchema = z.object({
157
174
  message: "round_robin にはユニットが2つ以上必要です",
158
175
  })
159
176
  }
177
+ // 交代制と「ループを実行した人」は排他:どの回に誰が担当かが起動者と交代位置の
178
+ // 掛け算になって読めなくなるため、設定できる形の方を減らす
179
+ if (
180
+ assignment.strategy === "round_robin" &&
181
+ assignment.units.some((unit) => unit.includes(RUN_STARTER))
182
+ ) {
183
+ ctx.addIssue({
184
+ code: "custom",
185
+ path: ["units"],
186
+ message: "round_robin の担当に「ループを実行した人」は入れられません",
187
+ })
188
+ }
160
189
  // ユニット内の重複は拒否(またぐ重複は「Aは毎回・相手が交代」の形があるので許す)
161
190
  assignment.units.forEach((unit, i) => {
162
191
  if (new Set(unit).size !== unit.length) {
163
192
  ctx.addIssue({
164
193
  code: "custom",
165
194
  path: ["units", i],
166
- message: "ユニット内で user_id が重複しています",
195
+ message: "ユニット内で担当が重複しています",
167
196
  })
168
197
  }
169
198
  })
@@ -171,6 +200,14 @@ export const humanConfigSchema = z.object({
171
200
  })
172
201
  export type HumanConfig = z.infer<typeof humanConfigSchema>
173
202
 
203
+ // コンポーネントノードの kind 別設定(node_components に対応)。呼ぶ先は
204
+ // kind='component' の loop。同 tenant・kind の検証と自 loop 参照の禁止は保存 API 側で行う
205
+ // (docs/tasks/wip/コンポーネント(ループの部品化と再利用).md)
206
+ export const componentConfigSchema = z.object({
207
+ component_loop_id: z.uuid(),
208
+ })
209
+ export type ComponentConfig = z.infer<typeof componentConfigSchema>
210
+
174
211
  // ノードにアタッチされた IOHook(node_hooks に対応。アタッチできるのは program ノードのみ)。
175
212
  // on_input / on_output / on_signal はアタッチ時に repo の config.json(hookManifestSchema)から
176
213
  // エディタが焼き込むスナップショット。ref 省略時はデフォルトブランチ
@@ -206,6 +243,7 @@ export const graphNodeSchema = z.object({
206
243
  program: programConfigSchema.optional(),
207
244
  ai: aiConfigSchema.optional(),
208
245
  human: humanConfigSchema.optional(),
246
+ component: componentConfigSchema.optional(),
209
247
  })
210
248
  export type GraphNode = z.infer<typeof graphNodeSchema>
211
249
 
@@ -279,16 +317,20 @@ export const saveGraphBodySchema = z
279
317
  if (port.io === "in") inCount++
280
318
  else outCount++
281
319
  })
282
- // end だけ「in は1つ以上」(複数ルートを別々の in で集約する)。
283
- // start・他 kind は従来どおり1つ固定
284
- if (node.kind === "end" ? inCount < 1 : inCount !== 1) {
320
+ // end は「in は1つ以上」(複数ルートを別々の in で集約する)。
321
+ // ai は2つ(上流データの in + 差し戻しの instruction)。他 kind 1つ固定。
322
+ // key の妥当性は kindPortsViolation が別途見る
323
+ const expectIn = node.kind === "ai" ? 2 : 1
324
+ const inCountOk =
325
+ node.kind === "end" ? inCount >= 1 : inCount === expectIn
326
+ if (!inCountOk) {
285
327
  ctx.addIssue({
286
328
  code: "custom",
287
329
  path: ["nodes", i, "ports"],
288
330
  message:
289
331
  node.kind === "end"
290
332
  ? "in ポートが1つ以上必要です"
291
- : "in ポートはちょうど1つ必要です",
333
+ : `in ポートはちょうど${expectIn}つ必要です`,
292
334
  })
293
335
  }
294
336
  if (outCount < 1) {
@@ -321,6 +363,16 @@ export const saveGraphBodySchema = z
321
363
  hookDirs.add(hook.hook_dir)
322
364
  })
323
365
 
366
+ // component は参照先が無いと成立しない(他 kind の設定はデフォルトで補えるが
367
+ // component_loop_id は補えない)
368
+ if (node.kind === "component" && node.component === undefined) {
369
+ ctx.addIssue({
370
+ code: "custom",
371
+ path: ["nodes", i, "component"],
372
+ message: "component ノードには component_loop_id が必要です",
373
+ })
374
+ }
375
+
324
376
  // kind 別のポート構成(program 以外は数・key・io 固定。ポートのkind別仕様.md)
325
377
  const violation = kindPortsViolation(node.kind, node.ports)
326
378
  if (violation) {
@@ -353,7 +405,7 @@ export const saveGraphBodySchema = z
353
405
  const usedPairs = new Set<string>()
354
406
  graph.connections.forEach((conn, i) => {
355
407
  const from = portById.get(conn.from_port_id)
356
- if (!from || from.io !== "out") {
408
+ if (from?.io !== "out") {
357
409
  ctx.addIssue({
358
410
  code: "custom",
359
411
  path: ["connections", i, "from_port_id"],
@@ -361,7 +413,7 @@ export const saveGraphBodySchema = z
361
413
  })
362
414
  }
363
415
  const to = portById.get(conn.to_port_id)
364
- if (!to || to.io !== "in") {
416
+ if (to?.io !== "in") {
365
417
  ctx.addIssue({
366
418
  code: "custom",
367
419
  path: ["connections", i, "to_port_id"],
@@ -382,6 +434,31 @@ export const saveGraphBodySchema = z
382
434
  // 型の接続バリデーション(ポートのkind別仕様.md §接続バリデーション)。
383
435
  // 導出ポートは接続から型を解決した上で判定する。ai の in・wait の in は常に許可
384
436
  const resolved = resolvePortSchemas(graph.nodes, graph.connections)
437
+
438
+ // fan-in(同じ in ポートへの流入が2本以上)は同じ型でなければならない。
439
+ // 型が割れていると伝播が未宣言に落ちて下流の型チェックが効かなくなる
440
+ // (docs/tasks/wip/ポートスキーマのany撲滅.md)
441
+ const inboundByPort = new Map<string, string[]>()
442
+ for (const conn of graph.connections) {
443
+ const list = inboundByPort.get(conn.to_port_id) ?? []
444
+ list.push(conn.from_port_id)
445
+ inboundByPort.set(conn.to_port_id, list)
446
+ }
447
+ for (const [toPortId, fromPortIds] of inboundByPort) {
448
+ if (fromPortIds.length < 2) continue
449
+ const schemas = fromPortIds.map(
450
+ (id) => resolved.get(id) ?? UNDECLARED_SCHEMA,
451
+ )
452
+ if (!hasInboundConflict(schemas)) continue
453
+ const to = portById.get(toPortId)
454
+ const toNode = nodeByPortId.get(toPortId)
455
+ ctx.addIssue({
456
+ code: "custom",
457
+ path: ["connections"],
458
+ message: `同じ入力ポート(${toNode?.name ?? "?"}.${to?.key ?? "?"})に異なる型を接続できません`,
459
+ })
460
+ }
461
+
385
462
  graph.connections.forEach((conn, i) => {
386
463
  const from = portById.get(conn.from_port_id)
387
464
  const to = portById.get(conn.to_port_id)
@@ -405,16 +482,33 @@ export const saveGraphBodySchema = z
405
482
  })
406
483
  }
407
484
 
408
- // human の reject の接続先は ai ノードのみ(kind 制約)
485
+ // human の reject の接続先は AI instruction ポートのみ。
486
+ // 差し戻しは「元のセッションに指示を与える」操作で、上流データの口(in)とは
487
+ // 意味が違う(docs/tasks/wip/ポートスキーマのany撲滅.md)
409
488
  if (
410
489
  fromNode.kind === "human" &&
411
490
  from.key === "reject" &&
412
- toNode.kind !== "ai"
491
+ !(toNode.kind === "ai" && to.key === AI_INSTRUCTION_PORT_KEY)
413
492
  ) {
414
493
  ctx.addIssue({
415
494
  code: "custom",
416
495
  path: ["connections", i, "to_port_id"],
417
- message: "差し戻し(reject)の接続先は AI ノードのみです",
496
+ message:
497
+ "差し戻し(reject)の接続先は AI ノードの instruction ポートのみです",
498
+ })
499
+ }
500
+
501
+ // instruction ポートに繋げるのは human の reject だけ(データを流し込ませない)
502
+ if (
503
+ toNode.kind === "ai" &&
504
+ to.key === AI_INSTRUCTION_PORT_KEY &&
505
+ !(fromNode.kind === "human" && from.key === "reject")
506
+ ) {
507
+ ctx.addIssue({
508
+ code: "custom",
509
+ path: ["connections", i, "to_port_id"],
510
+ message:
511
+ "instruction ポートに接続できるのは human の差し戻し(reject)だけです",
418
512
  })
419
513
  }
420
514
 
@@ -424,7 +518,8 @@ export const saveGraphBodySchema = z
424
518
  // それ以外は input 全体を data として表示する。型制約は課さない)
425
519
 
426
520
  // program の in への接続は 伝播型 ⊆ 宣言スキーマ。in は接続鏡映(sticky)なので
427
- // 具体型の接続では自明に通り、接続元 any × 宣言維持のときだけ従来の意味を持つ
521
+ // 具体型の接続では自明に通り、接続元が未宣言 × 宣言維持のときだけ従来の意味を持つ
522
+ // (未宣言は unknown=判断できないので通す。作りかけのグラフを保存できなくしない)
428
523
  const toSchema = resolved.get(conn.to_port_id) ?? to.schema
429
524
  if (toNode.kind === "program" && !isSchemaSubset(fromSchema, toSchema)) {
430
525
  ctx.addIssue({
@@ -434,6 +529,19 @@ export const saveGraphBodySchema = z
434
529
  "接続元の型が program の入力スキーマに適合しません(伝播型 ⊆ 宣言スキーマ)",
435
530
  })
436
531
  }
532
+
533
+ // component の in は子 loop の契約(焼き込み済み宣言)。program と同じ包含チェック
534
+ if (
535
+ toNode.kind === "component" &&
536
+ !isSchemaSubset(fromSchema, toSchema)
537
+ ) {
538
+ ctx.addIssue({
539
+ code: "custom",
540
+ path: ["connections", i, "to_port_id"],
541
+ message:
542
+ "接続元の型がコンポーネントの入力契約に適合しません(伝播型 ⊆ 契約)",
543
+ })
544
+ }
437
545
  })
438
546
  })
439
547
  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