@mawaru/sdk 0.35.0 → 0.36.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.
@@ -417,3 +417,78 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
417
417
  })
418
418
  })
419
419
  })
420
+
421
+ // forで配列を要素ごとに処理する(繰り返しの枠).md:繰り返しは結果を配列で返すので出口はちょうど1つ
422
+ describe("buildExtractPlan(singleExit:繰り返しの出口は1つ)", () => {
423
+ const childEndNode = (): GraphNode => ({
424
+ id: uuid(),
425
+ kind: "end",
426
+ name: "終了",
427
+ ports: [port("in", "in", {}), port("out", "main", {})],
428
+ hooks: [],
429
+ position_x: 560,
430
+ position_y: 200,
431
+ })
432
+ const plan = (
433
+ nodes: GraphNode[],
434
+ connections: { from_port_id: string; to_port_id: string }[],
435
+ selectedIds: string[],
436
+ childEnd = childEndNode(),
437
+ ) =>
438
+ buildExtractPlan({
439
+ nodes,
440
+ connections,
441
+ selectedIds,
442
+ name: "繰り返し",
443
+ componentLoopId: uuid(),
444
+ childStartOutPortId: uuid(),
445
+ childEnd,
446
+ newId: uuid,
447
+ singleExit: true,
448
+ })
449
+
450
+ it("外へ出る線が無ければ、線の出ていない唯一の出力を出口にする(結果を返す)", () => {
451
+ const { d, nodes, connections } = chain()
452
+ const childEnd = childEndNode()
453
+
454
+ const result = plan(nodes, connections, [d.id], childEnd)
455
+
456
+ if (!result.ok) throw new Error(result.reason)
457
+ const [nd] = result.childNodes
458
+ if (!nd) throw new Error("コピーがない")
459
+ expect(result.childConnections).toContainEqual({
460
+ from_port_id: outOf(nd).id,
461
+ to_port_id: inOf(childEnd).id,
462
+ })
463
+ expect(result.groupNode.ports.filter((p) => p.io === "out")).toHaveLength(1)
464
+ })
465
+
466
+ it("外へ出る線があればそれが出口(ふだんの抽出と同じ)", () => {
467
+ const { b, c, nodes, connections } = chain()
468
+
469
+ const result = plan(nodes, connections, [b.id, c.id])
470
+
471
+ if (!result.ok) throw new Error(result.reason)
472
+ expect(result.childEnd.ports.filter((p) => p.io === "in")).toHaveLength(1)
473
+ })
474
+
475
+ it("出口が2つ以上・出口にできる出力が決まらない選択は拒否する", () => {
476
+ const a = makeNode("program", "A")
477
+ const b = makeNode("program", "B")
478
+ const c = makeNode("program", "C")
479
+ const d = makeNode("program", "D")
480
+ // B と C を選ぶ:B → D、C → D の2つの出口
481
+ const twoExits = plan(
482
+ [a, b, c, d],
483
+ [connect(a, b), connect(a, c), connect(b, d), connect(c, d)],
484
+ [b.id, c.id],
485
+ )
486
+ expect(twoExits.ok).toBe(false)
487
+ if (twoExits.ok) return
488
+ expect(twoExits.reason).toContain("出口を1つ")
489
+
490
+ // 外へ出る線が無く、線の出ていない出力が2つ(B と C)
491
+ const ambiguous = plan([b, c], [], [b.id, c.id])
492
+ expect(ambiguous.ok).toBe(false)
493
+ })
494
+ })
@@ -118,6 +118,10 @@ export const buildExtractPlan = (params: {
118
118
  childStartOutPortId: string
119
119
  childEnd: GraphNode
120
120
  newId: () => string
121
+ // 繰り返し(for):結果を配列で返すので出口はちょうど1つ。外へ出る線が無ければ、
122
+ // 選択の中で線の出ていない唯一の出力を出口にする
123
+ // (forで配列を要素ごとに処理する(繰り返しの枠).md)
124
+ singleExit?: boolean
121
125
  }): { ok: false; reason: string } | ({ ok: true } & ExtractPlan) => {
122
126
  const {
123
127
  nodes,
@@ -128,11 +132,30 @@ export const buildExtractPlan = (params: {
128
132
  childStartOutPortId,
129
133
  childEnd,
130
134
  newId,
135
+ singleExit = false,
131
136
  } = params
132
137
  const analysis = analyzeExtractSelection(nodes, connections, selectedIds)
133
138
  if (!analysis.ok) return analysis
134
139
 
135
140
  const selected = new Set(selectedIds)
141
+ let exitPortIds = analysis.exitPortIds
142
+ if (singleExit) {
143
+ if (exitPortIds.length === 0) {
144
+ const wiredOut = new Set(analysis.internal.map((c) => c.from_port_id))
145
+ const free = nodes
146
+ .filter((n) => selected.has(n.id))
147
+ .flatMap((n) =>
148
+ n.ports
149
+ .filter((p) => p.io === "out" && !wiredOut.has(p.id))
150
+ .map((p) => p.id),
151
+ )
152
+ exitPortIds = free.length === 1 ? free : []
153
+ }
154
+ if (exitPortIds.length !== 1) {
155
+ return { ok: false, reason: "繰り返しの出口を1つにしてください" }
156
+ }
157
+ }
158
+
136
159
  // ノード・ポートとも新 id を採番してコピー(kind 別設定・hooks は共有参照で問題ない
137
160
  // =この後 JSON 化して保存 API に渡すだけで、エディタ状態には子側を持たない)。
138
161
  // node_key は親の版の行との突き合わせ用なので子へは持ち込まない(子 loop の draft に
@@ -185,7 +208,7 @@ export const buildExtractPlan = (params: {
185
208
  }
186
209
  }
187
210
  }
188
- const exits = analysis.exitPortIds.map((exitPortId, i) => {
211
+ const exits = exitPortIds.map((exitPortId, i) => {
189
212
  const exitName = portById.get(exitPortId)?.name ?? "出力"
190
213
  const seeded = i === 0 ? seededEndIns[0] : undefined
191
214
  const endIn: GraphPort = seeded
@@ -997,22 +997,69 @@ describe("saveGraphBodySchema:開始/終了ノード(start/end kind)", ()
997
997
  ).toThrow(/in ポートはちょうど1つ/)
998
998
  })
999
999
 
1000
- it("start への接続・end からの接続を拒否する", () => {
1001
- const s = startNode()
1000
+ it("end からの接続を拒否する", () => {
1002
1001
  const e = endNode()
1003
1002
  const other = makeNode()
1003
+ expect(() =>
1004
+ saveGraphBodySchema.parse({
1005
+ nodes: [e, other],
1006
+ connections: [connect(e, other)],
1007
+ }),
1008
+ ).toThrow(/終了ノード/)
1009
+ })
1010
+
1011
+ // start の入口へ戻すと次の周回になる
1012
+ // (docs/tasks/wip/startノードに入力をつないで周回する(runの周回).md)
1013
+ it("start の入口へ1本つなげる(周回の最後から戻す)", () => {
1014
+ const s = startNode()
1015
+ const other = { ...makeNode(simplePorts()), kind: "wait" }
1004
1016
  expect(() =>
1005
1017
  saveGraphBodySchema.parse({
1006
1018
  nodes: [s, other],
1007
- connections: [connect(other, s)],
1019
+ connections: [connect(s, other), connect(other, s)],
1008
1020
  }),
1009
- ).toThrow(/開始ノード/)
1021
+ ).not.toThrow()
1022
+ })
1023
+
1024
+ it("start の入口につなげる線は1本だけ", () => {
1025
+ const s = startNode()
1026
+ const a = makeNode()
1027
+ const b = makeNode()
1010
1028
  expect(() =>
1011
1029
  saveGraphBodySchema.parse({
1012
- nodes: [e, other],
1013
- connections: [connect(e, other)],
1030
+ nodes: [s, a, b],
1031
+ connections: [connect(a, s), connect(b, s)],
1014
1032
  }),
1015
- ).toThrow(/終了ノード/)
1033
+ ).toThrow(/開始ノードの入口につなげる線は1本だけです/)
1034
+ })
1035
+
1036
+ it("start の入口の型(直後のノードが受ける型)に合わない線を拒否する", () => {
1037
+ const s = startNode()
1038
+ const objectIn = {
1039
+ type: "object",
1040
+ properties: { title: { type: "string" } },
1041
+ required: ["title"],
1042
+ }
1043
+ const next = {
1044
+ ...makeNode([
1045
+ { ...port("in", "in"), schema: objectIn },
1046
+ port("out", "main"),
1047
+ ]),
1048
+ kind: "program",
1049
+ }
1050
+ const last = {
1051
+ ...makeNode([
1052
+ port("in", "in"),
1053
+ { ...port("out", "main"), schema: { type: "string" } },
1054
+ ]),
1055
+ kind: "program",
1056
+ }
1057
+ expect(() =>
1058
+ saveGraphBodySchema.parse({
1059
+ nodes: [s, next, last],
1060
+ connections: [connect(s, next), connect(next, last), connect(last, s)],
1061
+ }),
1062
+ ).toThrow(/入力スキーマに適合しません/)
1016
1063
  })
1017
1064
  })
1018
1065
 
package/_schemas/graph.ts CHANGED
@@ -521,12 +521,16 @@ export const saveGraphBodySchema = z
521
521
  const toNode = nodeByPortId.get(conn.to_port_id)
522
522
  if (!from || !to || !fromNode || !toNode) return // 構造違反は検出済み
523
523
 
524
- // start には引き込めない(run payload だけが入口)、end からは引き出せない(終端)
525
- if (toNode.kind === "start") {
524
+ // end からは引き出せない(終端)。start の入口へは戻せる(次の周回になる)が、
525
+ // 線は1本だけ(2本あると両方が着いたときに周回が2本並んで走る)
526
+ if (
527
+ toNode.kind === "start" &&
528
+ (inboundByPort.get(conn.to_port_id)?.length ?? 0) > 1
529
+ ) {
526
530
  ctx.addIssue({
527
531
  code: "custom",
528
532
  path: ["connections", i, "to_port_id"],
529
- message: "開始ノードには接続できません",
533
+ message: "開始ノードの入口につなげる線は1本だけです",
530
534
  })
531
535
  }
532
536
  if (fromNode.kind === "end") {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { GraphPort, JsonSchema } from "./node.js"
3
3
  import {
4
+ arrayOf,
4
5
  dataFormatOf,
5
6
  dataSchemaOf,
6
7
  fileSchema,
@@ -1116,3 +1117,30 @@ describe("schemaTypeKey(型としての同一性)", () => {
1116
1117
  ).not.toBe(schemaTypeKey({ type: "array", items: [{ type: "number" }] }))
1117
1118
  })
1118
1119
  })
1120
+
1121
+ describe("arrayOf(for の配置ポート:1件分の型を配列に包む)", () => {
1122
+ it("宣言された型は items に入れた配列になる", () => {
1123
+ const error = { type: "object", properties: { id: { type: "string" } } }
1124
+ expect(arrayOf(error)).toEqual({ type: "array", items: error })
1125
+ })
1126
+
1127
+ it("未宣言の型は items を持たない配列になる(要素の型は判断できないまま)", () => {
1128
+ expect(arrayOf(UNDECLARED_SCHEMA)).toEqual({ type: "array" })
1129
+ })
1130
+
1131
+ it("配列にした型同士は要素の型で適合を判定する", () => {
1132
+ const narrow = {
1133
+ type: "object",
1134
+ properties: { id: { type: "string" }, title: { type: "string" } },
1135
+ required: ["id", "title"],
1136
+ }
1137
+ const wide = {
1138
+ type: "object",
1139
+ properties: { id: { type: "string" } },
1140
+ required: ["id"],
1141
+ }
1142
+ expect(schemaFit(arrayOf(narrow), arrayOf(wide))).toBe("ok")
1143
+ expect(schemaFit(arrayOf(wide), arrayOf(narrow))).toBe("mismatch")
1144
+ expect(schemaFit(wide, arrayOf(wide))).toBe("mismatch")
1145
+ })
1146
+ })
@@ -15,6 +15,14 @@ export const UNDECLARED_SCHEMA: JsonSchema = {}
15
15
  export const isUndeclaredSchema = (schema: JsonSchema): boolean =>
16
16
  Object.keys(schema).length === 0 || schema.type === undefined
17
17
 
18
+ // 1件分の型を配列に包む(for の配置ポート:in = 中身の start.in の配列、out = 中身の
19
+ // end の in の配列。docs/tasks/done/forで配列を要素ごとに処理する(繰り返しの枠).md)。
20
+ // 未宣言は items を持たせない(要素の型は判断できないまま)
21
+ export const arrayOf = (schema: JsonSchema): JsonSchema =>
22
+ isUndeclaredSchema(schema)
23
+ ? { type: "array" }
24
+ : { type: "array", items: schema }
25
+
18
26
  // ---- file 型(docs/tasks/done/AIノードのファイル入力(file型と添付).md) ----
19
27
  // schema 上の file は x-mawaru-type マーカー付きの object(nominal に識別する)。値はバイト
20
28
  // ではなく storage path の参照で、署名 URL はどこにも焼き込まない。
@@ -28,6 +28,7 @@ export declare const buildExtractPlan: (params: {
28
28
  childStartOutPortId: string;
29
29
  childEnd: GraphNode;
30
30
  newId: () => string;
31
+ singleExit?: boolean;
31
32
  }) => {
32
33
  ok: false;
33
34
  reason: string;
@@ -55,11 +55,26 @@ export const analyzeExtractSelection = (nodes, connections, selectedIds) => {
55
55
  // end の in が1つの loop は out main の1口として振る舞う)
56
56
  const SINGLE_OUT_KEY = "main";
57
57
  export const buildExtractPlan = (params) => {
58
- const { nodes, connections, selectedIds, name, componentLoopId, childStartOutPortId, childEnd, newId, } = params;
58
+ const { nodes, connections, selectedIds, name, componentLoopId, childStartOutPortId, childEnd, newId, singleExit = false, } = params;
59
59
  const analysis = analyzeExtractSelection(nodes, connections, selectedIds);
60
60
  if (!analysis.ok)
61
61
  return analysis;
62
62
  const selected = new Set(selectedIds);
63
+ let exitPortIds = analysis.exitPortIds;
64
+ if (singleExit) {
65
+ if (exitPortIds.length === 0) {
66
+ const wiredOut = new Set(analysis.internal.map((c) => c.from_port_id));
67
+ const free = nodes
68
+ .filter((n) => selected.has(n.id))
69
+ .flatMap((n) => n.ports
70
+ .filter((p) => p.io === "out" && !wiredOut.has(p.id))
71
+ .map((p) => p.id));
72
+ exitPortIds = free.length === 1 ? free : [];
73
+ }
74
+ if (exitPortIds.length !== 1) {
75
+ return { ok: false, reason: "繰り返しの出口を1つにしてください" };
76
+ }
77
+ }
63
78
  // ノード・ポートとも新 id を採番してコピー(kind 別設定・hooks は共有参照で問題ない
64
79
  // =この後 JSON 化して保存 API に渡すだけで、エディタ状態には子側を持たない)。
65
80
  // node_key は親の版の行との突き合わせ用なので子へは持ち込まない(子 loop の draft に
@@ -108,7 +123,7 @@ export const buildExtractPlan = (params) => {
108
123
  }
109
124
  }
110
125
  };
111
- const exits = analysis.exitPortIds.map((exitPortId, i) => {
126
+ const exits = exitPortIds.map((exitPortId, i) => {
112
127
  const exitName = portById.get(exitPortId)?.name ?? "出力";
113
128
  const seeded = i === 0 ? seededEndIns[0] : undefined;
114
129
  const endIn = seeded
@@ -447,12 +447,14 @@ export const saveGraphBodySchema = z
447
447
  const toNode = nodeByPortId.get(conn.to_port_id);
448
448
  if (!from || !to || !fromNode || !toNode)
449
449
  return; // 構造違反は検出済み
450
- // start には引き込めない(run payload だけが入口)、end からは引き出せない(終端)
451
- if (toNode.kind === "start") {
450
+ // end からは引き出せない(終端)。start の入口へは戻せる(次の周回になる)が、
451
+ // 線は1本だけ(2本あると両方が着いたときに周回が2本並んで走る)
452
+ if (toNode.kind === "start" &&
453
+ (inboundByPort.get(conn.to_port_id)?.length ?? 0) > 1) {
452
454
  ctx.addIssue({
453
455
  code: "custom",
454
456
  path: ["connections", i, "to_port_id"],
455
- message: "開始ノードには接続できません",
457
+ message: "開始ノードの入口につなげる線は1本だけです",
456
458
  });
457
459
  }
458
460
  if (fromNode.kind === "end") {
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import type { GraphPort, JsonSchema } from "./node.js";
3
3
  export declare const UNDECLARED_SCHEMA: JsonSchema;
4
4
  export declare const isUndeclaredSchema: (schema: JsonSchema) => boolean;
5
+ export declare const arrayOf: (schema: JsonSchema) => JsonSchema;
5
6
  export declare const FILE_SCHEMA_MARKER = "x-mawaru-type";
6
7
  export declare const FILE_SCHEMA_TYPE = "file";
7
8
  export declare const fileSchema: () => JsonSchema;
@@ -9,6 +9,12 @@ import { z } from "zod";
9
9
  // (docs/tasks/done/ポートスキーマのany撲滅.md)
10
10
  export const UNDECLARED_SCHEMA = {};
11
11
  export const isUndeclaredSchema = (schema) => Object.keys(schema).length === 0 || schema.type === undefined;
12
+ // 1件分の型を配列に包む(for の配置ポート:in = 中身の start.in の配列、out = 中身の
13
+ // end の in の配列。docs/tasks/done/forで配列を要素ごとに処理する(繰り返しの枠).md)。
14
+ // 未宣言は items を持たせない(要素の型は判断できないまま)
15
+ export const arrayOf = (schema) => isUndeclaredSchema(schema)
16
+ ? { type: "array" }
17
+ : { type: "array", items: schema };
12
18
  // ---- file 型(docs/tasks/done/AIノードのファイル入力(file型と添付).md) ----
13
19
  // schema 上の file は x-mawaru-type マーカー付きの object(nominal に識別する)。値はバイト
14
20
  // ではなく storage path の参照で、署名 URL はどこにも焼き込まない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mawaru/sdk",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,12 @@ repo との矛盾・使用中ノードの削除は 409。**graph PUT は冪等
77
77
  1つの出口から複数の線を引くと枝が並行に走る(fan-out)。枝の結果を待ち合わせるときは
78
78
  合流(`join`)を置く:線のつながった入口が全部揃うまで待ち、`{ [入口の key]: 値 }` にまとめて
79
79
  `main` から流す。並行した枝を合流させずに同じノード(待機など)へ戻すと、枝の数だけ走り続ける。
80
+ 配列の要素ごとに同じ手順や別ループを回すときは繰り返し(for)にする:`POST loops` に
81
+ `kind: "for"` と `owner_loop_id`(このループ)を渡して中身の loop を作り、その graph
82
+ (start → 1件分の処理 → end)を保存してから、このループに component ノード
83
+ (`component.component_loop_id`=作った for)を置く。配置ノードの in / out は中身の型の配列に
84
+ 自動で焼き込まれ、全件そろうと入力と同じ順の配列で `main` から流れる(1件ずつは同時に走る)。
85
+ 中身の出口(end につないだ in)は1つにする。
80
86
  2. **repo 側の準備**:足りない program / ai は `nodes/program/<dir>/` / `nodes/ai/<dir>/` に作る
81
87
  (config.json → `npx mawaru typegen` → main.ts 実装 → `npx mawaru validate`)。
82
88
  **AI ノードの決定論的な処理は prompt ではなく `setup.sh` に書く**(下記)。