@mawaru/sdk 0.14.1 → 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.
@@ -216,6 +216,26 @@ describe("duplicateSelection(選択ノードの複製)", () => {
216
216
  )
217
217
  })
218
218
 
219
+ it("手動ルート(waypoints)は dx / dy を適用して引き継ぐ", () => {
220
+ const { b, c, nodes, connections } = chain()
221
+ const manual = connections.map((conn, i) =>
222
+ i === 1
223
+ ? {
224
+ ...conn,
225
+ waypoints: [
226
+ { x: 150, y: 0 },
227
+ { x: 150, y: 100 },
228
+ ],
229
+ }
230
+ : conn,
231
+ )
232
+ const result = dup(nodes, manual, [b.id, c.id], 40, -20)
233
+ expect(result.connections[0]?.waypoints).toEqual([
234
+ { x: 190, y: -20 },
235
+ { x: 190, y: 80 },
236
+ ])
237
+ })
238
+
219
239
  it("dx / dy を全ノードへ一律に適用し、相対配置を保つ", () => {
220
240
  const { b, c, nodes, connections } = chain()
221
241
  const result = dup(nodes, connections, [b.id, c.id], 40, -20)
@@ -37,11 +37,27 @@ export const duplicateSelection = (params: {
37
37
  }))
38
38
 
39
39
  // 両端が複製対象のポートである線(=選択内で完結する線)だけを張り替えて写す。
40
- // 外部との線(入口・出口)はコピーしない
40
+ // 外部との線(入口・出口)はコピーしない。手動ルート(waypoints)は
41
+ // ノードと同じ移動量で写し、調整済みの形を保つ
41
42
  const copiedConnections = connections.flatMap((c) => {
42
43
  const from = portIdMap.get(c.from_port_id)
43
44
  const to = portIdMap.get(c.to_port_id)
44
- return from && to ? [{ from_port_id: from, to_port_id: to }] : []
45
+ return from && to
46
+ ? [
47
+ {
48
+ from_port_id: from,
49
+ to_port_id: to,
50
+ ...(c.waypoints
51
+ ? {
52
+ waypoints: c.waypoints.map((p) => ({
53
+ x: p.x + dx,
54
+ y: p.y + dy,
55
+ })),
56
+ }
57
+ : {}),
58
+ },
59
+ ]
60
+ : []
45
61
  })
46
62
 
47
63
  return { nodes: copied, connections: copiedConnections }
@@ -4,18 +4,22 @@ import {
4
4
  buildExtractPlan,
5
5
  } from "./extract-component.js"
6
6
  import type { GraphNode } from "./graph.js"
7
- import type { GraphPort } from "./node.js"
7
+ import type { GraphPort, JsonSchema } from "./node.js"
8
8
 
9
9
  // テスト用の決定的な uuid(graph.test.ts と同じ流儀)
10
10
  let seq = 0
11
11
  const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
12
12
 
13
- const port = (io: "in" | "out", key: string): GraphPort => ({
13
+ const port = (
14
+ io: "in" | "out",
15
+ key: string,
16
+ schema: JsonSchema = { type: "object" },
17
+ ): GraphPort => ({
14
18
  id: uuid(),
15
19
  io,
16
20
  key,
17
21
  name: key,
18
- schema: { type: "object" },
22
+ schema,
19
23
  })
20
24
 
21
25
  const makeNode = (
@@ -62,44 +66,60 @@ const chain = () => {
62
66
  return { a, b, c, d, nodes, connections }
63
67
  }
64
68
 
65
- describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
69
+ describe("analyzeExtractSelection(境界の分類と成立条件)", () => {
66
70
  it("直列の中間2ノード:入口・出口と内部/入口/出口線を分類する", () => {
67
71
  const { a, b, c, d, nodes, connections } = chain()
68
72
  const result = analyzeExtractSelection(nodes, connections, [b.id, c.id])
69
73
  if (!result.ok) throw new Error(result.reason)
70
- expect(result.entryNodeId).toBe(b.id)
71
- expect(result.exitPortId).toBe(outOf(c).id)
74
+ expect(result.entryPortIds).toEqual([inOf(b).id])
75
+ expect(result.exitPortIds).toEqual([outOf(c).id])
72
76
  expect(result.internal).toEqual([connect(b, c)])
73
77
  expect(result.inbound).toEqual([connect(a, b)])
74
78
  expect(result.outbound).toEqual([connect(c, d)])
75
79
  })
76
80
 
77
- it("孤立した島(入出力なし)も抽出できる(entry/exit は null)", () => {
81
+ it("孤立した島(入出力なし)も抽出できる(入口・出口は空)", () => {
78
82
  const { b, c, nodes } = chain()
79
83
  const internalOnly = [connect(b, c)]
80
84
  const result = analyzeExtractSelection(nodes, internalOnly, [b.id, c.id])
81
85
  if (!result.ok) throw new Error(result.reason)
82
- expect(result.entryNodeId).toBeNull()
83
- expect(result.exitPortId).toBeNull()
86
+ expect(result.entryPortIds).toEqual([])
87
+ expect(result.exitPortIds).toEqual([])
84
88
  })
85
89
 
86
- it("入口が2ノードになる選択は拒否する", () => {
90
+ it("入口が複数でも型が同じなら成立する(同型の合流)", () => {
87
91
  const { a, b, c, d, nodes, connections } = chain()
88
- // A→B D→C を追加:選択 {B, C} への外部入力が B と C の2ノードに入る
92
+ // A→B に加えて D→C:選択 {B, C} への外部入力が B.in と C.in の2ポートに入る。
93
+ // どちらの in も {type:"object"} なので成立する
89
94
  const withSecondEntry = [...connections, connect(d, c)]
90
95
  const result = analyzeExtractSelection(nodes, withSecondEntry, [b.id, c.id])
96
+ if (!result.ok) throw new Error(result.reason)
97
+ expect(result.entryPortIds.sort()).toEqual([inOf(b).id, inOf(c).id].sort())
98
+ void a
99
+ })
100
+
101
+ it("入口の型が揃っていない選択は拒否する", () => {
102
+ const { a, b, d, nodes, connections } = chain()
103
+ // in の型が違うノードを選択に足し、外部から線を入れる
104
+ const e = {
105
+ ...makeNode("program", "E", 200, 200),
106
+ ports: [port("in", "in", { type: "string" }), port("out", "main")],
107
+ }
108
+ const withE = [...nodes, e]
109
+ const conns = [...connections, connect(d, e)]
110
+ const result = analyzeExtractSelection(withE, conns, [b.id, e.id])
91
111
  expect(result.ok).toBe(false)
92
- if (!result.ok) expect(result.reason).toContain("入口")
112
+ if (!result.ok) expect(result.reason).toContain("入口の型")
93
113
  void a
94
114
  })
95
115
 
96
- it("出口が2ポートになる選択は拒否する(同一ポートからの fan-out は許す)", () => {
116
+ it("出口は複数ポートでも成立する(同一ポートからの fan-out は1出口扱い)", () => {
97
117
  const { a, b, c, d, nodes, connections } = chain()
98
118
  // B→D を追加:選択 {B, C} から外への出力が B.main と C.main の2ポート
99
119
  const twoExits = [...connections, connect(b, d)]
100
120
  const result = analyzeExtractSelection(nodes, twoExits, [b.id, c.id])
101
- expect(result.ok).toBe(false)
102
- if (!result.ok) expect(result.reason).toContain("出口")
121
+ if (!result.ok) throw new Error(result.reason)
122
+ expect(result.exitPortIds.sort()).toEqual([outOf(b).id, outOf(c).id].sort())
103
123
 
104
124
  // C.main → D に加えて C.main → A(同一ポートの fan-out)は出口1つ扱い
105
125
  const fanOut = [
@@ -107,15 +127,16 @@ describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
107
127
  { from_port_id: outOf(c).id, to_port_id: inOf(a).id },
108
128
  ]
109
129
  const ok = analyzeExtractSelection(nodes, fanOut, [b.id, c.id])
110
- expect(ok.ok).toBe(true)
130
+ if (!ok.ok) throw new Error(ok.reason)
131
+ expect(ok.exitPortIds).toEqual([outOf(c).id])
111
132
  })
112
133
 
113
- it("1ノード選択:そのノードが入口、その out が出口になる", () => {
134
+ it("1ノード選択:そのノードの in が入口、その out が出口になる", () => {
114
135
  const { a, b, c, nodes, connections } = chain()
115
136
  const result = analyzeExtractSelection(nodes, connections, [b.id])
116
137
  if (!result.ok) throw new Error(result.reason)
117
- expect(result.entryNodeId).toBe(b.id)
118
- expect(result.exitPortId).toBe(outOf(b).id)
138
+ expect(result.entryPortIds).toEqual([inOf(b).id])
139
+ expect(result.exitPortIds).toEqual([outOf(b).id])
119
140
  expect(result.internal).toEqual([])
120
141
  expect(result.inbound).toEqual([connect(a, b)])
121
142
  expect(result.outbound).toEqual([connect(b, c)])
@@ -137,10 +158,20 @@ describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
137
158
  })
138
159
 
139
160
  describe("buildExtractPlan(子グラフと親置換の生成)", () => {
161
+ const childEndNode = (): GraphNode => ({
162
+ id: uuid(),
163
+ kind: "end",
164
+ name: "終了",
165
+ ports: [port("in", "in", {}), port("out", "main", {})],
166
+ hooks: [],
167
+ position_x: 560,
168
+ position_y: 200,
169
+ })
170
+
140
171
  const build = () => {
141
172
  const { a, b, c, d, nodes, connections } = chain()
142
173
  const childStartOutPortId = uuid()
143
- const childEndInPortId = uuid()
174
+ const childEnd = childEndNode()
144
175
  const componentLoopId = uuid()
145
176
  const plan = buildExtractPlan({
146
177
  nodes,
@@ -149,7 +180,7 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
149
180
  name: "PRレビュー",
150
181
  componentLoopId,
151
182
  childStartOutPortId,
152
- childEndInPortId,
183
+ childEnd,
153
184
  newId: uuid,
154
185
  })
155
186
  if (!plan.ok) throw new Error(plan.reason)
@@ -162,7 +193,7 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
162
193
  connections,
163
194
  plan,
164
195
  childStartOutPortId,
165
- childEndInPortId,
196
+ childEnd,
166
197
  componentLoopId,
167
198
  }
168
199
  }
@@ -185,18 +216,20 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
185
216
  expect(internal).toBeDefined()
186
217
  })
187
218
 
188
- it("子:start.main → 入口ノード、出口ポート → end.in が配線される", () => {
189
- const { plan, childStartOutPortId, childEndInPortId } = build()
219
+ it("子:start.main → 入口、出口ポート → end.in が配線される(出口1つは seed の in を使う)", () => {
220
+ const { plan, childStartOutPortId, childEnd } = build()
190
221
  const [nb, nc] = plan.childNodes
191
222
  if (!nb || !nc) throw new Error("コピーがない")
192
223
  expect(plan.childConnections).toContainEqual({
193
224
  from_port_id: childStartOutPortId,
194
225
  to_port_id: inOf(nb).id,
195
226
  })
227
+ // seed 済みの end.in がそのまま使われ、名前は出口ポート名になる
196
228
  expect(plan.childConnections).toContainEqual({
197
229
  from_port_id: outOf(nc).id,
198
- to_port_id: childEndInPortId,
230
+ to_port_id: inOf(childEnd).id,
199
231
  })
232
+ expect(inOf(plan.childEnd).name).toBe(outOf(nc).name)
200
233
  })
201
234
 
202
235
  it("親:選択が groupNode に置き換わり、外部との線が in/main へ繋ぎ直される", () => {
@@ -209,6 +242,10 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
209
242
  expect(plan.groupNode.name).toBe("PRレビュー")
210
243
  expect(plan.groupNode.position_x).toBe(150) // (100+200)/2
211
244
  expect(plan.groupNode.position_y).toBe(50) // (0+100)/2
245
+ // 出口1つ:out は互換の main 1口
246
+ expect(
247
+ plan.groupNode.ports.filter((p) => p.io === "out").map((p) => p.key),
248
+ ).toEqual(["main"])
212
249
 
213
250
  // 親 nodes:A, D, groupNode(B, C は消える)
214
251
  expect(plan.parentNodes.map((n) => n.id).sort()).toEqual(
@@ -228,10 +265,101 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
228
265
  void c
229
266
  })
230
267
 
268
+ it("複数入口(同型):start.main から全入口へ fan-out し、親の同一起点は group.in への1本に畳まれる", () => {
269
+ // X.main が B と C の両方へ入る(今回の実例:開発.main → レビューと監視)
270
+ const { a, b, c, d, nodes, connections } = chain()
271
+ const conns = [
272
+ ...connections,
273
+ { from_port_id: outOf(a).id, to_port_id: inOf(c).id },
274
+ ]
275
+ const childStartOutPortId = uuid()
276
+ const plan = buildExtractPlan({
277
+ nodes,
278
+ connections: conns,
279
+ selectedIds: [b.id, c.id],
280
+ name: "レビュー",
281
+ componentLoopId: uuid(),
282
+ childStartOutPortId,
283
+ childEnd: childEndNode(),
284
+ newId: uuid,
285
+ })
286
+ if (!plan.ok) throw new Error(plan.reason)
287
+ const [nb, nc] = plan.childNodes
288
+ if (!nb || !nc) throw new Error("コピーがない")
289
+ // 子:start.main → B.in と C.in の両方
290
+ expect(plan.childConnections).toContainEqual({
291
+ from_port_id: childStartOutPortId,
292
+ to_port_id: inOf(nb).id,
293
+ })
294
+ expect(plan.childConnections).toContainEqual({
295
+ from_port_id: childStartOutPortId,
296
+ to_port_id: inOf(nc).id,
297
+ })
298
+ // 親:A.main → group.in は1本だけ(重複線を作らない)
299
+ const inboundToGroup = plan.parentConnections.filter(
300
+ (conn) => conn.to_port_id === inOf(plan.groupNode).id,
301
+ )
302
+ expect(inboundToGroup).toEqual([
303
+ { from_port_id: outOf(a).id, to_port_id: inOf(plan.groupNode).id },
304
+ ])
305
+ void d
306
+ })
307
+
308
+ it("複数出口:出発ポートごとに end の in と group の out が生え、親は対応する out から繋ぎ直される", () => {
309
+ const { a, b, c, d, nodes, connections } = chain()
310
+ // B→D を追加:出口は B.main と C.main の2つ
311
+ const conns = [...connections, connect(b, d)]
312
+ const childEnd = childEndNode()
313
+ const plan = buildExtractPlan({
314
+ nodes,
315
+ connections: conns,
316
+ selectedIds: [b.id, c.id],
317
+ name: "レビュー",
318
+ componentLoopId: uuid(),
319
+ childStartOutPortId: uuid(),
320
+ childEnd,
321
+ newId: uuid,
322
+ })
323
+ if (!plan.ok) throw new Error(plan.reason)
324
+ const [nb, nc] = plan.childNodes
325
+ if (!nb || !nc) throw new Error("コピーがない")
326
+
327
+ // 子 end:in が出口の数だけある(1つ目は seed 再利用・2つ目は追加)
328
+ const endIns = plan.childEnd.ports.filter((p) => p.io === "in")
329
+ expect(endIns).toHaveLength(2)
330
+ // 出口ポート → 対応する end.in の配線
331
+ const endInByExit = new Map(
332
+ plan.childConnections
333
+ .filter((conn) => endIns.some((p) => p.id === conn.to_port_id))
334
+ .map((conn) => [conn.from_port_id, conn.to_port_id] as const),
335
+ )
336
+ expect(endInByExit.get(outOf(nc).id)).toBeDefined()
337
+ expect(endInByExit.get(outOf(nb).id)).toBeDefined()
338
+
339
+ // group:出口ごとの out(key は end.in の射影)
340
+ const groupOuts = plan.groupNode.ports.filter((p) => p.io === "out")
341
+ expect(groupOuts).toHaveLength(2)
342
+ expect(groupOuts.map((p) => p.key).sort()).toEqual(
343
+ endIns.map((p) => p.key).sort(),
344
+ )
345
+
346
+ // 親:C.main → D は「C.main に対応する out」→ D、B.main → D は「B.main に対応する out」→ D
347
+ const parentOutConns = plan.parentConnections.filter((conn) =>
348
+ groupOuts.some((p) => p.id === conn.from_port_id),
349
+ )
350
+ expect(parentOutConns).toHaveLength(2)
351
+ expect(new Set(parentOutConns.map((c2) => c2.to_port_id))).toEqual(
352
+ new Set([inOf(d).id]),
353
+ )
354
+ // 2本は別々の out から出る(同じ out からの fan-out ではない)
355
+ expect(new Set(parentOutConns.map((c2) => c2.from_port_id)).size).toBe(2)
356
+ void a
357
+ })
358
+
231
359
  it("1ノード選択:start.main → ノード → end.in と配線され、親では component ノードに置き換わる", () => {
232
360
  const { a, b, c, nodes, connections } = chain()
233
361
  const childStartOutPortId = uuid()
234
- const childEndInPortId = uuid()
362
+ const childEnd = childEndNode()
235
363
  const plan = buildExtractPlan({
236
364
  nodes,
237
365
  connections,
@@ -239,7 +367,7 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
239
367
  name: "待つだけ",
240
368
  componentLoopId: uuid(),
241
369
  childStartOutPortId,
242
- childEndInPortId,
370
+ childEnd,
243
371
  newId: uuid,
244
372
  })
245
373
  if (!plan.ok) throw new Error(plan.reason)
@@ -253,7 +381,7 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
253
381
  })
254
382
  expect(plan.childConnections).toContainEqual({
255
383
  from_port_id: outOf(nb).id,
256
- to_port_id: childEndInPortId,
384
+ to_port_id: inOf(childEnd).id,
257
385
  })
258
386
  // 親:B が component ノードに置き換わり、A.main → group.in/group.main → C.in
259
387
  expect(plan.parentNodes.some((n) => n.id === b.id)).toBe(false)
@@ -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[]
@@ -43,6 +48,9 @@ export const analyzeExtractSelection = (
43
48
  const nodeIdByPortId = new Map(
44
49
  nodes.flatMap((n) => n.ports.map((p) => [p.id, n.id] as const)),
45
50
  )
51
+ const portById = new Map(
52
+ nodes.flatMap((n) => n.ports.map((p) => [p.id, p] as const)),
53
+ )
46
54
  const internal: GraphConnection[] = []
47
55
  const inbound: GraphConnection[] = []
48
56
  const outbound: GraphConnection[] = []
@@ -54,29 +62,27 @@ export const analyzeExtractSelection = (
54
62
  else if (fromIn && !toIn) outbound.push(conn)
55
63
  }
56
64
 
57
- const entryNodeIds = new Set(
58
- 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 ?? {})),
59
71
  )
60
- if (entryNodeIds.size > 1) {
61
- return {
62
- ok: false,
63
- reason:
64
- "外部からの入力が複数のノードに入っています(入口は1つにしてください)",
65
- }
66
- }
67
- const exitPortIds = new Set(outbound.map((c) => c.from_port_id))
68
- if (exitPortIds.size > 1) {
72
+ if (entrySchemas.size > 1) {
69
73
  return {
70
74
  ok: false,
71
75
  reason:
72
- "外部への出力が複数のポートから出ています(出口は1つにしてください)",
76
+ "入口の型が揃っていません(同じ型の入口だけを1つのグループにまとめられます)",
73
77
  }
74
78
  }
79
+ // 出口は無条件(出発ポートごとに end の in = component の out になる)
80
+ const exitPortIds = [...new Set(outbound.map((c) => c.from_port_id))]
75
81
 
76
82
  return {
77
83
  ok: true,
78
- entryNodeId: [...entryNodeIds][0] ?? null,
79
- exitPortId: [...exitPortIds][0] ?? null,
84
+ entryPortIds,
85
+ exitPortIds,
80
86
  internal,
81
87
  inbound,
82
88
  outbound,
@@ -86,24 +92,30 @@ export const analyzeExtractSelection = (
86
92
  export type ExtractPlan = {
87
93
  // 子 loop に入るノード(新 id でコピー。seed 済み start/end は含まない=呼び出し側が足す)
88
94
  childNodes: GraphNode[]
89
- // 内部接続(新 id)+ start.main → 入口/出口 → end.in
95
+ // 内部接続(新 id)+ start.main → 各入口/各出口 → end の対応する in
90
96
  childConnections: GraphConnection[]
91
- // 親に置く GROUP / COMPONENT ノード(in/main・参照=componentLoopId・選択の重心)
97
+ // 出口を反映した子の end ノード(出口ごとに in を持つ。出口が無ければ渡された姿のまま)
98
+ childEnd: GraphNode
99
+ // 親に置く GROUP / COMPONENT ノード(in 1つ+出口ごとの out・参照=componentLoopId・選択の重心)
92
100
  groupNode: GraphNode
93
101
  // 置換後の親グラフ(選択ノードを除き groupNode を足した全量。線は繋ぎ直し済み)
94
102
  parentNodes: GraphNode[]
95
103
  parentConnections: GraphConnection[]
96
104
  }
97
105
 
106
+ // 出口が1つのときの component out キー(既存コンポーネントとの互換。
107
+ // end の in が1つの loop は out main の1口として振る舞う)
108
+ const SINGLE_OUT_KEY = "main"
109
+
98
110
  export const buildExtractPlan = (params: {
99
111
  nodes: GraphNode[]
100
112
  connections: GraphConnection[]
101
113
  selectedIds: string[]
102
114
  name: string
103
115
  componentLoopId: string
104
- // 子 loop(作成済み・seed 済み)の構造ノードのポート
116
+ // 子 loop(作成済み・seed 済み)の構造ノードのポート/ノード
105
117
  childStartOutPortId: string
106
- childEndInPortId: string
118
+ childEnd: GraphNode
107
119
  newId: () => string
108
120
  }): { ok: false; reason: string } | ({ ok: true } & ExtractPlan) => {
109
121
  const {
@@ -113,7 +125,7 @@ export const buildExtractPlan = (params: {
113
125
  name,
114
126
  componentLoopId,
115
127
  childStartOutPortId,
116
- childEndInPortId,
128
+ childEnd,
117
129
  newId,
118
130
  } = params
119
131
  const analysis = analyzeExtractSelection(nodes, connections, selectedIds)
@@ -140,28 +152,64 @@ export const buildExtractPlan = (params: {
140
152
  }
141
153
  })
142
154
 
155
+ const portById = new Map(
156
+ nodes.flatMap((n) => n.ports.map((p) => [p.id, p] as const)),
157
+ )
143
158
  const mapConn = (conn: GraphConnection): GraphConnection => ({
144
159
  from_port_id: portIdMap.get(conn.from_port_id) ?? conn.from_port_id,
145
160
  to_port_id: portIdMap.get(conn.to_port_id) ?? conn.to_port_id,
146
161
  })
147
162
  const childConnections = analysis.internal.map(mapConn)
148
- // 境界の配線:start.main → 入口ノードの in/出口ポート → end.in
149
- if (analysis.entryNodeId !== null) {
150
- const entry = childNodes.find(
151
- (n) => n.id === nodeIdMap.get(analysis.entryNodeId ?? ""),
152
- )
153
- const entryIn = entry?.ports.find((p) => p.io === "in")
154
- if (entryIn) {
155
- childConnections.push({
156
- from_port_id: childStartOutPortId,
157
- to_port_id: entryIn.id,
158
- })
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
+ }
159
183
  }
160
184
  }
161
- 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) {
162
210
  childConnections.push({
163
- from_port_id: portIdMap.get(analysis.exitPortId) ?? analysis.exitPortId,
164
- to_port_id: childEndInPortId,
211
+ from_port_id: portIdMap.get(exit.exitPortId) ?? exit.exitPortId,
212
+ to_port_id: exit.endIn.id,
165
213
  })
166
214
  }
167
215
 
@@ -175,13 +223,38 @@ export const buildExtractPlan = (params: {
175
223
  selectedNodes.reduce((sum, n) => sum + n.position_y, 0) /
176
224
  selectedNodes.length,
177
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
+ }))
178
251
  const groupNode: GraphNode = {
179
252
  id: newId(),
180
253
  kind: "component",
181
254
  name,
182
255
  ports: [
183
256
  { id: newId(), io: "in", key: "in", name: "入力", schema: {} },
184
- { id: newId(), io: "out", key: "main", name: "出力", schema: {} },
257
+ ...groupOuts.map((o) => o.port),
185
258
  ],
186
259
  hooks: [],
187
260
  position_x: centroid.x,
@@ -189,12 +262,16 @@ export const buildExtractPlan = (params: {
189
262
  component: { component_loop_id: componentLoopId },
190
263
  }
191
264
  const groupIn = groupNode.ports.find((p) => p.io === "in")
192
- const groupMain = groupNode.ports.find((p) => p.io === "out")
193
- if (!groupIn || !groupMain) {
265
+ if (!groupIn) {
194
266
  return { ok: false, reason: "groupNode のポート生成に失敗しました" }
195
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
+ )
196
273
 
197
- // 親の置換:選択関連の線(内部・境界)を除き、境界を group の in/main へ繋ぎ直す
274
+ // 親の置換:選択関連の線(内部・境界)を除き、境界を group の in/対応する out へ繋ぎ直す
198
275
  const removed = new Set(
199
276
  [...analysis.internal, ...analysis.inbound, ...analysis.outbound].map(
200
277
  (c) => `${c.from_port_id}→${c.to_port_id}`,
@@ -204,14 +281,17 @@ export const buildExtractPlan = (params: {
204
281
  ...connections.filter(
205
282
  (c) => !removed.has(`${c.from_port_id}→${c.to_port_id}`),
206
283
  ),
207
- ...analysis.inbound.map((c) => ({
208
- from_port_id: c.from_port_id,
209
- to_port_id: groupIn.id,
210
- })),
211
- ...analysis.outbound.map((c) => ({
212
- from_port_id: groupMain.id,
213
- to_port_id: c.to_port_id,
214
- })),
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
+ }),
215
295
  ]
216
296
  const parentNodes = [...nodes.filter((n) => !selected.has(n.id)), groupNode]
217
297
 
@@ -219,6 +299,7 @@ export const buildExtractPlan = (params: {
219
299
  ok: true,
220
300
  childNodes,
221
301
  childConnections,
302
+ childEnd: resolvedChildEnd,
222
303
  groupNode,
223
304
  parentNodes,
224
305
  parentConnections,
@@ -1055,21 +1055,23 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
1055
1055
  ).toThrow(/component_loop_id/)
1056
1056
  })
1057
1057
 
1058
- it("ポート構成は in/main 固定(out key 違い・複数 out を拒否)", () => {
1059
- const badKey = {
1058
+ it("in in 1つ固定・out は複数可(子 end in 射影。コンポーネントの複数入口・複数出口.md)", () => {
1059
+ // in key 違いは拒否
1060
+ const badIn = {
1060
1061
  ...componentNode(),
1061
- ports: [port("in", "in"), port("out", "result")],
1062
+ ports: [port("in", "input"), port("out", "main")],
1062
1063
  }
1063
1064
  expect(() =>
1064
- saveGraphBodySchema.parse({ nodes: [badKey], connections: [] }),
1065
- ).toThrow(/component のポート構成/)
1065
+ saveGraphBodySchema.parse({ nodes: [badIn], connections: [] }),
1066
+ ).toThrow(/component の in ポートは in/)
1067
+ // out は key 自由・複数可(キーは保存時にサーバが子契約から焼き込む)
1066
1068
  const multiOut = {
1067
1069
  ...componentNode(),
1068
1070
  ports: [port("in", "in"), port("out", "main"), port("out", "sub")],
1069
1071
  }
1070
1072
  expect(() =>
1071
1073
  saveGraphBodySchema.parse({ nodes: [multiOut], connections: [] }),
1072
- ).toThrow(/component のポート構成/)
1074
+ ).not.toThrow()
1073
1075
  })
1074
1076
 
1075
1077
  it("component の in への接続は契約への適合を検証する(伝播型 ⊆ 契約)", () => {
package/_schemas/graph.ts CHANGED
@@ -247,9 +247,17 @@ export const graphNodeSchema = z.object({
247
247
  })
248
248
  export type GraphNode = z.infer<typeof graphNodeSchema>
249
249
 
250
+ // 手動ルートの折れ点(loop 座標系の絶対座標)。connection の waypoints に並べる
251
+ // (docs/tasks/wip/connection線ルートの手動編集(ウェイポイント).md)
252
+ export const waypointSchema = z.object({ x: z.number(), y: z.number() })
253
+ export type Waypoint = z.infer<typeof waypointSchema>
254
+
250
255
  export const graphConnectionSchema = z.object({
251
256
  from_port_id: z.uuid(),
252
257
  to_port_id: z.uuid(),
258
+ // 手動ルート:線の内部コーナーの座標列。省略=自動ルート。
259
+ // 「手動だが折れ無し」という状態は作らない(正規化で折れが消えたら省略へ戻す)ので最低1点
260
+ waypoints: z.array(waypointSchema).min(1).optional(),
253
261
  })
254
262
  export type GraphConnection = z.infer<typeof graphConnectionSchema>
255
263
 
@@ -147,19 +147,20 @@ const FIXED_PORT_KEYS: Record<string, { in: string[]; out: string[] }> = {
147
147
  wait: { in: ["in"], out: ["main"] },
148
148
  // start は in 1つ(run 入力契約)+ out main 1つの恒等パススルー
149
149
  start: { in: ["in"], out: ["main"] },
150
- // component は in(子 loop の start.in 契約)+ out main(end.out の戻り値)。
151
- // スキーマは保存時にサーバが子 loop の契約から焼き込む宣言型
152
- // (DERIVED/MIRROR には入れない=接続で潰さない)
153
- component: { in: ["in"], out: ["main"] },
154
150
  }
155
151
 
156
- const FREE_OUT_KINDS = new Set(["program", "ai"])
152
+ // component in(子 loop の start.in 契約)1つ+ out は子 end の in ポートの射影
153
+ // (複数可。end の in が1つなら互換のため main 1口)。スキーマ・構成とも保存時に
154
+ // サーバが子 loop の契約から焼き込む宣言型(DERIVED/MIRROR には入れない=接続で潰さない。
155
+ // コンポーネントの複数入口・複数出口.md)
156
+ const FREE_OUT_KINDS = new Set(["program", "ai", "component"])
157
157
 
158
158
  // out は自由(複数出口=分岐)。program は in の構成も固定(1口)だが、
159
159
  // ai は in も自由(追加・削除・key 自由。セッション再開は key ではなくポートの
160
160
  // resume 属性で決まる。ポート型固定の撤去とセッション再開のポート属性化.md)
161
161
  const FIXED_IN_KEYS: Record<string, string[]> = {
162
162
  program: ["in"],
163
+ component: ["in"],
163
164
  }
164
165
 
165
166
  export const fixedInKeysOf = (kind: string): string[] | null =>
@@ -24,11 +24,27 @@ export const duplicateSelection = (params) => {
24
24
  position_y: n.position_y + dy,
25
25
  }));
26
26
  // 両端が複製対象のポートである線(=選択内で完結する線)だけを張り替えて写す。
27
- // 外部との線(入口・出口)はコピーしない
27
+ // 外部との線(入口・出口)はコピーしない。手動ルート(waypoints)は
28
+ // ノードと同じ移動量で写し、調整済みの形を保つ
28
29
  const copiedConnections = connections.flatMap((c) => {
29
30
  const from = portIdMap.get(c.from_port_id);
30
31
  const to = portIdMap.get(c.to_port_id);
31
- return from && to ? [{ from_port_id: from, to_port_id: to }] : [];
32
+ return from && to
33
+ ? [
34
+ {
35
+ from_port_id: from,
36
+ to_port_id: to,
37
+ ...(c.waypoints
38
+ ? {
39
+ waypoints: c.waypoints.map((p) => ({
40
+ x: p.x + dx,
41
+ y: p.y + dy,
42
+ })),
43
+ }
44
+ : {}),
45
+ },
46
+ ]
47
+ : [];
32
48
  });
33
49
  return { nodes: copied, connections: copiedConnections };
34
50
  };
@@ -4,8 +4,8 @@ export type ExtractAnalysis = {
4
4
  reason: string;
5
5
  } | {
6
6
  ok: true;
7
- entryNodeId: string | null;
8
- exitPortId: string | null;
7
+ entryPortIds: string[];
8
+ exitPortIds: string[];
9
9
  internal: GraphConnection[];
10
10
  inbound: GraphConnection[];
11
11
  outbound: GraphConnection[];
@@ -14,6 +14,7 @@ export declare const analyzeExtractSelection: (nodes: GraphNode[], connections:
14
14
  export type ExtractPlan = {
15
15
  childNodes: GraphNode[];
16
16
  childConnections: GraphConnection[];
17
+ childEnd: GraphNode;
17
18
  groupNode: GraphNode;
18
19
  parentNodes: GraphNode[];
19
20
  parentConnections: GraphConnection[];
@@ -25,7 +26,7 @@ export declare const buildExtractPlan: (params: {
25
26
  name: string;
26
27
  componentLoopId: string;
27
28
  childStartOutPortId: string;
28
- childEndInPortId: string;
29
+ childEnd: GraphNode;
29
30
  newId: () => string;
30
31
  }) => {
31
32
  ok: false;
@@ -14,6 +14,7 @@ export const analyzeExtractSelection = (nodes, connections, selectedIds) => {
14
14
  };
15
15
  }
16
16
  const nodeIdByPortId = new Map(nodes.flatMap((n) => n.ports.map((p) => [p.id, n.id])));
17
+ const portById = new Map(nodes.flatMap((n) => n.ports.map((p) => [p.id, p])));
17
18
  const internal = [];
18
19
  const inbound = [];
19
20
  const outbound = [];
@@ -27,31 +28,33 @@ export const analyzeExtractSelection = (nodes, connections, selectedIds) => {
27
28
  else if (fromIn && !toIn)
28
29
  outbound.push(conn);
29
30
  }
30
- const entryNodeIds = new Set(inbound.map((c) => nodeIdByPortId.get(c.to_port_id) ?? ""));
31
- if (entryNodeIds.size > 1) {
31
+ // 入口ポート(重複排除・出現順)。条件は「全入口の型が同一」だけ:
32
+ // 起点(どの外部ポートから入るか)は見ない。ノードが前のノードを知らないのと
33
+ // 同じ思想を境界にも適用する(グループ化=入口の合流の宣言)
34
+ const entryPortIds = [...new Set(inbound.map((c) => c.to_port_id))];
35
+ const entrySchemas = new Set(entryPortIds.map((id) => JSON.stringify(portById.get(id)?.schema ?? {})));
36
+ if (entrySchemas.size > 1) {
32
37
  return {
33
38
  ok: false,
34
- reason: "外部からの入力が複数のノードに入っています(入口は1つにしてください)",
35
- };
36
- }
37
- const exitPortIds = new Set(outbound.map((c) => c.from_port_id));
38
- if (exitPortIds.size > 1) {
39
- return {
40
- ok: false,
41
- reason: "外部への出力が複数のポートから出ています(出口は1つにしてください)",
39
+ reason: "入口の型が揃っていません(同じ型の入口だけを1つのグループにまとめられます)",
42
40
  };
43
41
  }
42
+ // 出口は無条件(出発ポートごとに end の in = component の out になる)
43
+ const exitPortIds = [...new Set(outbound.map((c) => c.from_port_id))];
44
44
  return {
45
45
  ok: true,
46
- entryNodeId: [...entryNodeIds][0] ?? null,
47
- exitPortId: [...exitPortIds][0] ?? null,
46
+ entryPortIds,
47
+ exitPortIds,
48
48
  internal,
49
49
  inbound,
50
50
  outbound,
51
51
  };
52
52
  };
53
+ // 出口が1つのときの component out キー(既存コンポーネントとの互換。
54
+ // end の in が1つの loop は out main の1口として振る舞う)
55
+ const SINGLE_OUT_KEY = "main";
53
56
  export const buildExtractPlan = (params) => {
54
- const { nodes, connections, selectedIds, name, componentLoopId, childStartOutPortId, childEndInPortId, newId, } = params;
57
+ const { nodes, connections, selectedIds, name, componentLoopId, childStartOutPortId, childEnd, newId, } = params;
55
58
  const analysis = analyzeExtractSelection(nodes, connections, selectedIds);
56
59
  if (!analysis.ok)
57
60
  return analysis;
@@ -75,26 +78,60 @@ export const buildExtractPlan = (params) => {
75
78
  }),
76
79
  };
77
80
  });
81
+ const portById = new Map(nodes.flatMap((n) => n.ports.map((p) => [p.id, p])));
78
82
  const mapConn = (conn) => ({
79
83
  from_port_id: portIdMap.get(conn.from_port_id) ?? conn.from_port_id,
80
84
  to_port_id: portIdMap.get(conn.to_port_id) ?? conn.to_port_id,
81
85
  });
82
86
  const childConnections = analysis.internal.map(mapConn);
83
- // 境界の配線:start.main → 入口ノードの in/出口ポート → end.in
84
- if (analysis.entryNodeId !== null) {
85
- const entry = childNodes.find((n) => n.id === nodeIdMap.get(analysis.entryNodeId ?? ""));
86
- const entryIn = entry?.ports.find((p) => p.io === "in");
87
- if (entryIn) {
88
- childConnections.push({
89
- from_port_id: childStartOutPortId,
90
- to_port_id: entryIn.id,
91
- });
92
- }
87
+ // 境界の配線(入口):start.main → 各入口ポート(複数なら fan-out)
88
+ for (const entryPortId of analysis.entryPortIds) {
89
+ childConnections.push({
90
+ from_port_id: childStartOutPortId,
91
+ to_port_id: portIdMap.get(entryPortId) ?? entryPortId,
92
+ });
93
93
  }
94
- if (analysis.exitPortId !== null) {
94
+ // 境界の配線(出口):出発ポートごとに end in を1つ充てる。
95
+ // 1つ目は seed 済みの in を使い(名前を出口ポート名へ)、2つ目以降は追加する。
96
+ // component の out はこの in たちの射影(出口1つは互換のため main キー)
97
+ const seededEndIns = childEnd.ports.filter((p) => p.io === "in");
98
+ const endInKeys = new Set(seededEndIns.map((p) => p.key));
99
+ const nextEndInKey = () => {
100
+ for (let i = 2;; i++) {
101
+ const key = `in_${i}`;
102
+ if (!endInKeys.has(key)) {
103
+ endInKeys.add(key);
104
+ return key;
105
+ }
106
+ }
107
+ };
108
+ const exits = analysis.exitPortIds.map((exitPortId, i) => {
109
+ const exitName = portById.get(exitPortId)?.name ?? "出力";
110
+ const seeded = i === 0 ? seededEndIns[0] : undefined;
111
+ const endIn = seeded
112
+ ? { ...seeded, name: exitName }
113
+ : {
114
+ id: newId(),
115
+ io: "in",
116
+ key: nextEndInKey(),
117
+ name: exitName,
118
+ schema: {},
119
+ };
120
+ return { exitPortId, endIn };
121
+ });
122
+ const resolvedChildEnd = exits.length === 0
123
+ ? childEnd
124
+ : {
125
+ ...childEnd,
126
+ ports: [
127
+ ...exits.map((e) => e.endIn),
128
+ ...childEnd.ports.filter((p) => p.io !== "in"),
129
+ ],
130
+ };
131
+ for (const exit of exits) {
95
132
  childConnections.push({
96
- from_port_id: portIdMap.get(analysis.exitPortId) ?? analysis.exitPortId,
97
- to_port_id: childEndInPortId,
133
+ from_port_id: portIdMap.get(exit.exitPortId) ?? exit.exitPortId,
134
+ to_port_id: exit.endIn.id,
98
135
  });
99
136
  }
100
137
  // 親に置くノード(位置は選択の重心。ポートスキーマは保存時にサーバが契約から焼き込む)
@@ -105,13 +142,37 @@ export const buildExtractPlan = (params) => {
105
142
  y: selectedNodes.reduce((sum, n) => sum + n.position_y, 0) /
106
143
  selectedNodes.length,
107
144
  };
145
+ // out は出口ごと(キーは end の in の射影。出口1つ以下は互換の main 1口)
146
+ const groupOuts = exits.length <= 1
147
+ ? [
148
+ {
149
+ exitPortId: exits[0]?.exitPortId ?? null,
150
+ port: {
151
+ id: newId(),
152
+ io: "out",
153
+ key: SINGLE_OUT_KEY,
154
+ name: exits[0] ? exits[0].endIn.name : "出力",
155
+ schema: {},
156
+ },
157
+ },
158
+ ]
159
+ : exits.map((e) => ({
160
+ exitPortId: e.exitPortId,
161
+ port: {
162
+ id: newId(),
163
+ io: "out",
164
+ key: e.endIn.key,
165
+ name: e.endIn.name,
166
+ schema: {},
167
+ },
168
+ }));
108
169
  const groupNode = {
109
170
  id: newId(),
110
171
  kind: "component",
111
172
  name,
112
173
  ports: [
113
174
  { id: newId(), io: "in", key: "in", name: "入力", schema: {} },
114
- { id: newId(), io: "out", key: "main", name: "出力", schema: {} },
175
+ ...groupOuts.map((o) => o.port),
115
176
  ],
116
177
  hooks: [],
117
178
  position_x: centroid.x,
@@ -119,28 +180,32 @@ export const buildExtractPlan = (params) => {
119
180
  component: { component_loop_id: componentLoopId },
120
181
  };
121
182
  const groupIn = groupNode.ports.find((p) => p.io === "in");
122
- const groupMain = groupNode.ports.find((p) => p.io === "out");
123
- if (!groupIn || !groupMain) {
183
+ if (!groupIn) {
124
184
  return { ok: false, reason: "groupNode のポート生成に失敗しました" };
125
185
  }
126
- // 親の置換:選択関連の線(内部・境界)を除き、境界を group in/main へ繋ぎ直す
186
+ const groupOutByExitPortId = new Map(groupOuts
187
+ .filter((o) => o.exitPortId !== null)
188
+ .map((o) => [o.exitPortId, o.port]));
189
+ // 親の置換:選択関連の線(内部・境界)を除き、境界を group の in/対応する out へ繋ぎ直す
127
190
  const removed = new Set([...analysis.internal, ...analysis.inbound, ...analysis.outbound].map((c) => `${c.from_port_id}→${c.to_port_id}`));
128
191
  const parentConnections = [
129
192
  ...connections.filter((c) => !removed.has(`${c.from_port_id}→${c.to_port_id}`)),
130
- ...analysis.inbound.map((c) => ({
131
- from_port_id: c.from_port_id,
193
+ // 同一起点から複数入口へ入っていた線は group.in への1本に畳む(重複線を作らない)
194
+ ...[...new Set(analysis.inbound.map((c) => c.from_port_id))].map((from_port_id) => ({
195
+ from_port_id,
132
196
  to_port_id: groupIn.id,
133
197
  })),
134
- ...analysis.outbound.map((c) => ({
135
- from_port_id: groupMain.id,
136
- to_port_id: c.to_port_id,
137
- })),
198
+ ...analysis.outbound.flatMap((c) => {
199
+ const out = groupOutByExitPortId.get(c.from_port_id);
200
+ return out ? [{ from_port_id: out.id, to_port_id: c.to_port_id }] : [];
201
+ }),
138
202
  ];
139
203
  const parentNodes = [...nodes.filter((n) => !selected.has(n.id)), groupNode];
140
204
  return {
141
205
  ok: true,
142
206
  childNodes,
143
207
  childConnections,
208
+ childEnd: resolvedChildEnd,
144
209
  groupNode,
145
210
  parentNodes,
146
211
  parentConnections,
@@ -155,9 +155,18 @@ export declare const graphNodeSchema: z.ZodObject<{
155
155
  }, z.core.$strip>>;
156
156
  }, z.core.$strip>;
157
157
  export type GraphNode = z.infer<typeof graphNodeSchema>;
158
+ export declare const waypointSchema: z.ZodObject<{
159
+ x: z.ZodNumber;
160
+ y: z.ZodNumber;
161
+ }, z.core.$strip>;
162
+ export type Waypoint = z.infer<typeof waypointSchema>;
158
163
  export declare const graphConnectionSchema: z.ZodObject<{
159
164
  from_port_id: z.ZodUUID;
160
165
  to_port_id: z.ZodUUID;
166
+ waypoints: z.ZodOptional<z.ZodArray<z.ZodObject<{
167
+ x: z.ZodNumber;
168
+ y: z.ZodNumber;
169
+ }, z.core.$strip>>>;
161
170
  }, z.core.$strip>;
162
171
  export type GraphConnection = z.infer<typeof graphConnectionSchema>;
163
172
  export declare const saveGraphBodySchema: z.ZodObject<{
@@ -240,6 +249,10 @@ export declare const saveGraphBodySchema: z.ZodObject<{
240
249
  connections: z.ZodArray<z.ZodObject<{
241
250
  from_port_id: z.ZodUUID;
242
251
  to_port_id: z.ZodUUID;
252
+ waypoints: z.ZodOptional<z.ZodArray<z.ZodObject<{
253
+ x: z.ZodNumber;
254
+ y: z.ZodNumber;
255
+ }, z.core.$strip>>>;
243
256
  }, z.core.$strip>>;
244
257
  refs: z.ZodDefault<z.ZodObject<{
245
258
  type: z.ZodLiteral<"object">;
@@ -193,9 +193,15 @@ export const graphNodeSchema = z.object({
193
193
  human: humanConfigSchema.optional(),
194
194
  component: componentConfigSchema.optional(),
195
195
  });
196
+ // 手動ルートの折れ点(loop 座標系の絶対座標)。connection の waypoints に並べる
197
+ // (docs/tasks/wip/connection線ルートの手動編集(ウェイポイント).md)
198
+ export const waypointSchema = z.object({ x: z.number(), y: z.number() });
196
199
  export const graphConnectionSchema = z.object({
197
200
  from_port_id: z.uuid(),
198
201
  to_port_id: z.uuid(),
202
+ // 手動ルート:線の内部コーナーの座標列。省略=自動ルート。
203
+ // 「手動だが折れ無し」という状態は作らない(正規化で折れが消えたら省略へ戻す)ので最低1点
204
+ waypoints: z.array(waypointSchema).min(1).optional(),
199
205
  });
200
206
  // PUT /tenants/:tenantId/loops/:loopId/graph のボディ(グラフ一括保存)。
201
207
  // refs=ループ変数の定義スキーマ(loops.refs)。回路と同じ「エディタの保存」で全置換する。
@@ -125,17 +125,18 @@ const FIXED_PORT_KEYS = {
125
125
  wait: { in: ["in"], out: ["main"] },
126
126
  // start は in 1つ(run 入力契約)+ out main 1つの恒等パススルー
127
127
  start: { in: ["in"], out: ["main"] },
128
- // component は in(子 loop の start.in 契約)+ out main(end.out の戻り値)。
129
- // スキーマは保存時にサーバが子 loop の契約から焼き込む宣言型
130
- // (DERIVED/MIRROR には入れない=接続で潰さない)
131
- component: { in: ["in"], out: ["main"] },
132
128
  };
133
- const FREE_OUT_KINDS = new Set(["program", "ai"]);
129
+ // component in(子 loop の start.in 契約)1つ+ out は子 end の in ポートの射影
130
+ // (複数可。end の in が1つなら互換のため main 1口)。スキーマ・構成とも保存時に
131
+ // サーバが子 loop の契約から焼き込む宣言型(DERIVED/MIRROR には入れない=接続で潰さない。
132
+ // コンポーネントの複数入口・複数出口.md)
133
+ const FREE_OUT_KINDS = new Set(["program", "ai", "component"]);
134
134
  // out は自由(複数出口=分岐)。program は in の構成も固定(1口)だが、
135
135
  // ai は in も自由(追加・削除・key 自由。セッション再開は key ではなくポートの
136
136
  // resume 属性で決まる。ポート型固定の撤去とセッション再開のポート属性化.md)
137
137
  const FIXED_IN_KEYS = {
138
138
  program: ["in"],
139
+ component: ["in"],
139
140
  };
140
141
  export const fixedInKeysOf = (kind) => FIXED_IN_KEYS[kind] ?? null;
141
142
  // config.json(manifest)の inputs 検査(適合なら null)。同じルールを二重に持たないため
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mawaru/sdk",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {