@mawaru/sdk 0.37.0 → 0.39.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.
@@ -145,6 +145,14 @@ describe("aiManifestSchema", () => {
145
145
  expect(parsed.inputs.instruction?.resume).toBe(true)
146
146
  })
147
147
 
148
+ it("inputs の flexible(推奨型を維持して任意の型を受ける)が読める", () => {
149
+ const parsed = aiManifestSchema.parse({
150
+ ...valid,
151
+ inputs: { in: { schema: { type: "object" }, flexible: true } },
152
+ })
153
+ expect(parsed.inputs.in?.flexible).toBe(true)
154
+ })
155
+
148
156
  it("旧形式(値が素の JSON Schema)は { schema: 値 } として読める", () => {
149
157
  const legacy = aiManifestSchema.parse({
150
158
  ...valid,
@@ -72,6 +72,8 @@ export const agentOfModel = (model: AiModel): AiAgentId =>
72
72
  export const aiInputDeclSchema = z.object({
73
73
  schema: jsonSchemaSchema,
74
74
  resume: z.boolean().optional(),
75
+ // 推奨型を維持して任意の型を受ける(接続で上書きされず、適合チェックもしない)
76
+ flexible: z.boolean().optional(),
75
77
  })
76
78
  export type AiInputDecl = z.infer<typeof aiInputDeclSchema>
77
79
 
@@ -1203,6 +1203,42 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
1203
1203
  ).not.toThrow()
1204
1204
  })
1205
1205
 
1206
+ it("flexible な in(契約で焼き込まれた component.in・AI の宣言 in)への接続は型が違っても保存できる", () => {
1207
+ const prog = {
1208
+ ...makeNode([
1209
+ port("in", "in"),
1210
+ { ...port("out", "main"), schema: { type: "string" } },
1211
+ ]),
1212
+ kind: "program" as const,
1213
+ }
1214
+ const comp = {
1215
+ ...componentNode({ type: "number" }),
1216
+ }
1217
+ comp.ports = comp.ports.map((p) =>
1218
+ p.io === "in" ? { ...p, flexible: true } : p,
1219
+ )
1220
+ expect(() =>
1221
+ saveGraphBodySchema.parse({
1222
+ nodes: [prog, comp],
1223
+ connections: [connect(prog, comp)],
1224
+ }),
1225
+ ).not.toThrow()
1226
+ // AI の flexible な in:鏡映されず推奨型 {type:"number"} が残るが、型が違う接続を通す
1227
+ const ai = {
1228
+ ...makeNode([
1229
+ { ...port("in", "in"), schema: { type: "number" }, flexible: true },
1230
+ port("out", "main"),
1231
+ ]),
1232
+ kind: "ai" as const,
1233
+ }
1234
+ expect(() =>
1235
+ saveGraphBodySchema.parse({
1236
+ nodes: [prog, ai],
1237
+ connections: [connect(prog, ai)],
1238
+ }),
1239
+ ).not.toThrow()
1240
+ })
1241
+
1206
1242
  it("component の in への接続は契約への適合を検証する(伝播型 ⊆ 契約)", () => {
1207
1243
  // 上流 program の out {type:"string"} → component の in {type:"number"} は不適合
1208
1244
  const prog = {
package/_schemas/graph.ts CHANGED
@@ -6,8 +6,8 @@ import {
6
6
  portKeySchema,
7
7
  } from "./node.js"
8
8
  import {
9
+ connectionAccepted,
9
10
  hasInboundConflict,
10
- isSchemaSubset,
11
11
  kindPortsViolation,
12
12
  resolvePortSchemas,
13
13
  UNDECLARED_SCHEMA,
@@ -551,9 +551,10 @@ export const saveGraphBodySchema = z
551
551
  // 接続鏡映する口(program / ai の in)とコピー宣言のビューは接続元の型を採用する
552
552
  // ので自明に通り、実質の検証対象は宣言で固定された口
553
553
  // (component の契約・宣言のあるビューの human.in)になる
554
+ // flexible な in(AI の宣言 in・契約で焼き込まれた component の in)は型を問わない
554
555
  const fromSchema = resolved.get(conn.from_port_id) ?? from.schema
555
556
  const toSchema = resolved.get(conn.to_port_id) ?? to.schema
556
- if (!isSchemaSubset(fromSchema, toSchema)) {
557
+ if (!connectionAccepted(fromSchema, { ...to, schema: toSchema })) {
557
558
  ctx.addIssue({
558
559
  code: "custom",
559
560
  path: ["connections", i, "to_port_id"],
package/_schemas/node.ts CHANGED
@@ -54,6 +54,10 @@ export const graphPortSchema = z
54
54
  // 再開する(AI ノードで意味を持つ。key ではなくこの属性が再開を決める。
55
55
  // ポート型固定の撤去とセッション再開のポート属性化.md)
56
56
  resume: z.boolean().optional(),
57
+ // in ポートの型属性:true なら宣言(推奨型)を維持し、接続元の型による上書き・
58
+ // 適合チェックをしない(AI の in で設定。start / component へは導出で流れる。
59
+ // AIノード入力のflexible属性(任意の型で呼べるループ).md)
60
+ flexible: z.boolean().optional(),
57
61
  side: portSideSchema.optional(),
58
62
  offset: z.number().min(0).max(1).optional(),
59
63
  })
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
2
2
  import type { GraphPort, JsonSchema } from "./node.js"
3
3
  import {
4
4
  arrayOf,
5
+ connectionAccepted,
5
6
  dataFormatOf,
6
7
  dataSchemaOf,
7
8
  fileSchema,
@@ -12,6 +13,7 @@ import {
12
13
  kindPortsViolation,
13
14
  mergeInboundSchemas,
14
15
  propagateDerivedPorts,
16
+ resolveFlexiblePorts,
15
17
  resolvePortSchemas,
16
18
  schemaFit,
17
19
  schemaTypeKey,
@@ -661,6 +663,128 @@ describe("型伝播(導出ポートの解決)", () => {
661
663
  expect(resolved.get(start.outMain.id)).toEqual(UNDECLARED_SCHEMA)
662
664
  })
663
665
 
666
+ // flexible:AI の in の型属性。宣言(推奨型)を維持し、接続の型を問わない。
667
+ // start へは型と同じ経路で導出される(AIノード入力のflexible属性(任意の型で呼べるループ).md)
668
+ describe("flexible(推奨型を維持して任意の型を受ける in)", () => {
669
+ const flexibleAi = (declared: JsonSchema) => {
670
+ const inPort: GraphPort = {
671
+ ...port("in", "in", declared),
672
+ flexible: true,
673
+ }
674
+ return {
675
+ node: { id: uuid(), kind: "ai", ports: [inPort, port("out", "main")] },
676
+ inPort,
677
+ }
678
+ }
679
+ const recommended = {
680
+ type: "object",
681
+ properties: { prompt: { type: "string" } },
682
+ required: ["prompt"],
683
+ }
684
+
685
+ it("flexible な in は接続元の具体型で上書きしない(宣言=推奨型を維持)", () => {
686
+ const program = programNode({ type: "string" })
687
+ const progOut = program.node.ports.find(
688
+ (p) => p.io === "out",
689
+ ) as GraphPort
690
+ progOut.schema = declaredOut
691
+ const ai = flexibleAi(recommended)
692
+ const resolved = resolvePortSchemas(
693
+ [program.node, ai.node],
694
+ [{ from_port_id: progOut.id, to_port_id: ai.inPort.id }],
695
+ )
696
+ expect(resolved.get(ai.inPort.id)).toEqual(recommended)
697
+ })
698
+
699
+ it("flexible な in は型の割れた fan-in でも推奨型を維持する", () => {
700
+ const a = programNode({ type: "string" })
701
+ const aOut = a.node.ports.find((p) => p.io === "out") as GraphPort
702
+ aOut.schema = { type: "string" }
703
+ const b = programNode({ type: "string" })
704
+ const bOut = b.node.ports.find((p) => p.io === "out") as GraphPort
705
+ bOut.schema = { type: "number" }
706
+ const ai = flexibleAi(recommended)
707
+ const resolved = resolvePortSchemas(
708
+ [a.node, b.node, ai.node],
709
+ [
710
+ { from_port_id: aOut.id, to_port_id: ai.inPort.id },
711
+ { from_port_id: bOut.id, to_port_id: ai.inPort.id },
712
+ ],
713
+ )
714
+ expect(resolved.get(ai.inPort.id)).toEqual(recommended)
715
+ })
716
+
717
+ it("start → AI(flexible) 直結:start の in / out main が推奨型で flexible になる", () => {
718
+ const start = startNode()
719
+ const ai = flexibleAi(recommended)
720
+ const connections = [
721
+ { from_port_id: start.outMain.id, to_port_id: ai.inPort.id },
722
+ ]
723
+ expect(resolveFlexiblePorts([start.node, ai.node], connections)).toEqual(
724
+ new Set([start.inPort.id, start.outMain.id]),
725
+ )
726
+ const propagated = propagateDerivedPorts(
727
+ [start.node, ai.node],
728
+ connections,
729
+ )[0]
730
+ if (!propagated) throw new Error("start not propagated")
731
+ for (const p of propagated.ports) {
732
+ expect(p.schema).toEqual(recommended)
733
+ expect(p.flexible).toBe(true)
734
+ }
735
+ })
736
+
737
+ it("start → [AI(flexible), program]:非 flexible の接続先が混ざれば start は flexible にならない", () => {
738
+ const start = startNode()
739
+ const ai = flexibleAi(recommended)
740
+ const program = programNode(recommended)
741
+ const connections = [
742
+ { from_port_id: start.outMain.id, to_port_id: ai.inPort.id },
743
+ { from_port_id: start.outMain.id, to_port_id: program.inPort.id },
744
+ ]
745
+ expect(
746
+ resolveFlexiblePorts([start.node, ai.node, program.node], connections),
747
+ ).toEqual(new Set())
748
+ })
749
+
750
+ it("start:未接続なら flexible にならず、ports に残った古い flexible は落とす", () => {
751
+ const inPort: GraphPort = { ...port("in", "in"), flexible: true }
752
+ const outMain: GraphPort = { ...port("out", "main"), flexible: true }
753
+ const start = { id: uuid(), kind: "start", ports: [inPort, outMain] }
754
+ const propagated = propagateDerivedPorts([start], [])[0]
755
+ if (!propagated) throw new Error("start not propagated")
756
+ for (const p of propagated.ports) expect(p.flexible).toBeUndefined()
757
+ })
758
+
759
+ it("component の in(契約の焼き込みで flexible)に直結した start も flexible(推移)", () => {
760
+ const start = startNode()
761
+ const compIn: GraphPort = {
762
+ ...port("in", "in", recommended),
763
+ flexible: true,
764
+ }
765
+ const component = {
766
+ id: uuid(),
767
+ kind: "component",
768
+ ports: [compIn, port("out", "main")],
769
+ }
770
+ expect(
771
+ resolveFlexiblePorts(
772
+ [start.node, component],
773
+ [{ from_port_id: start.outMain.id, to_port_id: compIn.id }],
774
+ ),
775
+ ).toEqual(new Set([start.inPort.id, start.outMain.id]))
776
+ })
777
+
778
+ it("connectionAccepted:受け側が flexible なら型を問わず可、そうでなければ isSchemaSubset", () => {
779
+ const to = port("in", "in", recommended)
780
+ expect(connectionAccepted({ type: "string" }, to)).toBe(false)
781
+ expect(
782
+ connectionAccepted({ type: "string" }, { ...to, flexible: true }),
783
+ ).toBe(true)
784
+ expect(connectionAccepted(recommended, to)).toBe(true)
785
+ })
786
+ })
787
+
664
788
  it("propagateDerivedPorts:schema を書き戻し、変化のないノードは同じ参照を返す", () => {
665
789
  const progOut = port("out", "main", declaredOut)
666
790
  const program = {
@@ -487,6 +487,8 @@ export const resolvePortSchemas = <N extends PropagationNode>(
487
487
  if (isMirror) {
488
488
  for (const port of node.ports) {
489
489
  if (port.io !== "in") continue
490
+ // flexible な in は宣言(推奨型)を維持する(接続の型を問わない口)
491
+ if (port.flexible === true) continue
490
492
  const sources = inbound.get(port.id) ?? []
491
493
  const inSchema = mergeInboundSchemas(
492
494
  sources.map((id) => resolved.get(id) ?? UNDECLARED_SCHEMA),
@@ -535,19 +537,82 @@ export const resolvePortSchemas = <N extends PropagationNode>(
535
537
 
536
538
  // ノード配列へ伝播結果を書き戻す(接続変更時のポートデータ自動更新)。
537
539
  // schema が変わらないポート・ノードは同じ参照を返す
540
+ // flexible の導出:start の in / out main は、接続先の in が1つ以上あり**全て** flexible
541
+ // (AI の宣言 in・契約で焼き込まれた component の in)のときだけ flexible。
542
+ // 非 flexible の接続先(program 等)が1つでも混ざれば型どおりの入力が要るので false。
543
+ // 直結のみ(wait 等の素通し経由は型と同じく流さない)。戻りは flexible になる port_id の集合
544
+ export const resolveFlexiblePorts = (
545
+ nodes: PropagationNode[],
546
+ connections: PortLink[],
547
+ ): Set<string> => {
548
+ const flexibleByPortId = new Map<string, boolean>()
549
+ for (const node of nodes) {
550
+ if (node.kind === "start") continue
551
+ for (const port of node.ports) {
552
+ if (port.io === "in")
553
+ flexibleByPortId.set(port.id, port.flexible === true)
554
+ }
555
+ }
556
+ const outbound = new Map<string, string[]>()
557
+ for (const conn of connections) {
558
+ const outs = outbound.get(conn.from_port_id) ?? []
559
+ outs.push(conn.to_port_id)
560
+ outbound.set(conn.from_port_id, outs)
561
+ }
562
+ const flexible = new Set<string>()
563
+ for (const node of nodes) {
564
+ if (node.kind !== "start") continue
565
+ const inPort = node.ports.find((p) => p.io === "in")
566
+ const outMain = node.ports.find((p) => p.io === "out" && p.key === "main")
567
+ if (!inPort || !outMain) continue
568
+ const targets = outbound.get(outMain.id) ?? []
569
+ if (
570
+ targets.length > 0 &&
571
+ targets.every((id) => flexibleByPortId.get(id) === true)
572
+ ) {
573
+ flexible.add(inPort.id)
574
+ flexible.add(outMain.id)
575
+ }
576
+ }
577
+ return flexible
578
+ }
579
+
580
+ // 接続の型が受け入れられるか:受け側が flexible なら常に可、それ以外は 伝播型 ⊆ 宣言。
581
+ // sdk の保存契約・backend の保存時チェック・エディタの表示はすべてこれを使う
582
+ // (判定が散ると表示と保存がズレる)
583
+ export const connectionAccepted = (
584
+ from: JsonSchema,
585
+ to: Pick<GraphPort, "schema" | "flexible">,
586
+ ): boolean => to.flexible === true || isSchemaSubset(from, to.schema)
587
+
538
588
  export const propagateDerivedPorts = <N extends PropagationNode>(
539
589
  nodes: N[],
540
590
  connections: PortLink[],
541
591
  options?: PropagationOptions<N>,
542
592
  ): N[] => {
543
593
  const resolved = resolvePortSchemas(nodes, connections, options)
594
+ const flexiblePorts = resolveFlexiblePorts(nodes, connections)
544
595
  return nodes.map((node) => {
545
596
  let touched = false
546
597
  const ports = node.ports.map((port) => {
547
598
  const schema = resolved.get(port.id) ?? port.schema
548
- if (JSON.stringify(schema) === JSON.stringify(port.schema)) return port
599
+ // start の flexible は導出結果で置き換える(false は省略=キー無し)
600
+ const flexible =
601
+ node.kind === "start"
602
+ ? flexiblePorts.has(port.id) || undefined
603
+ : port.flexible
604
+ if (
605
+ JSON.stringify(schema) === JSON.stringify(port.schema) &&
606
+ flexible === port.flexible
607
+ )
608
+ return port
549
609
  touched = true
550
- return { ...port, schema }
610
+ const { flexible: _dropped, ...rest } = port
611
+ return {
612
+ ...rest,
613
+ schema,
614
+ ...(flexible ? { flexible } : {}),
615
+ } as typeof port
551
616
  })
552
617
  return touched ? { ...node, ports } : node
553
618
  })
@@ -49,6 +49,7 @@ export declare const agentOfModel: (model: AiModel) => AiAgentId;
49
49
  export declare const aiInputDeclSchema: z.ZodObject<{
50
50
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
51
51
  resume: z.ZodOptional<z.ZodBoolean>;
52
+ flexible: z.ZodOptional<z.ZodBoolean>;
52
53
  }, z.core.$strip>;
53
54
  export type AiInputDecl = z.infer<typeof aiInputDeclSchema>;
54
55
  export declare const aiManifestSchema: z.ZodObject<{
@@ -87,9 +88,11 @@ export declare const aiManifestSchema: z.ZodObject<{
87
88
  inputs: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
88
89
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
89
90
  resume: z.ZodOptional<z.ZodBoolean>;
91
+ flexible: z.ZodOptional<z.ZodBoolean>;
90
92
  }, z.core.$strip>, z.ZodPipe<z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodTransform<{
91
93
  schema: Record<string, unknown>;
92
94
  resume?: boolean | undefined;
95
+ flexible?: boolean | undefined;
93
96
  }, Record<string, unknown>>>]>>;
94
97
  outputs: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>;
95
98
  }, z.core.$strip>;
@@ -65,6 +65,8 @@ export const agentOfModel = (model) => model.startsWith("claude-") ? "claude" :
65
65
  export const aiInputDeclSchema = z.object({
66
66
  schema: jsonSchemaSchema,
67
67
  resume: z.boolean().optional(),
68
+ // 推奨型を維持して任意の型を受ける(接続で上書きされず、適合チェックもしない)
69
+ flexible: z.boolean().optional(),
68
70
  });
69
71
  // 旧形式(値が素の JSON Schema)は { schema: 値 } として読む。判別は「schema キーを
70
72
  // 持つか」:JSON Schema のトップレベルに "schema" キーは現れない(メタスキーマ参照は
@@ -152,6 +152,7 @@ export declare const graphNodeSchema: z.ZodObject<{
152
152
  name: z.ZodString;
153
153
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
154
154
  resume: z.ZodOptional<z.ZodBoolean>;
155
+ flexible: z.ZodOptional<z.ZodBoolean>;
155
156
  side: z.ZodOptional<z.ZodEnum<{
156
157
  top: "top";
157
158
  right: "right";
@@ -252,6 +253,7 @@ export declare const saveGraphBodySchema: z.ZodObject<{
252
253
  name: z.ZodString;
253
254
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
254
255
  resume: z.ZodOptional<z.ZodBoolean>;
256
+ flexible: z.ZodOptional<z.ZodBoolean>;
255
257
  side: z.ZodOptional<z.ZodEnum<{
256
258
  top: "top";
257
259
  right: "right";
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { graphPortSchema, jsonSchemaSchema, nodeKindSchema, portKeySchema, } from "./node.js";
3
- import { hasInboundConflict, isSchemaSubset, kindPortsViolation, resolvePortSchemas, UNDECLARED_SCHEMA, } from "./port-spec.js";
3
+ import { connectionAccepted, hasInboundConflict, kindPortsViolation, resolvePortSchemas, UNDECLARED_SCHEMA, } from "./port-spec.js";
4
4
  // refs=ループ変数の袋(key→任意の JSON 値。上流出力の参照(refs).md)。
5
5
  // run 開始時に定義から複製され、全 step に全量差し込まれる。
6
6
  // key はポート key と同じ規則(^[a-z][a-z0-9_]*$)。実行系ボディ
@@ -473,9 +473,10 @@ export const saveGraphBodySchema = z
473
473
  // 接続鏡映する口(program / ai の in)とコピー宣言のビューは接続元の型を採用する
474
474
  // ので自明に通り、実質の検証対象は宣言で固定された口
475
475
  // (component の契約・宣言のあるビューの human.in)になる
476
+ // flexible な in(AI の宣言 in・契約で焼き込まれた component の in)は型を問わない
476
477
  const fromSchema = resolved.get(conn.from_port_id) ?? from.schema;
477
478
  const toSchema = resolved.get(conn.to_port_id) ?? to.schema;
478
- if (!isSchemaSubset(fromSchema, toSchema)) {
479
+ if (!connectionAccepted(fromSchema, { ...to, schema: toSchema })) {
479
480
  ctx.addIssue({
480
481
  code: "custom",
481
482
  path: ["connections", i, "to_port_id"],
@@ -39,6 +39,7 @@ export declare const graphPortSchema: z.ZodObject<{
39
39
  name: z.ZodString;
40
40
  schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
41
41
  resume: z.ZodOptional<z.ZodBoolean>;
42
+ flexible: z.ZodOptional<z.ZodBoolean>;
42
43
  side: z.ZodOptional<z.ZodEnum<{
43
44
  top: "top";
44
45
  right: "right";
@@ -43,6 +43,10 @@ export const graphPortSchema = z
43
43
  // 再開する(AI ノードで意味を持つ。key ではなくこの属性が再開を決める。
44
44
  // ポート型固定の撤去とセッション再開のポート属性化.md)
45
45
  resume: z.boolean().optional(),
46
+ // in ポートの型属性:true なら宣言(推奨型)を維持し、接続元の型による上書き・
47
+ // 適合チェックをしない(AI の in で設定。start / component へは導出で流れる。
48
+ // AIノード入力のflexible属性(任意の型で呼べるループ).md)
49
+ flexible: z.boolean().optional(),
46
50
  side: portSideSchema.optional(),
47
51
  offset: z.number().min(0).max(1).optional(),
48
52
  })
@@ -70,6 +70,8 @@ export type PropagationOptions<N> = {
70
70
  } | null | undefined;
71
71
  };
72
72
  export declare const resolvePortSchemas: <N extends PropagationNode>(nodes: N[], connections: PortLink[], options?: PropagationOptions<N>) => Map<string, JsonSchema>;
73
+ export declare const resolveFlexiblePorts: (nodes: PropagationNode[], connections: PortLink[]) => Set<string>;
74
+ export declare const connectionAccepted: (from: JsonSchema, to: Pick<GraphPort, "schema" | "flexible">) => boolean;
73
75
  export declare const propagateDerivedPorts: <N extends PropagationNode>(nodes: N[], connections: PortLink[], options?: PropagationOptions<N>) => N[];
74
76
  export type SchemaFit = "ok" | "mismatch" | "unknown";
75
77
  export declare const schemaFit: (sub: JsonSchema, sup: JsonSchema) => SchemaFit;
@@ -382,6 +382,9 @@ export const resolvePortSchemas = (nodes, connections, options) => {
382
382
  for (const port of node.ports) {
383
383
  if (port.io !== "in")
384
384
  continue;
385
+ // flexible な in は宣言(推奨型)を維持する(接続の型を問わない口)
386
+ if (port.flexible === true)
387
+ continue;
385
388
  const sources = inbound.get(port.id) ?? [];
386
389
  const inSchema = mergeInboundSchemas(sources.map((id) => resolved.get(id) ?? UNDECLARED_SCHEMA));
387
390
  if (!isUndeclaredSchema(inSchema))
@@ -426,16 +429,68 @@ export const resolvePortSchemas = (nodes, connections, options) => {
426
429
  };
427
430
  // ノード配列へ伝播結果を書き戻す(接続変更時のポートデータ自動更新)。
428
431
  // schema が変わらないポート・ノードは同じ参照を返す
432
+ // flexible の導出:start の in / out main は、接続先の in が1つ以上あり**全て** flexible
433
+ // (AI の宣言 in・契約で焼き込まれた component の in)のときだけ flexible。
434
+ // 非 flexible の接続先(program 等)が1つでも混ざれば型どおりの入力が要るので false。
435
+ // 直結のみ(wait 等の素通し経由は型と同じく流さない)。戻りは flexible になる port_id の集合
436
+ export const resolveFlexiblePorts = (nodes, connections) => {
437
+ const flexibleByPortId = new Map();
438
+ for (const node of nodes) {
439
+ if (node.kind === "start")
440
+ continue;
441
+ for (const port of node.ports) {
442
+ if (port.io === "in")
443
+ flexibleByPortId.set(port.id, port.flexible === true);
444
+ }
445
+ }
446
+ const outbound = new Map();
447
+ for (const conn of connections) {
448
+ const outs = outbound.get(conn.from_port_id) ?? [];
449
+ outs.push(conn.to_port_id);
450
+ outbound.set(conn.from_port_id, outs);
451
+ }
452
+ const flexible = new Set();
453
+ for (const node of nodes) {
454
+ if (node.kind !== "start")
455
+ continue;
456
+ const inPort = node.ports.find((p) => p.io === "in");
457
+ const outMain = node.ports.find((p) => p.io === "out" && p.key === "main");
458
+ if (!inPort || !outMain)
459
+ continue;
460
+ const targets = outbound.get(outMain.id) ?? [];
461
+ if (targets.length > 0 &&
462
+ targets.every((id) => flexibleByPortId.get(id) === true)) {
463
+ flexible.add(inPort.id);
464
+ flexible.add(outMain.id);
465
+ }
466
+ }
467
+ return flexible;
468
+ };
469
+ // 接続の型が受け入れられるか:受け側が flexible なら常に可、それ以外は 伝播型 ⊆ 宣言。
470
+ // sdk の保存契約・backend の保存時チェック・エディタの表示はすべてこれを使う
471
+ // (判定が散ると表示と保存がズレる)
472
+ export const connectionAccepted = (from, to) => to.flexible === true || isSchemaSubset(from, to.schema);
429
473
  export const propagateDerivedPorts = (nodes, connections, options) => {
430
474
  const resolved = resolvePortSchemas(nodes, connections, options);
475
+ const flexiblePorts = resolveFlexiblePorts(nodes, connections);
431
476
  return nodes.map((node) => {
432
477
  let touched = false;
433
478
  const ports = node.ports.map((port) => {
434
479
  const schema = resolved.get(port.id) ?? port.schema;
435
- if (JSON.stringify(schema) === JSON.stringify(port.schema))
480
+ // start の flexible は導出結果で置き換える(false は省略=キー無し)
481
+ const flexible = node.kind === "start"
482
+ ? flexiblePorts.has(port.id) || undefined
483
+ : port.flexible;
484
+ if (JSON.stringify(schema) === JSON.stringify(port.schema) &&
485
+ flexible === port.flexible)
436
486
  return port;
437
487
  touched = true;
438
- return { ...port, schema };
488
+ const { flexible: _dropped, ...rest } = port;
489
+ return {
490
+ ...rest,
491
+ schema,
492
+ ...(flexible ? { flexible } : {}),
493
+ };
439
494
  });
440
495
  return touched ? { ...node, ports } : node;
441
496
  });
@@ -56,10 +56,12 @@ views/
56
56
  走り続ける)。省略時は `claude-opus`。認証は repo の Actions secrets に置く:Claude Code は
57
57
  `ANTHROPIC_API_KEY`(または `CLAUDE_CODE_OAUTH_TOKEN`)、Codex は明示的な `auth_mode` と対応する認証情報(下記「Codex の認証方式」参照)
58
58
  (`env` に宣言しなくても渡される)
59
- - `inputs` は1件以上・key 自由。値は `{ "schema": <JSON Schema>, "resume": true }` の形
59
+ - `inputs` は1件以上・key 自由。値は `{ "schema": <JSON Schema>, "resume": true, "flexible": true }` の形
60
60
  (`resume` は省略可。true の口から入力が届いた実行は、同じノードの直近セッションを
61
- 復元して再開する——差し戻し→修正の続き実行に使う)。旧形式(値が素の JSON Schema)も
62
- 読める
61
+ 復元して再開する——差し戻し→修正の続き実行に使う。`flexible` も省略可。true の口は
62
+ `schema` を推奨型として維持し、接続で型が上書きされず型の違う接続も通る——この AI が
63
+ 先頭のループを他のループや API から型を揃えずに呼べるようにする)。旧形式(値が素の
64
+ JSON Schema)も読める
63
65
  - 実行時はラッパーがエージェント CLI(Claude Code / Codex、非対話)を起動し、`outputs` の
64
66
  スキーマに適合する output を検証して報告する。出口が複数あれば AI が1つ選ぶ(複数出口=分岐)
65
67
  - **文体の既定は「短く」**:出力の文章は承認画面・Slack・顧客への回答としてそのまま人が読むため、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mawaru/sdk",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,7 +59,7 @@ secret)でも、**足りない値を推測・生成・空文字で代用しな
59
59
 
60
60
  疎通確認のあと `GET {MAWARU_API_URL}/tenants/{MAWARU_TENANT_ID}/openapi.json`
61
61
  (ヘッダ `x-api-key: $MAWARU_API_KEY`)を取得すると、機械 API 面
62
- (loops の一覧・作成・詳細・graph PUT、runs/start、runs/{runId}/cancel、runs/{runId}/complete、members、api-keys、steps の end / decision)の
62
+ (loops の一覧・作成・詳細・graph PUT・versions/publish、runs/start、runs/{runId} の参照・cancel・complete、members、api-keys、steps の end / decision)の
63
63
  パス・メソッド・リクエストボディ(Zod 由来の JSON Schema)が載っている。ここが常に正。
64
64
  段階導入中で未掲載のエンドポイントもある(その場合だけ `api/index.ts` 相当を辿る)。
65
65
 
@@ -73,7 +73,15 @@ repo との矛盾・使用中ノードの削除は 409。**graph PUT は冪等
73
73
 
74
74
  ## 手順
75
75
 
76
- 1. **設計**:要件から必要なノード(program / ai / human / wait / join)と流れを決め、ユーザーに一言で確認する。
76
+ ループは次の4段階で作る。**言われなくても必ず 1 のスケルトンから始め、段階を飛ばさない。**
77
+ graph の保存(`PUT loops/{loopId}/graph`)は常に下書き(draft)に入り、4 で publish するまで
78
+ 本番の run には使われない。
79
+
80
+ ### 1. スケルトンを作って合意する
81
+
82
+ ノードの配置と接続だけを下書きに保存する。中身(repo のプログラム・prompt)はまだ作らない。
83
+
84
+ 1. **設計**:要件から必要なノード(program / ai / human / wait / join)と流れを決める。
77
85
  1つの出口から複数の線を引くと枝が並行に走る(fan-out)。枝の結果を待ち合わせるときは
78
86
  合流(`join`)を置く:線のつながった入口が全部揃うまで待ち、`{ [入口の key]: 値 }` にまとめて
79
87
  `main` から流す。並行した枝を合流させずに同じノード(待機など)へ戻すと、枝の数だけ走り続ける。
@@ -83,26 +91,56 @@ repo との矛盾・使用中ノードの削除は 409。**graph PUT は冪等
83
91
  (`component.component_loop_id`=作った for)を置く。配置ノードの in / out は中身の型の配列に
84
92
  自動で焼き込まれ、全件そろうと入力と同じ順の配列で `main` から流れる(1件ずつは同時に走る)。
85
93
  中身の出口(end につないだ in)は1つにする。
86
- 2. **repo 側の準備**:足りない program / ai は `nodes/program/<dir>/` / `nodes/ai/<dir>/` に作る
87
- (config.json → `npx mawaru typegen` → main.ts 実装 → `npx mawaru validate`)。
88
- **AI ノードの決定論的な処理は prompt ではなく `setup.sh` に書く**(下記)。
89
- **作った・変えたものは GitHub へ push してから次へ進む**(mawaru は repo を GitHub 経由で
90
- 読むため、未 push だと graph 保存の検証・書き戻しと食い違って 409 になる)。
91
- 3. **ループ作成**:`POST loops`。
92
- 4. **graph ボディを組む**:
94
+ 2. **ループ作成**:`POST loops`(既存ループの更新なら不要)。
95
+ 3. **graph ボディを組む**(中身は空のまま):
93
96
  - node / port の `id` はクライアント採番(`uuidgen` などで UUID を作る)
94
- - program / ai ノードの ports は repo の config.json の inputs / outputs をそのまま写す
95
- (in ポート key は `in`、out ポートは outputs の各 key)
96
- - kind 別の固定ポート構成(human / wait / ai / join)は `port-spec.ts` の `kindPortsViolation` に従う。
97
- join は in を枝の数だけ(key 自由。例 `in_1`, `in_2`)+ out `main`。出口の型は自動で決まる
97
+ - program / ai ノードには `program` / `ai`(dir の指定)を付けない。dir 未設定のノードは
98
+ repo へ書き戻されないので、repo 側に何も無くても保存できる
99
+ - ポートは kind 別の固定構成(`port-spec.ts` の `kindPortsViolation`)と分岐する出口の key だけ
100
+ 決め、`schema` は `{}`(未宣言)でよい。型は 2 で config.json と一緒に決める。
101
+ program の in は `in`。join は in を枝の数だけ(key 自由。例 `in_1`, `in_2`)+ out `main`
102
+ - human は担当(`human.assignment.units`)が保存に必須なので、誰が承認するかもここで決める
98
103
  - ループ変数(refs=run 全体で読める key→値 の袋)が要るならトップレベルの `refs` に入れる。
99
104
  ノード単位の宣言やバインドは無い。handler は実行時に `refs.json` で全量を受け取り、
100
105
  `end` の `refs` パッチで値を更新できる(config.json の `refs` はループ定義からの転写=生成物)。
101
106
  キー `input` は予約(run の起動入力が自動で入る。定義に置くと 422)
102
107
  - `position_x` / `position_y` は左→右に流れる単純配置(例: x を 320 刻み、y は 240)
103
- 5. **保存**:`PUT loops/{loopId}/graph`。400 / 422 / 409 ならメッセージを読んで直して再送。
104
- 6. **報告**:エディタ URL `{MAWARU_APP_URL}/loops/{loopId}/edit` をユーザーに渡し、
105
- 見た目の確認と run はユーザーに委ねる。
108
+ 4. **保存**:`PUT loops/{loopId}/graph`。400 / 422 / 409 ならメッセージを読んで直して再送。
109
+ 5. **合意**:エディタ URL `{MAWARU_APP_URL}/loops/{loopId}/edit` をユーザーに渡し、ノードの並びと
110
+ つながりを見てもらう。**OK をもらうまで 2 へ進まない**(直しの依頼は graph を直して保存し、
111
+ もう一度見てもらう)。
112
+
113
+ ### 2. 各ノードの中身を実装し、1つずつ確かめる
114
+
115
+ 上流のノードから順に、1つずつ次を繰り返す:
116
+
117
+ 1. **repo 側の実装**:`nodes/program/<dir>/` / `nodes/ai/<dir>/` を作る
118
+ (config.json → `npx mawaru typegen` → main.ts 実装 → `npx mawaru validate`)。
119
+ config.json の inputs / outputs はスケルトンのポートの key に合わせ、ここで型を決める。
120
+ **AI ノードの決定論的な処理は prompt ではなく `setup.sh` に書く**(下記)。
121
+ **GitHub へ push してから次へ進む**(mawaru は repo を GitHub 経由で読むため、未 push だと
122
+ graph 保存の書き戻しと食い違って 409 になり、テスト実行も古い中身で走る)。
123
+ 2. **ノードに結びつける**:graph の該当ノードに `program.program_dir` / `ai.ai_dir` を設定し、
124
+ ports を config.json の inputs / outputs の写しにして保存する(in ポート key は `in`、
125
+ out ポートは outputs の各 key)。human は承認ビュー、wait は待ち方もここで設定する。
126
+ 3. **単体で確かめる**:そのノードだけをテスト実行する。`POST runs/start` に `kind: "test"`・
127
+ `test_scope: "only"`・`start_node_id`(そのノード)・`payload`(サンプル入力。上流を確かめ済みなら
128
+ その output を使う)・`start_by`(ユーザーの user_id。`GET members` で引く。API キー起動では必須)を
129
+ 渡し、`GET runs/{runId}` で step の status と output を見る。下書きの program / ai は
130
+ ブランチ先頭の commit で走る。失敗したら直して push し、もう一度実行する。
131
+ human はテスト実行の担当が起動者になるので、承認・差し戻しはユーザーに操作してもらう。
132
+
133
+ ### 3. 全体を通して確かめる
134
+
135
+ 開始ノード(kind `start`)を `start_node_id` に、`test_scope: "from"` でテスト実行し、end まで
136
+ 流れることを確かめる(分岐があれば主な道を一通り)。human を通る道はユーザーに操作してもらう。
137
+ 結果をユーザーに報告する。
138
+
139
+ ### 4. publish して起動する
140
+
141
+ ユーザーの OK をもらってから `POST loops/{loopId}/versions/publish` で下書きを公開する
142
+ (AI / Program の実行 commit はここで固定される)。続けて `POST runs/start`(`loop_id`・`payload`。
143
+ `kind` を省くと本番 run)で起動し、run の URL `{MAWARU_APP_URL}/runs/{runId}` をユーザーに渡す。
106
144
 
107
145
  ## AI ノード:出力は短く(既定)
108
146