@mawaru/sdk 0.13.0 → 0.15.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.
@@ -1,20 +1,25 @@
1
1
  import type { GraphConnection, GraphNode } from "./graph.js"
2
+ import type { GraphPort } from "./node.js"
2
3
 
3
- // 矩形選択からのグループ化・コンポーネント化(ノードのグループ化・コンポーネント化.md)。
4
+ // 矩形選択からのグループ化・コンポーネント化(ノードのグループ化・コンポーネント化.md
5
+ // コンポーネントの複数入口・複数出口.md)。
4
6
  // 選択境界を分類・検証し(analyze)、「子(部品)loop の graph」と「親の置換後グラフ」を
5
7
  // 生成する(build)。純関数(id 採番は newId で注入)。エディタとテストの両方から使う。
6
8
  //
7
- // V1 制約:component の口は in 1つ・out main 1つなので、
8
- // 入口(外→選択内の線の到達先ノード)は1つ・出口(選択内→外の線の出発ポート)は1つまで。
9
+ // 境界は start / end が吸収し、成立条件は型だけで決める(配線の由来は見ない):
10
+ // - 入口:外→選択内の線の到達先ポート。複数可。条件は全入口ポートの型が同一なこと
11
+ // (子では start.main から全入口へ fan-out する)
12
+ // - 出口:選択内→外の線の出発ポート。複数可・無条件(子では出発ポートごとに end の
13
+ // in を1つ作り、component の out として親へ射影する)
9
14
 
10
15
  export type ExtractAnalysis =
11
16
  | { ok: false; reason: string }
12
17
  | {
13
18
  ok: true
14
- // 外部からの入力を受けるノード(入出力の無い孤立した島は null)
15
- entryNodeId: string | null
16
- // 外部への出力の出発ポート(同一ポートからの fan-out は1出口扱い)
17
- exitPortId: string | null
19
+ // 外部からの入力を受けるポート(重複排除・出現順。入出力の無い孤立した島は空)
20
+ entryPortIds: string[]
21
+ // 外部への出力の出発ポート(同一ポートからの fan-out は1出口扱い。出現順)
22
+ exitPortIds: string[]
18
23
  internal: GraphConnection[]
19
24
  inbound: GraphConnection[]
20
25
  outbound: GraphConnection[]
@@ -26,8 +31,10 @@ export const analyzeExtractSelection = (
26
31
  selectedIds: readonly string[],
27
32
  ): ExtractAnalysis => {
28
33
  const selected = new Set(selectedIds)
29
- if (selected.size < 2) {
30
- return { ok: false, reason: "2つ以上のノードを選択してください" }
34
+ // 1ノードからコンポーネント化できる(グループ化の「2つ以上」はエディタ側の
35
+ // 有効条件。コンポーネントの設定値(refs)と1ノードのコンポーネント化.md)
36
+ if (selected.size < 1) {
37
+ return { ok: false, reason: "ノードを選択してください" }
31
38
  }
32
39
  const selectedNodes = nodes.filter((n) => selected.has(n.id))
33
40
  const flow = selectedNodes.find((n) => n.kind === "start" || n.kind === "end")
@@ -41,6 +48,9 @@ export const analyzeExtractSelection = (
41
48
  const nodeIdByPortId = new Map(
42
49
  nodes.flatMap((n) => n.ports.map((p) => [p.id, n.id] as const)),
43
50
  )
51
+ const portById = new Map(
52
+ nodes.flatMap((n) => n.ports.map((p) => [p.id, p] as const)),
53
+ )
44
54
  const internal: GraphConnection[] = []
45
55
  const inbound: GraphConnection[] = []
46
56
  const outbound: GraphConnection[] = []
@@ -52,29 +62,27 @@ export const analyzeExtractSelection = (
52
62
  else if (fromIn && !toIn) outbound.push(conn)
53
63
  }
54
64
 
55
- const entryNodeIds = new Set(
56
- inbound.map((c) => nodeIdByPortId.get(c.to_port_id) ?? ""),
65
+ // 入口ポート(重複排除・出現順)。条件は「全入口の型が同一」だけ:
66
+ // 起点(どの外部ポートから入るか)は見ない。ノードが前のノードを知らないのと
67
+ // 同じ思想を境界にも適用する(グループ化=入口の合流の宣言)
68
+ const entryPortIds = [...new Set(inbound.map((c) => c.to_port_id))]
69
+ const entrySchemas = new Set(
70
+ entryPortIds.map((id) => JSON.stringify(portById.get(id)?.schema ?? {})),
57
71
  )
58
- if (entryNodeIds.size > 1) {
59
- return {
60
- ok: false,
61
- reason:
62
- "外部からの入力が複数のノードに入っています(入口は1つにしてください)",
63
- }
64
- }
65
- const exitPortIds = new Set(outbound.map((c) => c.from_port_id))
66
- if (exitPortIds.size > 1) {
72
+ if (entrySchemas.size > 1) {
67
73
  return {
68
74
  ok: false,
69
75
  reason:
70
- "外部への出力が複数のポートから出ています(出口は1つにしてください)",
76
+ "入口の型が揃っていません(同じ型の入口だけを1つのグループにまとめられます)",
71
77
  }
72
78
  }
79
+ // 出口は無条件(出発ポートごとに end の in = component の out になる)
80
+ const exitPortIds = [...new Set(outbound.map((c) => c.from_port_id))]
73
81
 
74
82
  return {
75
83
  ok: true,
76
- entryNodeId: [...entryNodeIds][0] ?? null,
77
- exitPortId: [...exitPortIds][0] ?? null,
84
+ entryPortIds,
85
+ exitPortIds,
78
86
  internal,
79
87
  inbound,
80
88
  outbound,
@@ -84,24 +92,30 @@ export const analyzeExtractSelection = (
84
92
  export type ExtractPlan = {
85
93
  // 子 loop に入るノード(新 id でコピー。seed 済み start/end は含まない=呼び出し側が足す)
86
94
  childNodes: GraphNode[]
87
- // 内部接続(新 id)+ start.main → 入口/出口 → end.in
95
+ // 内部接続(新 id)+ start.main → 各入口/各出口 → end の対応する in
88
96
  childConnections: GraphConnection[]
89
- // 親に置く GROUP / COMPONENT ノード(in/main・参照=componentLoopId・選択の重心)
97
+ // 出口を反映した子の end ノード(出口ごとに in を持つ。出口が無ければ渡された姿のまま)
98
+ childEnd: GraphNode
99
+ // 親に置く GROUP / COMPONENT ノード(in 1つ+出口ごとの out・参照=componentLoopId・選択の重心)
90
100
  groupNode: GraphNode
91
101
  // 置換後の親グラフ(選択ノードを除き groupNode を足した全量。線は繋ぎ直し済み)
92
102
  parentNodes: GraphNode[]
93
103
  parentConnections: GraphConnection[]
94
104
  }
95
105
 
106
+ // 出口が1つのときの component out キー(既存コンポーネントとの互換。
107
+ // end の in が1つの loop は out main の1口として振る舞う)
108
+ const SINGLE_OUT_KEY = "main"
109
+
96
110
  export const buildExtractPlan = (params: {
97
111
  nodes: GraphNode[]
98
112
  connections: GraphConnection[]
99
113
  selectedIds: string[]
100
114
  name: string
101
115
  componentLoopId: string
102
- // 子 loop(作成済み・seed 済み)の構造ノードのポート
116
+ // 子 loop(作成済み・seed 済み)の構造ノードのポート/ノード
103
117
  childStartOutPortId: string
104
- childEndInPortId: string
118
+ childEnd: GraphNode
105
119
  newId: () => string
106
120
  }): { ok: false; reason: string } | ({ ok: true } & ExtractPlan) => {
107
121
  const {
@@ -111,7 +125,7 @@ export const buildExtractPlan = (params: {
111
125
  name,
112
126
  componentLoopId,
113
127
  childStartOutPortId,
114
- childEndInPortId,
128
+ childEnd,
115
129
  newId,
116
130
  } = params
117
131
  const analysis = analyzeExtractSelection(nodes, connections, selectedIds)
@@ -138,28 +152,64 @@ export const buildExtractPlan = (params: {
138
152
  }
139
153
  })
140
154
 
155
+ const portById = new Map(
156
+ nodes.flatMap((n) => n.ports.map((p) => [p.id, p] as const)),
157
+ )
141
158
  const mapConn = (conn: GraphConnection): GraphConnection => ({
142
159
  from_port_id: portIdMap.get(conn.from_port_id) ?? conn.from_port_id,
143
160
  to_port_id: portIdMap.get(conn.to_port_id) ?? conn.to_port_id,
144
161
  })
145
162
  const childConnections = analysis.internal.map(mapConn)
146
- // 境界の配線:start.main → 入口ノードの in/出口ポート → end.in
147
- if (analysis.entryNodeId !== null) {
148
- const entry = childNodes.find(
149
- (n) => n.id === nodeIdMap.get(analysis.entryNodeId ?? ""),
150
- )
151
- const entryIn = entry?.ports.find((p) => p.io === "in")
152
- if (entryIn) {
153
- childConnections.push({
154
- from_port_id: childStartOutPortId,
155
- to_port_id: entryIn.id,
156
- })
163
+ // 境界の配線(入口):start.main → 各入口ポート(複数なら fan-out)
164
+ for (const entryPortId of analysis.entryPortIds) {
165
+ childConnections.push({
166
+ from_port_id: childStartOutPortId,
167
+ to_port_id: portIdMap.get(entryPortId) ?? entryPortId,
168
+ })
169
+ }
170
+
171
+ // 境界の配線(出口):出発ポートごとに end の in を1つ充てる。
172
+ // 1つ目は seed 済みの in を使い(名前を出口ポート名へ)、2つ目以降は追加する。
173
+ // component の out はこの in たちの射影(出口1つは互換のため main キー)
174
+ const seededEndIns = childEnd.ports.filter((p) => p.io === "in")
175
+ const endInKeys = new Set(seededEndIns.map((p) => p.key))
176
+ const nextEndInKey = (): string => {
177
+ for (let i = 2; ; i++) {
178
+ const key = `in_${i}`
179
+ if (!endInKeys.has(key)) {
180
+ endInKeys.add(key)
181
+ return key
182
+ }
157
183
  }
158
184
  }
159
- if (analysis.exitPortId !== null) {
185
+ const exits = analysis.exitPortIds.map((exitPortId, i) => {
186
+ const exitName = portById.get(exitPortId)?.name ?? "出力"
187
+ const seeded = i === 0 ? seededEndIns[0] : undefined
188
+ const endIn: GraphPort = seeded
189
+ ? { ...seeded, name: exitName }
190
+ : {
191
+ id: newId(),
192
+ io: "in",
193
+ key: nextEndInKey(),
194
+ name: exitName,
195
+ schema: {},
196
+ }
197
+ return { exitPortId, endIn }
198
+ })
199
+ const resolvedChildEnd: GraphNode =
200
+ exits.length === 0
201
+ ? childEnd
202
+ : {
203
+ ...childEnd,
204
+ ports: [
205
+ ...exits.map((e) => e.endIn),
206
+ ...childEnd.ports.filter((p) => p.io !== "in"),
207
+ ],
208
+ }
209
+ for (const exit of exits) {
160
210
  childConnections.push({
161
- from_port_id: portIdMap.get(analysis.exitPortId) ?? analysis.exitPortId,
162
- to_port_id: childEndInPortId,
211
+ from_port_id: portIdMap.get(exit.exitPortId) ?? exit.exitPortId,
212
+ to_port_id: exit.endIn.id,
163
213
  })
164
214
  }
165
215
 
@@ -173,13 +223,38 @@ export const buildExtractPlan = (params: {
173
223
  selectedNodes.reduce((sum, n) => sum + n.position_y, 0) /
174
224
  selectedNodes.length,
175
225
  }
226
+ // out は出口ごと(キーは end の in の射影。出口1つ以下は互換の main 1口)
227
+ const groupOuts: { exitPortId: string | null; port: GraphPort }[] =
228
+ exits.length <= 1
229
+ ? [
230
+ {
231
+ exitPortId: exits[0]?.exitPortId ?? null,
232
+ port: {
233
+ id: newId(),
234
+ io: "out",
235
+ key: SINGLE_OUT_KEY,
236
+ name: exits[0] ? exits[0].endIn.name : "出力",
237
+ schema: {},
238
+ },
239
+ },
240
+ ]
241
+ : exits.map((e) => ({
242
+ exitPortId: e.exitPortId,
243
+ port: {
244
+ id: newId(),
245
+ io: "out",
246
+ key: e.endIn.key,
247
+ name: e.endIn.name,
248
+ schema: {},
249
+ },
250
+ }))
176
251
  const groupNode: GraphNode = {
177
252
  id: newId(),
178
253
  kind: "component",
179
254
  name,
180
255
  ports: [
181
256
  { id: newId(), io: "in", key: "in", name: "入力", schema: {} },
182
- { id: newId(), io: "out", key: "main", name: "出力", schema: {} },
257
+ ...groupOuts.map((o) => o.port),
183
258
  ],
184
259
  hooks: [],
185
260
  position_x: centroid.x,
@@ -187,12 +262,16 @@ export const buildExtractPlan = (params: {
187
262
  component: { component_loop_id: componentLoopId },
188
263
  }
189
264
  const groupIn = groupNode.ports.find((p) => p.io === "in")
190
- const groupMain = groupNode.ports.find((p) => p.io === "out")
191
- if (!groupIn || !groupMain) {
265
+ if (!groupIn) {
192
266
  return { ok: false, reason: "groupNode のポート生成に失敗しました" }
193
267
  }
268
+ const groupOutByExitPortId = new Map(
269
+ groupOuts
270
+ .filter((o) => o.exitPortId !== null)
271
+ .map((o) => [o.exitPortId as string, o.port] as const),
272
+ )
194
273
 
195
- // 親の置換:選択関連の線(内部・境界)を除き、境界を group の in/main へ繋ぎ直す
274
+ // 親の置換:選択関連の線(内部・境界)を除き、境界を group の in/対応する out へ繋ぎ直す
196
275
  const removed = new Set(
197
276
  [...analysis.internal, ...analysis.inbound, ...analysis.outbound].map(
198
277
  (c) => `${c.from_port_id}→${c.to_port_id}`,
@@ -202,14 +281,17 @@ export const buildExtractPlan = (params: {
202
281
  ...connections.filter(
203
282
  (c) => !removed.has(`${c.from_port_id}→${c.to_port_id}`),
204
283
  ),
205
- ...analysis.inbound.map((c) => ({
206
- from_port_id: c.from_port_id,
207
- to_port_id: groupIn.id,
208
- })),
209
- ...analysis.outbound.map((c) => ({
210
- from_port_id: groupMain.id,
211
- to_port_id: c.to_port_id,
212
- })),
284
+ // 同一起点から複数入口へ入っていた線は group.in への1本に畳む(重複線を作らない)
285
+ ...[...new Set(analysis.inbound.map((c) => c.from_port_id))].map(
286
+ (from_port_id) => ({
287
+ from_port_id,
288
+ to_port_id: groupIn.id,
289
+ }),
290
+ ),
291
+ ...analysis.outbound.flatMap((c) => {
292
+ const out = groupOutByExitPortId.get(c.from_port_id)
293
+ return out ? [{ from_port_id: out.id, to_port_id: c.to_port_id }] : []
294
+ }),
213
295
  ]
214
296
  const parentNodes = [...nodes.filter((n) => !selected.has(n.id)), groupNode]
215
297
 
@@ -217,6 +299,7 @@ export const buildExtractPlan = (params: {
217
299
  ok: true,
218
300
  childNodes,
219
301
  childConnections,
302
+ childEnd: resolvedChildEnd,
220
303
  groupNode,
221
304
  parentNodes,
222
305
  parentConnections,
@@ -8,7 +8,7 @@ import {
8
8
  waitConfigSchema,
9
9
  } from "./graph.js"
10
10
  import type { GraphPort } from "./node.js"
11
- import { envelopeSchemaOf, resolvePortSchemas } from "./port-spec.js"
11
+ import { resolvePortSchemas } from "./port-spec.js"
12
12
 
13
13
  // テスト用の決定的な uuid(common は node 型定義を持たないため crypto は使わない)
14
14
  let seq = 0
@@ -89,15 +89,14 @@ const port = (io: "in" | "out", key: string): GraphPort => ({
89
89
  schema: { type: "object" },
90
90
  })
91
91
 
92
- // ai の out 宣言は封筒型 { data, description } 固定なので、デフォルトノードは
93
- // data=テキストの封筒を持つ
94
- const textEnvelope = envelopeSchemaOf({ type: "string" })
92
+ // ai の out も自由な宣言型。デフォルトノードはテキスト(string)を宣言する
93
+ const textOutput = { type: "string" }
95
94
 
96
95
  // 既定は ai ノード。ai の in は 上流データの in + 差し戻しの instruction の2口
97
96
  const aiPorts = (): GraphPort[] => [
98
97
  port("in", "in"),
99
98
  port("in", "instruction"),
100
- { ...port("out", "main"), schema: textEnvelope },
99
+ { ...port("out", "main"), schema: textOutput },
101
100
  ]
102
101
 
103
102
  // ai 以外に流用するとき用(in は1つ)
@@ -388,17 +387,27 @@ describe("saveGraphBodySchema", () => {
388
387
  ).toThrow(/key が重複/)
389
388
  })
390
389
 
391
- it("in ポートがちょうど1つでない node を拒否する(0個・2個)", () => {
390
+ it("in ポートの口数:ai は1つ以上(自由)、program 等は ちょうど1つ", () => {
391
+ // ai:0個は拒否、複数は許可(in の追加・削除は自由)
392
392
  const zero = makeNode([port("out", "main")])
393
393
  expect(() =>
394
394
  saveGraphBodySchema.parse({ nodes: [zero], connections: [] }),
395
395
  ).toThrow(/in ポート/)
396
-
397
- const two = makeNode([
396
+ const many = makeNode([
398
397
  port("in", "in"),
399
- port("in", "in2"),
398
+ port("in", "feedback"),
399
+ port("in", "extra"),
400
400
  port("out", "main"),
401
401
  ])
402
+ expect(() =>
403
+ saveGraphBodySchema.parse({ nodes: [many], connections: [] }),
404
+ ).not.toThrow()
405
+
406
+ // program:ちょうど1つ
407
+ const two = {
408
+ ...makeNode([port("in", "in"), port("in", "in2"), port("out", "main")]),
409
+ kind: "program" as const,
410
+ }
402
411
  expect(() =>
403
412
  saveGraphBodySchema.parse({ nodes: [two], connections: [] }),
404
413
  ).toThrow(/in ポート/)
@@ -497,8 +506,6 @@ describe("saveGraphBodySchema", () => {
497
506
 
498
507
  // ---- kind 別ポート構成と型の接続バリデーション(ポートのkind別仕様.md) ----
499
508
 
500
- const aiEnvelope = textEnvelope
501
-
502
509
  const humanNode = () => ({
503
510
  ...makeNode([
504
511
  port("in", "in"),
@@ -511,7 +518,7 @@ const aiNode = () => ({
511
518
  ...makeNode([
512
519
  port("in", "in"),
513
520
  port("in", "instruction"),
514
- { ...port("out", "main"), schema: aiEnvelope },
521
+ { ...port("out", "main"), schema: textOutput },
515
522
  ]),
516
523
  kind: "ai" as const,
517
524
  })
@@ -549,27 +556,27 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
549
556
  ...makeNode([
550
557
  port("in", "in"),
551
558
  port("in", "instruction"),
552
- { ...port("out", "spam"), schema: aiEnvelope },
553
- { ...port("out", "sales"), schema: aiEnvelope },
554
- { ...port("out", "support"), schema: aiEnvelope },
559
+ { ...port("out", "spam"), schema: textOutput },
560
+ { ...port("out", "sales"), schema: textOutput },
561
+ { ...port("out", "support"), schema: textOutput },
555
562
  ]),
556
563
  kind: "ai" as const,
557
564
  }
558
565
  expect(() =>
559
566
  saveGraphBodySchema.parse({ nodes: [multi], connections: [] }),
560
567
  ).not.toThrow()
561
- // in の構成は in / instruction 固定のまま
562
- const badIn = {
568
+ // in key も自由(in / instruction の固定構成は廃止)
569
+ const freeIn = {
563
570
  ...makeNode([
564
571
  port("in", "input"),
565
- port("in", "instruction"),
566
- { ...port("out", "main"), schema: aiEnvelope },
572
+ port("in", "feedback"),
573
+ { ...port("out", "main"), schema: textOutput },
567
574
  ]),
568
575
  kind: "ai" as const,
569
576
  }
570
577
  expect(() =>
571
- saveGraphBodySchema.parse({ nodes: [badIn], connections: [] }),
572
- ).toThrow(/ai の in ポート/)
578
+ saveGraphBodySchema.parse({ nodes: [freeIn], connections: [] }),
579
+ ).not.toThrow()
573
580
  })
574
581
 
575
582
  it("program は out を複数持てる(key 自由)", () => {
@@ -586,49 +593,38 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
586
593
  ).not.toThrow()
587
594
  })
588
595
 
589
- it("ai の出力宣言が封筒型 { data, description } でないと拒否する(全出口に適用)", () => {
590
- const bad = makeNode([
591
- port("in", "in"),
592
- port("in", "instruction"),
593
- { ...port("out", "main"), schema: { type: "object" } },
594
- ])
595
- expect(() =>
596
- saveGraphBodySchema.parse({ nodes: [bad], connections: [] }),
597
- ).toThrow(/封筒型 \{ data, description \}/)
598
- // main 以外の出口も封筒型を強制する
599
- const badExtra = makeNode([
600
- port("in", "in"),
601
- port("in", "instruction"),
602
- { ...port("out", "main"), schema: aiEnvelope },
603
- { ...port("out", "extra"), schema: { type: "object" } },
604
- ])
605
- expect(() =>
606
- saveGraphBodySchema.parse({ nodes: [badExtra], connections: [] }),
607
- ).toThrow(/封筒型 \{ data, description \}/)
608
- // data は任意の型でよい(カタログ外の object も通る)
609
- const custom = makeNode([
610
- port("in", "in"),
611
- port("in", "instruction"),
596
+ it("ai の出力宣言は任意の JSON Schema でよい(形の強制はしない)", () => {
597
+ for (const schema of [
598
+ { type: "object" },
599
+ { type: "string" },
600
+ { type: "array", items: { type: "number" } },
612
601
  {
613
- ...port("out", "main"),
614
- schema: envelopeSchemaOf({
615
- type: "object",
616
- properties: { title: { type: "string" } },
617
- required: ["title"],
618
- }),
602
+ // かつて強制していた { data, description } もただの object として valid
603
+ type: "object",
604
+ properties: {
605
+ data: { type: "string" },
606
+ description: { type: "string" },
607
+ },
608
+ required: ["data", "description"],
619
609
  },
620
- ])
621
- expect(() =>
622
- saveGraphBodySchema.parse({ nodes: [custom], connections: [] }),
623
- ).not.toThrow()
610
+ ]) {
611
+ const node = makeNode([
612
+ port("in", "in"),
613
+ port("in", "instruction"),
614
+ { ...port("out", "main"), schema },
615
+ ])
616
+ expect(() =>
617
+ saveGraphBodySchema.parse({ nodes: [node], connections: [] }),
618
+ ).not.toThrow()
619
+ }
624
620
  })
625
621
  })
626
622
 
627
623
  describe("saveGraphBodySchema(型の接続バリデーション)", () => {
628
- // 接続の可否は kind ではなく型で決まる(reject instruction は両端とも
629
- // instructionSchema() で固定されているので、型検証だけで組み合わせが決まる)
630
- // → docs/tasks/wip/Humanの入出力を承認ビューの宣言に一本化する.md
631
- it("human の reject → ai の instruction は型が一致するので許可", () => {
624
+ // 接続の可否は kind ではなく型で決まる。reject / instruction の型固定は廃止され、
625
+ // ai の in はどの口も接続鏡映(接続元の型に同期)なので接続は自明に通る
626
+ // → docs/tasks/wip/ポート型固定の撤去とセッション再開のポート属性化.md
627
+ it("human の reject → ai の instruction を許可(kind の縛りも型固定も無い)", () => {
632
628
  const human = humanNode()
633
629
  const ai = aiNode()
634
630
  const rejectPort = outByKey(human, "reject")
@@ -645,7 +641,7 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
645
641
  ).not.toThrow()
646
642
  })
647
643
 
648
- it("reject は ai 以外へも繋げる(接続鏡映の in が指示型を採用する)", () => {
644
+ it("reject は ai 以外へも繋げる(接続鏡映の in が接続元の型を採用する)", () => {
649
645
  const human = humanNode()
650
646
  const prog = programNode()
651
647
  expect(() =>
@@ -661,10 +657,11 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
661
657
  ).not.toThrow()
662
658
  })
663
659
 
664
- it("instruction ポートには型の合わない出力を繋げない(kind ではなく型で弾く)", () => {
660
+ it("instruction も他の in と同じ接続鏡映:宣言と違う型の out でも同期して繋がる", () => {
665
661
  const upstream = aiNode()
666
662
  const downstream = aiNode()
667
- // ai.main は封筒型 { data, description }、instruction { prompt, files }
663
+ // ai.main string、instruction の宣言は { type: "object" } だが、
664
+ // 鏡映で接続元に同期される(instruction の特別扱いは無い)
668
665
  expect(() =>
669
666
  saveGraphBodySchema.parse({
670
667
  nodes: [upstream, downstream],
@@ -675,10 +672,10 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
675
672
  },
676
673
  ],
677
674
  }),
678
- ).toThrow(/適合しません/)
675
+ ).not.toThrow()
679
676
  })
680
677
 
681
- it("human の in はどの型でも接続できる(封筒型の制約は課さない)", () => {
678
+ it("human の in はどの型でも接続できる(形の制約は課さない)", () => {
682
679
  const ai = aiNode()
683
680
  const human = humanNode()
684
681
  expect(() =>
@@ -688,8 +685,7 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
688
685
  }),
689
686
  ).not.toThrow()
690
687
 
691
- // program out が封筒型でない({ type: "object" } のまま)でも接続できる
692
- // (input 全体を data として表示する)
688
+ // program out が素の object でも接続できる
693
689
  const human2 = humanNode()
694
690
  const progPlain = {
695
691
  ...makeNode([
@@ -707,7 +703,8 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
707
703
  })
708
704
 
709
705
  it("program の in への接続は 伝播型 ⊆ 宣言スキーマ(human 経由の伝播込み)", () => {
710
- // ai(封筒型) → human(approve は data 型に剥がれる) program(string を要求) は通る
706
+ // ai → humanapprove は in と同型)→ program(string を要求) は、
707
+ // program の in が接続鏡映(sticky)なので通る
711
708
  const ai = aiNode()
712
709
  const human = humanNode()
713
710
  const prog = programNode({ type: "string" })
@@ -1027,6 +1024,30 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
1027
1024
  ).not.toThrow()
1028
1025
  })
1029
1026
 
1027
+ it("component.refs(配置の設定値)を受け付けて保持する(省略も可)", () => {
1028
+ const withRefs = {
1029
+ ...componentNode(),
1030
+ component: {
1031
+ component_loop_id: uuid(),
1032
+ refs: { labels: ["bug"], assignee: "kazuwombat" },
1033
+ },
1034
+ }
1035
+ const parsed = saveGraphBodySchema.parse({
1036
+ nodes: [withRefs],
1037
+ connections: [],
1038
+ })
1039
+ expect(parsed.nodes[0]?.component?.refs).toEqual({
1040
+ labels: ["bug"],
1041
+ assignee: "kazuwombat",
1042
+ })
1043
+ // 省略時は undefined のまま(既存グラフの互換。保存側で {} 扱い)
1044
+ const omitted = saveGraphBodySchema.parse({
1045
+ nodes: [componentNode()],
1046
+ connections: [],
1047
+ })
1048
+ expect(omitted.nodes[0]?.component?.refs).toBeUndefined()
1049
+ })
1050
+
1030
1051
  it("component_loop_id の無い component ノードを拒否する", () => {
1031
1052
  const node = { ...componentNode(), component: undefined }
1032
1053
  expect(() =>
@@ -1034,21 +1055,23 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
1034
1055
  ).toThrow(/component_loop_id/)
1035
1056
  })
1036
1057
 
1037
- it("ポート構成は in/main 固定(out key 違い・複数 out を拒否)", () => {
1038
- const badKey = {
1058
+ it("in in 1つ固定・out は複数可(子 end in 射影。コンポーネントの複数入口・複数出口.md)", () => {
1059
+ // in key 違いは拒否
1060
+ const badIn = {
1039
1061
  ...componentNode(),
1040
- ports: [port("in", "in"), port("out", "result")],
1062
+ ports: [port("in", "input"), port("out", "main")],
1041
1063
  }
1042
1064
  expect(() =>
1043
- saveGraphBodySchema.parse({ nodes: [badKey], connections: [] }),
1044
- ).toThrow(/component のポート構成/)
1065
+ saveGraphBodySchema.parse({ nodes: [badIn], connections: [] }),
1066
+ ).toThrow(/component の in ポートは in/)
1067
+ // out は key 自由・複数可(キーは保存時にサーバが子契約から焼き込む)
1045
1068
  const multiOut = {
1046
1069
  ...componentNode(),
1047
1070
  ports: [port("in", "in"), port("out", "main"), port("out", "sub")],
1048
1071
  }
1049
1072
  expect(() =>
1050
1073
  saveGraphBodySchema.parse({ nodes: [multiOut], connections: [] }),
1051
- ).toThrow(/component のポート構成/)
1074
+ ).not.toThrow()
1052
1075
  })
1053
1076
 
1054
1077
  it("component の in への接続は契約への適合を検証する(伝播型 ⊆ 契約)", () => {