@mawaru/sdk 0.14.1 → 0.16.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
@@ -230,6 +230,29 @@ export const graphNodeHookSchema = z.object({
230
230
  export type GraphNodeHook = z.infer<typeof graphNodeHookSchema>
231
231
 
232
232
  // 新規ノードの id・ポートの id もクライアントが採番する(一時IDの解決を不要にする)
233
+ // ノードの表示アイコン:プリセット・絵文字・画像 URL のいずれか一つ。
234
+ // 未設定(省略)=既定の kind アイコン(docs/tasks/wip/ノードのアイコン(プログラム任意設定・HUMAN担当者の自動表示).md)。
235
+ // preset は契約上不透明な文字列(承認ビューと同じ方式):カタログはフロントが
236
+ // 表示時に参照し、無い key は既定グリフへフォールバックする
237
+ export const nodeIconSchema = z.union([
238
+ z.strictObject({ preset: z.string().min(1) }),
239
+ // ZWJ 合字の絵文字を許容する長さ(1グリフでも code unit は最大10超になりうる)
240
+ z.strictObject({ emoji: z.string().min(1).max(16) }),
241
+ z.strictObject({ image_url: z.url() }),
242
+ ])
243
+ export type NodeIcon = z.infer<typeof nodeIconSchema>
244
+
245
+ // mawaru 提供のプリセットアイコンのカタログ(SVG 実体はフロントに同梱)
246
+ export const NODE_ICON_PRESETS = [
247
+ { key: "github", name: "GitHub" },
248
+ { key: "slack", name: "Slack" },
249
+ { key: "gmail", name: "Gmail" },
250
+ { key: "google-sheets", name: "Google スプレッドシート" },
251
+ { key: "google-drive", name: "Google Drive" },
252
+ { key: "notion", name: "Notion" },
253
+ ] as const
254
+ export type NodeIconPresetKey = (typeof NODE_ICON_PRESETS)[number]["key"]
255
+
233
256
  export const graphNodeSchema = z.object({
234
257
  id: z.uuid(),
235
258
  kind: graphNodeKindSchema,
@@ -238,6 +261,8 @@ export const graphNodeSchema = z.object({
238
261
  hooks: z.array(graphNodeHookSchema).default([]),
239
262
  position_x: z.number(),
240
263
  position_y: z.number(),
264
+ // 表示アイコン(全 kind 共通の任意設定。human はフロントが担当から導出するので通常設定しない)
265
+ icon: nodeIconSchema.optional(),
241
266
  // kind 別設定(自 kind のときだけ意味を持つ。省略時は保存側がデフォルトを補う)
242
267
  wait: waitConfigSchema.optional(),
243
268
  program: programConfigSchema.optional(),
@@ -247,9 +272,17 @@ export const graphNodeSchema = z.object({
247
272
  })
248
273
  export type GraphNode = z.infer<typeof graphNodeSchema>
249
274
 
275
+ // 手動ルートの折れ点(loop 座標系の絶対座標)。connection の waypoints に並べる
276
+ // (docs/tasks/wip/connection線ルートの手動編集(ウェイポイント).md)
277
+ export const waypointSchema = z.object({ x: z.number(), y: z.number() })
278
+ export type Waypoint = z.infer<typeof waypointSchema>
279
+
250
280
  export const graphConnectionSchema = z.object({
251
281
  from_port_id: z.uuid(),
252
282
  to_port_id: z.uuid(),
283
+ // 手動ルート:線の内部コーナーの座標列。省略=自動ルート。
284
+ // 「手動だが折れ無し」という状態は作らない(正規化で折れが消えたら省略へ戻す)ので最低1点
285
+ waypoints: z.array(waypointSchema).min(1).optional(),
253
286
  })
254
287
  export type GraphConnection = z.infer<typeof graphConnectionSchema>
255
288
 
@@ -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,
@@ -78,6 +78,34 @@ export declare const graphNodeHookSchema: z.ZodObject<{
78
78
  on_signal: z.ZodDefault<z.ZodBoolean>;
79
79
  }, z.core.$strip>;
80
80
  export type GraphNodeHook = z.infer<typeof graphNodeHookSchema>;
81
+ export declare const nodeIconSchema: z.ZodUnion<readonly [z.ZodObject<{
82
+ preset: z.ZodString;
83
+ }, z.core.$strict>, z.ZodObject<{
84
+ emoji: z.ZodString;
85
+ }, z.core.$strict>, z.ZodObject<{
86
+ image_url: z.ZodURL;
87
+ }, z.core.$strict>]>;
88
+ export type NodeIcon = z.infer<typeof nodeIconSchema>;
89
+ export declare const NODE_ICON_PRESETS: readonly [{
90
+ readonly key: "github";
91
+ readonly name: "GitHub";
92
+ }, {
93
+ readonly key: "slack";
94
+ readonly name: "Slack";
95
+ }, {
96
+ readonly key: "gmail";
97
+ readonly name: "Gmail";
98
+ }, {
99
+ readonly key: "google-sheets";
100
+ readonly name: "Google スプレッドシート";
101
+ }, {
102
+ readonly key: "google-drive";
103
+ readonly name: "Google Drive";
104
+ }, {
105
+ readonly key: "notion";
106
+ readonly name: "Notion";
107
+ }];
108
+ export type NodeIconPresetKey = (typeof NODE_ICON_PRESETS)[number]["key"];
81
109
  export declare const graphNodeSchema: z.ZodObject<{
82
110
  id: z.ZodUUID;
83
111
  kind: z.ZodEnum<{
@@ -117,6 +145,13 @@ export declare const graphNodeSchema: z.ZodObject<{
117
145
  }, z.core.$strip>>>;
118
146
  position_x: z.ZodNumber;
119
147
  position_y: z.ZodNumber;
148
+ icon: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
149
+ preset: z.ZodString;
150
+ }, z.core.$strict>, z.ZodObject<{
151
+ emoji: z.ZodString;
152
+ }, z.core.$strict>, z.ZodObject<{
153
+ image_url: z.ZodURL;
154
+ }, z.core.$strict>]>>;
120
155
  wait: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
121
156
  wait_type: z.ZodLiteral<"duration">;
122
157
  duration_seconds: z.ZodNumber;
@@ -155,9 +190,18 @@ export declare const graphNodeSchema: z.ZodObject<{
155
190
  }, z.core.$strip>>;
156
191
  }, z.core.$strip>;
157
192
  export type GraphNode = z.infer<typeof graphNodeSchema>;
193
+ export declare const waypointSchema: z.ZodObject<{
194
+ x: z.ZodNumber;
195
+ y: z.ZodNumber;
196
+ }, z.core.$strip>;
197
+ export type Waypoint = z.infer<typeof waypointSchema>;
158
198
  export declare const graphConnectionSchema: z.ZodObject<{
159
199
  from_port_id: z.ZodUUID;
160
200
  to_port_id: z.ZodUUID;
201
+ waypoints: z.ZodOptional<z.ZodArray<z.ZodObject<{
202
+ x: z.ZodNumber;
203
+ y: z.ZodNumber;
204
+ }, z.core.$strip>>>;
161
205
  }, z.core.$strip>;
162
206
  export type GraphConnection = z.infer<typeof graphConnectionSchema>;
163
207
  export declare const saveGraphBodySchema: z.ZodObject<{
@@ -200,6 +244,13 @@ export declare const saveGraphBodySchema: z.ZodObject<{
200
244
  }, z.core.$strip>>>;
201
245
  position_x: z.ZodNumber;
202
246
  position_y: z.ZodNumber;
247
+ icon: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
248
+ preset: z.ZodString;
249
+ }, z.core.$strict>, z.ZodObject<{
250
+ emoji: z.ZodString;
251
+ }, z.core.$strict>, z.ZodObject<{
252
+ image_url: z.ZodURL;
253
+ }, z.core.$strict>]>>;
203
254
  wait: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
204
255
  wait_type: z.ZodLiteral<"duration">;
205
256
  duration_seconds: z.ZodNumber;
@@ -240,6 +291,10 @@ export declare const saveGraphBodySchema: z.ZodObject<{
240
291
  connections: z.ZodArray<z.ZodObject<{
241
292
  from_port_id: z.ZodUUID;
242
293
  to_port_id: z.ZodUUID;
294
+ waypoints: z.ZodOptional<z.ZodArray<z.ZodObject<{
295
+ x: z.ZodNumber;
296
+ y: z.ZodNumber;
297
+ }, z.core.$strip>>>;
243
298
  }, z.core.$strip>>;
244
299
  refs: z.ZodDefault<z.ZodObject<{
245
300
  type: z.ZodLiteral<"object">;
@@ -178,6 +178,25 @@ export const graphNodeHookSchema = z.object({
178
178
  on_signal: z.boolean().default(false),
179
179
  });
180
180
  // 新規ノードの id・ポートの id もクライアントが採番する(一時IDの解決を不要にする)
181
+ // ノードの表示アイコン:プリセット・絵文字・画像 URL のいずれか一つ。
182
+ // 未設定(省略)=既定の kind アイコン(docs/tasks/wip/ノードのアイコン(プログラム任意設定・HUMAN担当者の自動表示).md)。
183
+ // preset は契約上不透明な文字列(承認ビューと同じ方式):カタログはフロントが
184
+ // 表示時に参照し、無い key は既定グリフへフォールバックする
185
+ export const nodeIconSchema = z.union([
186
+ z.strictObject({ preset: z.string().min(1) }),
187
+ // ZWJ 合字の絵文字を許容する長さ(1グリフでも code unit は最大10超になりうる)
188
+ z.strictObject({ emoji: z.string().min(1).max(16) }),
189
+ z.strictObject({ image_url: z.url() }),
190
+ ]);
191
+ // mawaru 提供のプリセットアイコンのカタログ(SVG 実体はフロントに同梱)
192
+ export const NODE_ICON_PRESETS = [
193
+ { key: "github", name: "GitHub" },
194
+ { key: "slack", name: "Slack" },
195
+ { key: "gmail", name: "Gmail" },
196
+ { key: "google-sheets", name: "Google スプレッドシート" },
197
+ { key: "google-drive", name: "Google Drive" },
198
+ { key: "notion", name: "Notion" },
199
+ ];
181
200
  export const graphNodeSchema = z.object({
182
201
  id: z.uuid(),
183
202
  kind: graphNodeKindSchema,
@@ -186,6 +205,8 @@ export const graphNodeSchema = z.object({
186
205
  hooks: z.array(graphNodeHookSchema).default([]),
187
206
  position_x: z.number(),
188
207
  position_y: z.number(),
208
+ // 表示アイコン(全 kind 共通の任意設定。human はフロントが担当から導出するので通常設定しない)
209
+ icon: nodeIconSchema.optional(),
189
210
  // kind 別設定(自 kind のときだけ意味を持つ。省略時は保存側がデフォルトを補う)
190
211
  wait: waitConfigSchema.optional(),
191
212
  program: programConfigSchema.optional(),
@@ -193,9 +214,15 @@ export const graphNodeSchema = z.object({
193
214
  human: humanConfigSchema.optional(),
194
215
  component: componentConfigSchema.optional(),
195
216
  });
217
+ // 手動ルートの折れ点(loop 座標系の絶対座標)。connection の waypoints に並べる
218
+ // (docs/tasks/wip/connection線ルートの手動編集(ウェイポイント).md)
219
+ export const waypointSchema = z.object({ x: z.number(), y: z.number() });
196
220
  export const graphConnectionSchema = z.object({
197
221
  from_port_id: z.uuid(),
198
222
  to_port_id: z.uuid(),
223
+ // 手動ルート:線の内部コーナーの座標列。省略=自動ルート。
224
+ // 「手動だが折れ無し」という状態は作らない(正規化で折れが消えたら省略へ戻す)ので最低1点
225
+ waypoints: z.array(waypointSchema).min(1).optional(),
199
226
  });
200
227
  // PUT /tenants/:tenantId/loops/:loopId/graph のボディ(グラフ一括保存)。
201
228
  // 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.16.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {