@mawaru/sdk 0.11.0 → 0.14.1

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.
Files changed (43) hide show
  1. package/_schemas/ai-manifest.test.ts +54 -59
  2. package/_schemas/ai-manifest.ts +38 -23
  3. package/_schemas/duplicate-selection.test.ts +235 -0
  4. package/_schemas/duplicate-selection.ts +48 -0
  5. package/_schemas/extract-component.test.ts +51 -2
  6. package/_schemas/extract-component.ts +4 -2
  7. package/_schemas/graph.test.ts +105 -86
  8. package/_schemas/graph.ts +20 -77
  9. package/_schemas/node.ts +4 -0
  10. package/_schemas/port-spec.test.ts +194 -113
  11. package/_schemas/port-spec.ts +95 -107
  12. package/dist/_cli/credentials.d.ts +14 -0
  13. package/dist/_cli/credentials.js +81 -0
  14. package/dist/_cli/login.d.ts +5 -0
  15. package/dist/_cli/login.js +82 -0
  16. package/dist/_cli/resumeSession.d.ts +21 -0
  17. package/dist/_cli/resumeSession.js +125 -0
  18. package/dist/_cli/sessionCommand.d.ts +3 -0
  19. package/dist/_cli/sessionCommand.js +55 -0
  20. package/dist/_schemas/ai-manifest.d.ts +12 -1
  21. package/dist/_schemas/ai-manifest.js +35 -20
  22. package/dist/_schemas/duplicate-selection.d.ts +12 -0
  23. package/dist/_schemas/duplicate-selection.js +34 -0
  24. package/dist/_schemas/extract-component.js +4 -2
  25. package/dist/_schemas/graph.d.ts +14 -12
  26. package/dist/_schemas/graph.js +21 -60
  27. package/dist/_schemas/hook-manifest.d.ts +2 -2
  28. package/dist/_schemas/node.d.ts +3 -2
  29. package/dist/_schemas/node.js +4 -0
  30. package/dist/_schemas/port-spec.d.ts +11 -6
  31. package/dist/_schemas/port-spec.js +72 -97
  32. package/dist/cli.js +50 -5
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/init.js +1 -1
  36. package/dist/typegen.js +6 -6
  37. package/docs/development.md +5 -1
  38. package/index.ts +1 -0
  39. package/package.json +11 -18
  40. package/skills/create-loop/SKILL.md +1 -1
  41. package/templates/CLAUDE.md +1 -1
  42. package/templates/echo/main.ts +1 -1
  43. package/templates/mawaru-runner.yml +1 -1
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { AI_MODELS, aiManifestSchema } from "./ai-manifest.js"
3
- import { envelopeSchemaOf, instructionSchema } from "./port-spec.js"
3
+ import { instructionSchema } from "./port-spec.js"
4
4
 
5
- const envelope = envelopeSchemaOf({ type: "string" })
5
+ const textOutput = { type: "string" }
6
6
 
7
7
  const valid = {
8
8
  name: "返信下書き",
@@ -11,8 +11,8 @@ const valid = {
11
11
  model: "claude-opus-4-8",
12
12
  skills: ["answer-faq"],
13
13
  env: ["ANTHROPIC_API_KEY"],
14
- inputs: { in: {} },
15
- outputs: { main: envelope },
14
+ inputs: { in: { schema: {} } },
15
+ outputs: { main: textOutput },
16
16
  }
17
17
 
18
18
  describe("aiManifestSchema", () => {
@@ -28,8 +28,8 @@ describe("aiManifestSchema", () => {
28
28
  it("name / description / model / skills / env は省略できる(既定値が補われる)", () => {
29
29
  const manifest = aiManifestSchema.parse({
30
30
  prompt: valid.prompt,
31
- inputs: { in: {} },
32
- outputs: { main: envelope },
31
+ inputs: { in: { schema: {} } },
32
+ outputs: { main: textOutput },
33
33
  })
34
34
  expect(manifest.name).toBeUndefined()
35
35
  expect(manifest.model).toBe("claude-opus-5")
@@ -56,90 +56,85 @@ describe("aiManifestSchema", () => {
56
56
  ).toThrow()
57
57
  })
58
58
 
59
- it('inputs は "in" 必須・instruction は任意(kind が固定する2口)', () => {
60
- // in は必須
59
+ it("inputs は key 自由・1件以上(in / instruction の固定構成は廃止)", () => {
60
+ // 0件は拒否
61
61
  expect(() => aiManifestSchema.parse({ ...valid, inputs: {} })).toThrow()
62
+ // key は自由(旧固定の in / instruction 以外も宣言できる)
62
63
  expect(() =>
63
- aiManifestSchema.parse({ ...valid, inputs: { data: {} } }),
64
- ).toThrow()
65
- // 固定ポートの instruction も宣言に載る(ノードの現在の姿を書き出すため)
66
- expect(
67
64
  aiManifestSchema.parse({
68
65
  ...valid,
69
- inputs: { in: {}, instruction: instructionSchema() },
70
- }).inputs.instruction,
71
- ).toEqual(instructionSchema())
72
- // in を2口に分ける前に同期された config.json(in だけ)もそのまま読める
66
+ inputs: { task: { schema: {} }, feedback: { schema: {} } },
67
+ }),
68
+ ).not.toThrow()
69
+ // key はポート key の規則(英小文字始まり・英数字と _)
70
+ for (const key of ["Bad", "1st", "a-b", ""]) {
71
+ expect(() =>
72
+ aiManifestSchema.parse({ ...valid, inputs: { [key]: { schema: {} } } }),
73
+ ).toThrow()
74
+ }
75
+ })
76
+
77
+ it("inputs は { schema, resume } の新形式で宣言でき、resume が読める", () => {
78
+ const parsed = aiManifestSchema.parse({
79
+ ...valid,
80
+ inputs: {
81
+ in: { schema: { type: "object" } },
82
+ instruction: { schema: instructionSchema(), resume: true },
83
+ },
84
+ })
85
+ expect(parsed.inputs.in).toEqual({ schema: { type: "object" } })
86
+ expect(parsed.inputs.instruction?.schema).toEqual(instructionSchema())
87
+ expect(parsed.inputs.instruction?.resume).toBe(true)
88
+ })
89
+
90
+ it("旧形式(値が素の JSON Schema)は { schema: 値 } として読める", () => {
91
+ const legacy = aiManifestSchema.parse({
92
+ ...valid,
93
+ inputs: { in: { type: "object" }, instruction: instructionSchema() },
94
+ })
95
+ expect(legacy.inputs.in).toEqual({ schema: { type: "object" } })
96
+ expect(legacy.inputs.instruction).toEqual({
97
+ schema: instructionSchema(),
98
+ })
99
+ // 未宣言 {} も旧形式として { schema: {} } になる
73
100
  expect(
74
- Object.keys(
75
- aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs,
76
- ),
77
- ).toEqual(["in"])
78
- // kind が固定しない key は宣言できない
79
- expect(() =>
80
- aiManifestSchema.parse({ ...valid, inputs: { in: {}, extra: {} } }),
81
- ).toThrow()
101
+ aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs.in,
102
+ ).toEqual({ schema: {} })
82
103
  })
83
104
 
84
105
  it("outputs は1件以上・key 自由(AI の複数出口=分岐)", () => {
85
106
  expect(() => aiManifestSchema.parse({ ...valid, outputs: {} })).toThrow()
86
107
  expect(() =>
87
- aiManifestSchema.parse({ ...valid, outputs: { draft: envelope } }),
108
+ aiManifestSchema.parse({ ...valid, outputs: { reply: textOutput } }),
88
109
  ).not.toThrow()
89
110
  expect(() =>
90
111
  aiManifestSchema.parse({
91
112
  ...valid,
92
- outputs: { spam: envelope, sales: envelope, support: envelope },
113
+ outputs: { spam: textOutput, sales: textOutput, support: textOutput },
93
114
  }),
94
115
  ).not.toThrow()
95
116
  })
96
117
 
97
- it("outputs は全出口に封筒型 { data, description } を強制する(data は任意の型)", () => {
118
+ it("outputs は任意の JSON Schema を受け付ける(形の強制はしない)", () => {
98
119
  for (const schema of [
99
120
  {}, // any
100
121
  { type: "string" },
101
- { type: "object", properties: { text: { type: "string" } } }, // data キーなし
102
- {
103
- type: "object",
104
- properties: { data: { type: "string" } }, // description なし
105
- required: ["data"],
106
- },
122
+ { type: "object", properties: { text: { type: "string" } } },
123
+ { type: "array", items: { type: "number" } },
107
124
  {
125
+ // かつて強制していた { data, description } もただの object として valid
108
126
  type: "object",
109
127
  properties: {
110
128
  data: { type: "string" },
111
- description: { type: "string" }, // description が optional
129
+ description: { type: "string" },
112
130
  },
113
- required: ["data"],
131
+ required: ["data", "description"],
114
132
  },
115
133
  ]) {
116
134
  expect(() =>
117
135
  aiManifestSchema.parse({ ...valid, outputs: { main: schema } }),
118
- ).toThrow()
119
- // 妥当な出口に混ざっていても、封筒型でない出口があれば弾く
120
- expect(() =>
121
- aiManifestSchema.parse({
122
- ...valid,
123
- outputs: { main: envelope, other: schema },
124
- }),
125
- ).toThrow()
136
+ ).not.toThrow()
126
137
  }
127
- // data が任意の構造(カタログ外の object)でも通る
128
- expect(() =>
129
- aiManifestSchema.parse({
130
- ...valid,
131
- outputs: {
132
- main: envelopeSchemaOf({
133
- type: "object",
134
- properties: {
135
- title: { type: "string" },
136
- count: { type: "number" },
137
- },
138
- required: ["title"],
139
- }),
140
- },
141
- }),
142
- ).not.toThrow()
143
138
  })
144
139
 
145
140
  it("ディレクトリ名として不正な skill 名を拒否する", () => {
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod"
2
- import { jsonSchemaSchema } from "./node.js"
3
- import { isAiEnvelopeSchema, manifestInKeysViolation } from "./port-spec.js"
2
+ import { jsonSchemaSchema, PORT_KEY_PATTERN } from "./node.js"
4
3
 
5
4
  // AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)。
6
5
  // エイリアス("opus" 等)は採らずバージョンを固定する:実行のたびに黙って別モデルへ
@@ -15,6 +14,23 @@ export const aiModelSchema = z.enum(AI_MODELS)
15
14
  export type AiModel = z.infer<typeof aiModelSchema>
16
15
  export const DEFAULT_AI_MODEL = "claude-opus-5" satisfies AiModel
17
16
 
17
+ // 入力口の宣言:スキーマ+実行属性。resume: true の口から入力が届いた実行は
18
+ // 同じノードの直近セッションを復元して再開する(backend が ports.resume へ同期して
19
+ // payload 発行時に参照する。ポート型固定の撤去とセッション再開のポート属性化.md)
20
+ export const aiInputDeclSchema = z.object({
21
+ schema: jsonSchemaSchema,
22
+ resume: z.boolean().optional(),
23
+ })
24
+ export type AiInputDecl = z.infer<typeof aiInputDeclSchema>
25
+
26
+ // 旧形式(値が素の JSON Schema)は { schema: 値 } として読む。判別は「schema キーを
27
+ // 持つか」:JSON Schema のトップレベルに "schema" キーは現れない(メタスキーマ参照は
28
+ // "$schema")ので実用上一意。書き戻し(backend の syncNodeConfigs)は常に新形式で書く
29
+ const aiInputValueSchema = z.union([
30
+ aiInputDeclSchema,
31
+ jsonSchemaSchema.transform((schema): AiInputDecl => ({ schema })),
32
+ ])
33
+
18
34
  // nodes/ai/<dir>/config.json(実行repoの構造見直し)。AI ノードの定義(prompt/model/skills/
19
35
  // 入出力スキーマ/env)は repo 側が正で、エディタが取り込んで ports を自動生成する
20
36
  // (実行時の正は ports テーブルのまま。プログラム規約 v2 と同じ関係)。
@@ -42,38 +58,37 @@ export const aiManifestSchema = z
42
58
  // 実行中の経過(発話・ツール呼び出し)を Actions ログへ逐次出すデバッグフラグ。
43
59
  // 通常時は config.json にキーを書かない(インスペクタも false ならキーを消す)
44
60
  verbose: z.boolean().optional(),
45
- inputs: z.record(z.string(), jsonSchemaSchema),
61
+ inputs: z.record(z.string(), aiInputValueSchema),
46
62
  outputs: z.record(z.string(), jsonSchemaSchema),
47
63
  })
48
64
  .superRefine((manifest, ctx) => {
49
- // in の構成は kind が固定する(ai は上流データの in 差し戻しの instruction の2口)。
50
- // ルールの正は port-spec 側で、ここは参照するだけ
51
- const inViolation = manifestInKeysViolation(
52
- "ai",
53
- Object.keys(manifest.inputs),
54
- )
55
- if (inViolation) {
56
- ctx.addIssue({ code: "custom", path: ["inputs"], message: inViolation })
57
- }
58
- // outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)
59
- if (Object.keys(manifest.outputs).length < 1) {
65
+ // inputs は1件以上・key 自由(in / instruction の固定構成は廃止。
66
+ // セッション再開は key ではなく resume 属性で決まる)
67
+ const inKeys = Object.keys(manifest.inputs)
68
+ if (inKeys.length < 1) {
60
69
  ctx.addIssue({
61
70
  code: "custom",
62
- path: ["outputs"],
63
- message: "outputs は1件以上宣言してください",
71
+ path: ["inputs"],
72
+ message: "inputs は1件以上宣言してください",
64
73
  })
65
74
  }
66
- // Human 承認 UI が依存する封筒型 { data: T, description: string } を全出口に強制
67
- // (どの出口も human に接続できる、という AI ノードの不変条件。saveGraph と同じ判定。
68
- // data=T は任意の JSON Schema)
69
- for (const [key, schema] of Object.entries(manifest.outputs)) {
70
- if (!isAiEnvelopeSchema(schema)) {
75
+ for (const key of inKeys) {
76
+ if (!PORT_KEY_PATTERN.test(key)) {
71
77
  ctx.addIssue({
72
78
  code: "custom",
73
- path: ["outputs", key],
74
- message: `outputs.${key} は封筒型 { data, description } で宣言してください(両方必須・description は string)`,
79
+ path: ["inputs", key],
80
+ message: `inputs の key が不正です: ${key}(英小文字始まり・英数字と _ のみ)`,
75
81
  })
76
82
  }
77
83
  }
84
+ // outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)。
85
+ // スキーマは任意の JSON Schema(形の強制はしない)
86
+ if (Object.keys(manifest.outputs).length < 1) {
87
+ ctx.addIssue({
88
+ code: "custom",
89
+ path: ["outputs"],
90
+ message: "outputs は1件以上宣言してください",
91
+ })
92
+ }
78
93
  })
79
94
  export type AiManifest = z.infer<typeof aiManifestSchema>
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { duplicateSelection } from "./duplicate-selection.js"
3
+ import type { GraphNode } from "./graph.js"
4
+ import { RUN_STARTER } from "./graph.js"
5
+ import type { GraphPort } from "./node.js"
6
+
7
+ // テスト用の決定的な uuid(extract-component.test.ts と同じ流儀)
8
+ let seq = 0
9
+ const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
10
+
11
+ const port = (io: "in" | "out", key: string): GraphPort => ({
12
+ id: uuid(),
13
+ io,
14
+ key,
15
+ name: key,
16
+ schema: { type: "object" },
17
+ })
18
+
19
+ const makeNode = (
20
+ kind: GraphNode["kind"],
21
+ name: string,
22
+ x = 0,
23
+ y = 0,
24
+ ): GraphNode => ({
25
+ id: uuid(),
26
+ kind,
27
+ name,
28
+ ports: [port("in", "in"), port("out", "main")],
29
+ hooks: [],
30
+ position_x: x,
31
+ position_y: y,
32
+ ...(kind === "wait"
33
+ ? { wait: { wait_type: "duration" as const, duration_seconds: 90 } }
34
+ : {}),
35
+ })
36
+
37
+ const inOf = (n: GraphNode) => {
38
+ const p = n.ports.find((p) => p.io === "in")
39
+ if (!p) throw new Error("in がない")
40
+ return p
41
+ }
42
+ const outOf = (n: GraphNode, key = "main") => {
43
+ const p = n.ports.find((p) => p.io === "out" && p.key === key)
44
+ if (!p) throw new Error("out がない")
45
+ return p
46
+ }
47
+ const connect = (from: GraphNode, to: GraphNode) => ({
48
+ from_port_id: outOf(from).id,
49
+ to_port_id: inOf(to).id,
50
+ })
51
+
52
+ // 直列 A → B → C → D(B, C を選択するのが基本ケース)
53
+ const chain = () => {
54
+ const a = makeNode("program", "A", 0, 0)
55
+ const b = makeNode("wait", "B", 100, 0)
56
+ const c = makeNode("program", "C", 200, 100)
57
+ const d = makeNode("program", "D", 300, 0)
58
+ const nodes = [a, b, c, d]
59
+ const connections = [connect(a, b), connect(b, c), connect(c, d)]
60
+ return { a, b, c, d, nodes, connections }
61
+ }
62
+
63
+ const dup = (
64
+ nodes: GraphNode[],
65
+ connections: { from_port_id: string; to_port_id: string }[],
66
+ selectedIds: string[],
67
+ dx = 0,
68
+ dy = 0,
69
+ ) =>
70
+ duplicateSelection({
71
+ nodes,
72
+ connections,
73
+ selectedIds,
74
+ dx,
75
+ dy,
76
+ newId: uuid,
77
+ })
78
+
79
+ describe("duplicateSelection(選択ノードの複製)", () => {
80
+ it("ノード id・ポート id を全て新規に採番する", () => {
81
+ const { b, c, nodes, connections } = chain()
82
+ const result = dup(nodes, connections, [b.id, c.id])
83
+
84
+ expect(result.nodes).toHaveLength(2)
85
+ const oldNodeIds = new Set([b.id, c.id])
86
+ const oldPortIds = new Set([...b.ports, ...c.ports].map((p) => p.id))
87
+ for (const n of result.nodes) {
88
+ expect(oldNodeIds.has(n.id)).toBe(false)
89
+ for (const p of n.ports) expect(oldPortIds.has(p.id)).toBe(false)
90
+ }
91
+ // ポート id はグラフ全体で一意(複製内でも重複しない)
92
+ const newPortIds = result.nodes.flatMap((n) => n.ports.map((p) => p.id))
93
+ expect(new Set(newPortIds).size).toBe(newPortIds.length)
94
+ })
95
+
96
+ it("元のノード・接続を変更しない", () => {
97
+ const { b, c, nodes, connections } = chain()
98
+ const snapshot = structuredClone({ nodes, connections })
99
+ dup(nodes, connections, [b.id, c.id], 40, 40)
100
+ expect({ nodes, connections }).toEqual(snapshot)
101
+ })
102
+
103
+ it("選択内で完結する接続だけを新しいポート id へ張り替えてコピーする", () => {
104
+ const { b, c, nodes, connections } = chain()
105
+ const result = dup(nodes, connections, [b.id, c.id])
106
+
107
+ // A→B(入口)と C→D(出口)は落ち、B→C だけが残る
108
+ expect(result.connections).toHaveLength(1)
109
+ const newB = result.nodes.find((n) => n.name === "B")
110
+ const newC = result.nodes.find((n) => n.name === "C")
111
+ if (!newB || !newC) throw new Error("複製ノードがない")
112
+ expect(result.connections[0]).toEqual({
113
+ from_port_id: outOf(newB).id,
114
+ to_port_id: inOf(newC).id,
115
+ })
116
+ })
117
+
118
+ it("同じ out ポートからの fan-out も両方コピーされる", () => {
119
+ const a = makeNode("program", "A", 0, 0)
120
+ const b = makeNode("program", "B", 100, 0)
121
+ const c = makeNode("program", "C", 100, 100)
122
+ const nodes = [a, b, c]
123
+ const connections = [connect(a, b), connect(a, c)]
124
+ const result = dup(nodes, connections, [a.id, b.id, c.id])
125
+
126
+ expect(result.nodes).toHaveLength(3)
127
+ expect(result.connections).toHaveLength(2)
128
+ const newA = result.nodes.find((n) => n.name === "A")
129
+ if (!newA) throw new Error("複製ノードがない")
130
+ for (const conn of result.connections) {
131
+ expect(conn.from_port_id).toBe(outOf(newA).id)
132
+ }
133
+ })
134
+
135
+ it("start / end は選択に含まれていても複製しない", () => {
136
+ const start = makeNode("start", "開始", 0, 0)
137
+ const b = makeNode("program", "B", 100, 0)
138
+ const end = makeNode("end", "終了", 200, 0)
139
+ const nodes = [start, b, end]
140
+ const connections = [connect(start, b), connect(b, end)]
141
+ const result = dup(nodes, connections, [start.id, b.id, end.id])
142
+
143
+ expect(result.nodes.map((n) => n.name)).toEqual(["B"])
144
+ // start / end 側の線は「選択内で完結」しないので落ちる
145
+ expect(result.connections).toEqual([])
146
+ })
147
+
148
+ it("選択が空・start/end のみのときは空の結果を返す", () => {
149
+ const start = makeNode("start", "開始", 0, 0)
150
+ const end = makeNode("end", "終了", 200, 0)
151
+ const nodes = [start, end]
152
+ const connections = [connect(start, end)]
153
+
154
+ expect(dup(nodes, connections, [])).toEqual({ nodes: [], connections: [] })
155
+ expect(dup(nodes, connections, [start.id, end.id])).toEqual({
156
+ nodes: [],
157
+ connections: [],
158
+ })
159
+ })
160
+
161
+ it("kind 別設定・hooks・ポートの属性を引き継ぐ", () => {
162
+ const wait = makeNode("wait", "待つ", 0, 0)
163
+ const human: GraphNode = {
164
+ ...makeNode("human", "承認", 100, 0),
165
+ ports: [
166
+ { ...port("in", "in"), side: "left", offset: 0.25 },
167
+ {
168
+ ...port("out", "approve"),
169
+ name: "承認",
170
+ schema: { type: "string" },
171
+ side: "right",
172
+ offset: 0.75,
173
+ },
174
+ port("out", "reject"),
175
+ ],
176
+ human: {
177
+ assignment: {
178
+ strategy: "fixed",
179
+ approval_mode: "any",
180
+ units: [[RUN_STARTER]],
181
+ },
182
+ },
183
+ }
184
+ const program: GraphNode = {
185
+ ...makeNode("program", "実行", 200, 0),
186
+ program: { program_dir: "sendReply" },
187
+ hooks: [
188
+ {
189
+ hook_dir: "notify",
190
+ on_input: false,
191
+ on_output: true,
192
+ on_signal: false,
193
+ },
194
+ ],
195
+ }
196
+ const component: GraphNode = {
197
+ ...makeNode("component", "部品", 300, 0),
198
+ component: { component_loop_id: uuid() },
199
+ }
200
+ const nodes = [wait, human, program, component]
201
+ const result = dup(nodes, [], [wait.id, human.id, program.id, component.id])
202
+
203
+ const byName = new Map(result.nodes.map((n) => [n.name, n]))
204
+ expect(byName.get("待つ")?.wait).toEqual(wait.wait)
205
+ expect(byName.get("承認")?.human).toEqual(human.human)
206
+ expect(byName.get("実行")?.program).toEqual(program.program)
207
+ expect(byName.get("実行")?.hooks).toEqual(program.hooks)
208
+ // component 参照はそのまま(同じ子 loop を指す2つ目の配置)
209
+ expect(byName.get("部品")?.component).toEqual(component.component)
210
+
211
+ // ポートの io / key / name / schema / side / offset が保たれる
212
+ const newHuman = byName.get("承認")
213
+ if (!newHuman) throw new Error("複製ノードがない")
214
+ expect(newHuman.ports.map(({ id: _id, ...rest }) => rest)).toEqual(
215
+ human.ports.map(({ id: _id, ...rest }) => rest),
216
+ )
217
+ })
218
+
219
+ it("dx / dy を全ノードへ一律に適用し、相対配置を保つ", () => {
220
+ const { b, c, nodes, connections } = chain()
221
+ const result = dup(nodes, connections, [b.id, c.id], 40, -20)
222
+
223
+ const byName = new Map(result.nodes.map((n) => [n.name, n]))
224
+ expect(byName.get("B")?.position_x).toBe(b.position_x + 40)
225
+ expect(byName.get("B")?.position_y).toBe(b.position_y - 20)
226
+ expect(byName.get("C")?.position_x).toBe(c.position_x + 40)
227
+ expect(byName.get("C")?.position_y).toBe(c.position_y - 20)
228
+ })
229
+
230
+ it("元グラフでの並び順を保って返す(選択の指定順には依存しない)", () => {
231
+ const { b, c, nodes, connections } = chain()
232
+ const result = dup(nodes, connections, [c.id, b.id])
233
+ expect(result.nodes.map((n) => n.name)).toEqual(["B", "C"])
234
+ })
235
+ })
@@ -0,0 +1,48 @@
1
+ import type { GraphConnection, GraphNode } from "./graph.js"
2
+
3
+ // 選択ノードのコピー&ペースト・複製(ノードのコピー&ペーストと複数選択.md)。
4
+ // 純関数(id 採番は newId で注入)。エディタとテストの両方から使う。
5
+ //
6
+ // グラフは全量置換 PUT なので、複製は「id を全部採番し直して足す」だけで成立する。
7
+ // ポート数の制約(in は kind ごとに固定・out は1以上)や (io,key) のノード内一意は
8
+ // ノードを丸ごと写すので自動的に保たれる。
9
+
10
+ export const duplicateSelection = (params: {
11
+ nodes: GraphNode[]
12
+ connections: GraphConnection[]
13
+ selectedIds: readonly string[]
14
+ // 貼り付け位置の移動量(world 座標。選択の相対配置はそのまま保つ)
15
+ dx: number
16
+ dy: number
17
+ newId: () => string
18
+ }): { nodes: GraphNode[]; connections: GraphConnection[] } => {
19
+ const { nodes, connections, selectedIds, dx, dy, newId } = params
20
+ const selected = new Set(selectedIds)
21
+
22
+ // start / end は回路の構造ノードなので複製しない(選択に混ざっていても落とす)。
23
+ // 並び順は元グラフのまま(選択の指定順に依存させない)
24
+ const portIdMap = new Map<string, string>()
25
+ const copied = nodes
26
+ .filter((n) => selected.has(n.id) && n.kind !== "start" && n.kind !== "end")
27
+ .map((n) => ({
28
+ ...n,
29
+ id: newId(),
30
+ ports: n.ports.map((p) => {
31
+ const portId = newId()
32
+ portIdMap.set(p.id, portId)
33
+ return { ...p, id: portId }
34
+ }),
35
+ position_x: n.position_x + dx,
36
+ position_y: n.position_y + dy,
37
+ }))
38
+
39
+ // 両端が複製対象のポートである線(=選択内で完結する線)だけを張り替えて写す。
40
+ // 外部との線(入口・出口)はコピーしない
41
+ const copiedConnections = connections.flatMap((c) => {
42
+ const from = portIdMap.get(c.from_port_id)
43
+ const to = portIdMap.get(c.to_port_id)
44
+ return from && to ? [{ from_port_id: from, to_port_id: to }] : []
45
+ })
46
+
47
+ return { nodes: copied, connections: copiedConnections }
48
+ }
@@ -110,10 +110,20 @@ describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
110
110
  expect(ok.ok).toBe(true)
111
111
  })
112
112
 
113
- it("空・1ノードのみ・start/end 含みは拒否する", () => {
113
+ it("1ノード選択:そのノードが入口、その out が出口になる", () => {
114
+ const { a, b, c, nodes, connections } = chain()
115
+ const result = analyzeExtractSelection(nodes, connections, [b.id])
116
+ if (!result.ok) throw new Error(result.reason)
117
+ expect(result.entryNodeId).toBe(b.id)
118
+ expect(result.exitPortId).toBe(outOf(b).id)
119
+ expect(result.internal).toEqual([])
120
+ expect(result.inbound).toEqual([connect(a, b)])
121
+ expect(result.outbound).toEqual([connect(b, c)])
122
+ })
123
+
124
+ it("空・start/end 含みは拒否する", () => {
114
125
  const { b, nodes, connections } = chain()
115
126
  expect(analyzeExtractSelection(nodes, connections, []).ok).toBe(false)
116
- expect(analyzeExtractSelection(nodes, connections, [b.id]).ok).toBe(false)
117
127
 
118
128
  const start = makeNode("start", "開始")
119
129
  const withStart = [...nodes, start]
@@ -217,4 +227,43 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
217
227
  void b
218
228
  void c
219
229
  })
230
+
231
+ it("1ノード選択:start.main → ノード → end.in と配線され、親では component ノードに置き換わる", () => {
232
+ const { a, b, c, nodes, connections } = chain()
233
+ const childStartOutPortId = uuid()
234
+ const childEndInPortId = uuid()
235
+ const plan = buildExtractPlan({
236
+ nodes,
237
+ connections,
238
+ selectedIds: [b.id],
239
+ name: "待つだけ",
240
+ componentLoopId: uuid(),
241
+ childStartOutPortId,
242
+ childEndInPortId,
243
+ newId: uuid,
244
+ })
245
+ if (!plan.ok) throw new Error(plan.reason)
246
+ expect(plan.childNodes).toHaveLength(1)
247
+ const [nb] = plan.childNodes
248
+ if (!nb) throw new Error("コピーがない")
249
+ expect(nb.wait).toEqual({ wait_type: "duration", duration_seconds: 90 })
250
+ expect(plan.childConnections).toContainEqual({
251
+ from_port_id: childStartOutPortId,
252
+ to_port_id: inOf(nb).id,
253
+ })
254
+ expect(plan.childConnections).toContainEqual({
255
+ from_port_id: outOf(nb).id,
256
+ to_port_id: childEndInPortId,
257
+ })
258
+ // 親:B が component ノードに置き換わり、A.main → group.in/group.main → C.in
259
+ expect(plan.parentNodes.some((n) => n.id === b.id)).toBe(false)
260
+ expect(plan.parentConnections).toContainEqual({
261
+ from_port_id: outOf(a).id,
262
+ to_port_id: inOf(plan.groupNode).id,
263
+ })
264
+ expect(plan.parentConnections).toContainEqual({
265
+ from_port_id: outOf(plan.groupNode).id,
266
+ to_port_id: inOf(c).id,
267
+ })
268
+ })
220
269
  })
@@ -26,8 +26,10 @@ export const analyzeExtractSelection = (
26
26
  selectedIds: readonly string[],
27
27
  ): ExtractAnalysis => {
28
28
  const selected = new Set(selectedIds)
29
- if (selected.size < 2) {
30
- return { ok: false, reason: "2つ以上のノードを選択してください" }
29
+ // 1ノードからコンポーネント化できる(グループ化の「2つ以上」はエディタ側の
30
+ // 有効条件。コンポーネントの設定値(refs)と1ノードのコンポーネント化.md)
31
+ if (selected.size < 1) {
32
+ return { ok: false, reason: "ノードを選択してください" }
31
33
  }
32
34
  const selectedNodes = nodes.filter((n) => selected.has(n.id))
33
35
  const flow = selectedNodes.find((n) => n.kind === "start" || n.kind === "end")