@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.
- package/_schemas/ai-manifest.test.ts +54 -59
- package/_schemas/ai-manifest.ts +38 -23
- package/_schemas/duplicate-selection.test.ts +235 -0
- package/_schemas/duplicate-selection.ts +48 -0
- package/_schemas/extract-component.test.ts +51 -2
- package/_schemas/extract-component.ts +4 -2
- package/_schemas/graph.test.ts +105 -86
- package/_schemas/graph.ts +20 -77
- package/_schemas/node.ts +4 -0
- package/_schemas/port-spec.test.ts +194 -113
- package/_schemas/port-spec.ts +95 -107
- package/dist/_cli/credentials.d.ts +14 -0
- package/dist/_cli/credentials.js +81 -0
- package/dist/_cli/login.d.ts +5 -0
- package/dist/_cli/login.js +82 -0
- package/dist/_cli/resumeSession.d.ts +21 -0
- package/dist/_cli/resumeSession.js +125 -0
- package/dist/_cli/sessionCommand.d.ts +3 -0
- package/dist/_cli/sessionCommand.js +55 -0
- package/dist/_schemas/ai-manifest.d.ts +12 -1
- package/dist/_schemas/ai-manifest.js +35 -20
- package/dist/_schemas/duplicate-selection.d.ts +12 -0
- package/dist/_schemas/duplicate-selection.js +34 -0
- package/dist/_schemas/extract-component.js +4 -2
- package/dist/_schemas/graph.d.ts +14 -12
- package/dist/_schemas/graph.js +21 -60
- package/dist/_schemas/hook-manifest.d.ts +2 -2
- package/dist/_schemas/node.d.ts +3 -2
- package/dist/_schemas/node.js +4 -0
- package/dist/_schemas/port-spec.d.ts +11 -6
- package/dist/_schemas/port-spec.js +72 -97
- package/dist/cli.js +50 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +1 -1
- package/dist/typegen.js +6 -6
- package/docs/development.md +5 -1
- package/index.ts +1 -0
- package/package.json +11 -18
- package/skills/create-loop/SKILL.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/echo/main.ts +1 -1
- 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 {
|
|
3
|
+
import { instructionSchema } from "./port-spec.js"
|
|
4
4
|
|
|
5
|
-
const
|
|
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:
|
|
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:
|
|
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(
|
|
60
|
-
//
|
|
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: {
|
|
70
|
-
})
|
|
71
|
-
).
|
|
72
|
-
//
|
|
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
|
-
|
|
75
|
-
|
|
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: {
|
|
108
|
+
aiManifestSchema.parse({ ...valid, outputs: { reply: textOutput } }),
|
|
88
109
|
).not.toThrow()
|
|
89
110
|
expect(() =>
|
|
90
111
|
aiManifestSchema.parse({
|
|
91
112
|
...valid,
|
|
92
|
-
outputs: { spam:
|
|
113
|
+
outputs: { spam: textOutput, sales: textOutput, support: textOutput },
|
|
93
114
|
}),
|
|
94
115
|
).not.toThrow()
|
|
95
116
|
})
|
|
96
117
|
|
|
97
|
-
it("outputs
|
|
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" } } },
|
|
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" },
|
|
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 名を拒否する", () => {
|
package/_schemas/ai-manifest.ts
CHANGED
|
@@ -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(),
|
|
61
|
+
inputs: z.record(z.string(), aiInputValueSchema),
|
|
46
62
|
outputs: z.record(z.string(), jsonSchemaSchema),
|
|
47
63
|
})
|
|
48
64
|
.superRefine((manifest, ctx) => {
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
const
|
|
52
|
-
|
|
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: ["
|
|
63
|
-
message: "
|
|
71
|
+
path: ["inputs"],
|
|
72
|
+
message: "inputs は1件以上宣言してください",
|
|
64
73
|
})
|
|
65
74
|
}
|
|
66
|
-
|
|
67
|
-
|
|
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: ["
|
|
74
|
-
message: `
|
|
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("
|
|
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
|
-
|
|
30
|
-
|
|
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")
|