@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.
- package/_schemas/duplicate-selection.test.ts +20 -0
- package/_schemas/duplicate-selection.ts +18 -2
- package/_schemas/extract-component.test.ts +156 -28
- package/_schemas/extract-component.ts +133 -52
- package/_schemas/graph.test.ts +8 -6
- package/_schemas/graph.ts +8 -0
- package/_schemas/port-spec.ts +6 -5
- package/dist/_schemas/duplicate-selection.js +18 -2
- package/dist/_schemas/extract-component.d.ts +4 -3
- package/dist/_schemas/extract-component.js +101 -36
- package/dist/_schemas/graph.d.ts +13 -0
- package/dist/_schemas/graph.js +6 -0
- package/dist/_schemas/port-spec.js +6 -5
- package/package.json +1 -1
|
@@ -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
|
|
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 = (
|
|
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
|
|
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
|
|
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.
|
|
71
|
-
expect(result.
|
|
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("
|
|
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.
|
|
83
|
-
expect(result.
|
|
86
|
+
expect(result.entryPortIds).toEqual([])
|
|
87
|
+
expect(result.exitPortIds).toEqual([])
|
|
84
88
|
})
|
|
85
89
|
|
|
86
|
-
it("
|
|
90
|
+
it("入口が複数でも型が同じなら成立する(同型の合流)", () => {
|
|
87
91
|
const { a, b, c, d, nodes, connections } = chain()
|
|
88
|
-
// A→B
|
|
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("
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
130
|
+
if (!ok.ok) throw new Error(ok.reason)
|
|
131
|
+
expect(ok.exitPortIds).toEqual([outOf(c).id])
|
|
111
132
|
})
|
|
112
133
|
|
|
113
|
-
it("1
|
|
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.
|
|
118
|
-
expect(result.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
189
|
-
const { plan, childStartOutPortId,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
15
|
-
|
|
16
|
-
// 外部への出力の出発ポート(同一ポートからの fan-out は1
|
|
17
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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 (
|
|
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
|
-
"
|
|
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
|
-
|
|
79
|
-
|
|
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 →
|
|
95
|
+
// 内部接続(新 id)+ start.main → 各入口/各出口 → end の対応する in
|
|
90
96
|
childConnections: GraphConnection[]
|
|
91
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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(
|
|
164
|
-
to_port_id:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|
package/_schemas/graph.test.ts
CHANGED
|
@@ -1055,21 +1055,23 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
|
|
|
1055
1055
|
).toThrow(/component_loop_id/)
|
|
1056
1056
|
})
|
|
1057
1057
|
|
|
1058
|
-
it("
|
|
1059
|
-
|
|
1058
|
+
it("in は in 1つ固定・out は複数可(子 end の in 射影。コンポーネントの複数入口・複数出口.md)", () => {
|
|
1059
|
+
// in の key 違いは拒否
|
|
1060
|
+
const badIn = {
|
|
1060
1061
|
...componentNode(),
|
|
1061
|
-
ports: [port("in", "
|
|
1062
|
+
ports: [port("in", "input"), port("out", "main")],
|
|
1062
1063
|
}
|
|
1063
1064
|
expect(() =>
|
|
1064
|
-
saveGraphBodySchema.parse({ nodes: [
|
|
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(
|
|
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
|
|
package/_schemas/port-spec.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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: "
|
|
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
|
-
|
|
47
|
-
|
|
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,
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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(
|
|
97
|
-
to_port_id:
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
if (!groupIn || !groupMain) {
|
|
183
|
+
if (!groupIn) {
|
|
124
184
|
return { ok: false, reason: "groupNode のポート生成に失敗しました" };
|
|
125
185
|
}
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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.
|
|
135
|
-
|
|
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,
|
package/dist/_schemas/graph.d.ts
CHANGED
|
@@ -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">;
|
package/dist/_schemas/graph.js
CHANGED
|
@@ -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
|
-
|
|
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)。同じルールを二重に持たないため
|