@mawaru/sdk 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/_schemas/graph.ts CHANGED
@@ -292,9 +292,10 @@ export const graphNodeSchema = z.object({
292
292
  position_y: z.number(),
293
293
  // 表示アイコン(全 kind 共通の任意設定。human はフロントが担当から導出するので通常設定しない)
294
294
  icon: nodeIconSchema.optional(),
295
- // Slack ビュー(全 kind 共通の任意設定。承認待ち・完了メッセージの見せ方)。
296
- // human の view と同じ「不透明なキー」で、実在・可視性の検証は保存時に mawaru 側が行う
297
- // (カタログは非公開。@mawaru/common の slack-view.ts)。
295
+ // Slack ビュー(全 kind 共通の任意設定。実行中(human は承認待ち)・完了メッセージの見せ方)。
296
+ // human の view と同じ「不透明なキー」で、選べる key は GET slack-views が返す。
297
+ // <slug>/<name> は実行 repo の views/slack/<name>/(@mawaru/sdk/slack-view の契約で書く)。
298
+ // 保存時に mawaru が見るのは common/* の実在と <slug>/* の可視性だけ。
298
299
  // 省略=承認待ちは承認ボタンだけ・完了は ✅ の1行(データは出さない)。
299
300
  // ポートの型には一切影響しない
300
301
  slack_view: z.string().min(1).optional(),
@@ -0,0 +1,10 @@
1
+ import { type Credentials } from "./credentials.js";
2
+ export type MawaruApi = (method: "GET" | "POST", path: string, body?: unknown) => Promise<{
3
+ status: number;
4
+ body: unknown;
5
+ }>;
6
+ export declare const mawaruApiFor: (credentials: Credentials, tenantId: string) => MawaruApi;
7
+ export declare const expectOk: (what: string, res: {
8
+ status: number;
9
+ body: unknown;
10
+ }) => unknown;
@@ -0,0 +1,28 @@
1
+ import { accessTokenFrom } from "./credentials.js";
2
+ export const mawaruApiFor = (credentials, tenantId) => async (method, path, body) => {
3
+ const token = await accessTokenFrom(credentials);
4
+ const res = await fetch(`${credentials.api_url}/tenants/${tenantId}${path}`, {
5
+ method,
6
+ headers: {
7
+ Authorization: `Bearer ${token}`,
8
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
9
+ },
10
+ body: body === undefined ? undefined : JSON.stringify(body),
11
+ });
12
+ const text = await res.text();
13
+ try {
14
+ return { status: res.status, body: text ? JSON.parse(text) : null };
15
+ }
16
+ catch {
17
+ return { status: res.status, body: { message: text } };
18
+ }
19
+ };
20
+ // 2xx 以外は API の message を添えて失敗にする(403 は admin でない・409 は Slack 未接続 等)
21
+ export const expectOk = (what, res) => {
22
+ if (res.status >= 200 && res.status < 300)
23
+ return res.body;
24
+ const message = res.body && typeof res.body === "object" && "message" in res.body
25
+ ? String(res.body.message)
26
+ : `HTTP ${res.status}`;
27
+ throw new Error(`${what}に失敗しました(${res.status}): ${message}`);
28
+ };
@@ -0,0 +1,18 @@
1
+ import { type MawaruApi } from "./previewApi.js";
2
+ import { type SlackSurface } from "./previewSlack.js";
3
+ export type PreviewOptions = {
4
+ target: string;
5
+ case?: string;
6
+ surface?: SlackSurface;
7
+ channel?: string;
8
+ };
9
+ export declare const parsePreviewArgs: (args: string[]) => PreviewOptions;
10
+ type Log = (line: string) => void;
11
+ export declare const runPreviewWith: (params: {
12
+ root: string;
13
+ options: PreviewOptions;
14
+ api: MawaruApi;
15
+ log: Log;
16
+ }) => Promise<number>;
17
+ export declare const runPreview: (args: string[]) => Promise<number>;
18
+ export {};
@@ -0,0 +1,109 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { requireCredentials } from "./credentials.js";
5
+ import { expectOk, mawaruApiFor } from "./previewApi.js";
6
+ import { readViewFiles, resolveView } from "./previewFiles.js";
7
+ import { previewSlack, SLACK_SURFACES, } from "./previewSlack.js";
8
+ // `mawaru preview <種類>/<名前> [--case <ケース名>] [--surface running|completed] [--channel <#名前|ID>]`
9
+ // (実行 repo のルートで実行)
10
+ //
11
+ // views/<種類>/<名前>/ のビューを、push せずに手元のファイルのまま確かめる。描画は mawaru API がやるので、
12
+ // `mawaru login` のセッション(web と同じ membership)と、どのテナントのつもりで描くかの
13
+ // MAWARU_TENANT_ID(repo の .env。create-loop スキルと同じ)が要る。
14
+ // ファイルの集め方・cases.ts の評価・ケースの絞り込みは種類をまたいで共通で、種類ごとに「確かめ方」
15
+ // (Slack は送る。承認ビューは repo へ移すときに足す)だけを差し替える
16
+ // (docs/tasks/wip/Slackビューの顧客repo配置.md の「手元プレビュー」)
17
+ const USAGE = "使い方: npx mawaru preview <種類>/<名前> [--case <ケース名>] [--surface running|completed] [--channel <#名前|チャンネル ID>]\n" +
18
+ " 例: npx mawaru preview slack/pr-review(views/slack/pr-review/ の cases.ts の全ケースを Slack に送る)";
19
+ export const parsePreviewArgs = (args) => {
20
+ const { values, positionals } = parseArgs({
21
+ args,
22
+ allowPositionals: true,
23
+ options: {
24
+ case: { type: "string" },
25
+ surface: { type: "string" },
26
+ channel: { type: "string" },
27
+ },
28
+ });
29
+ const [target, ...extra] = positionals;
30
+ if (!target || extra.length > 0)
31
+ throw new Error(USAGE);
32
+ if (values.surface !== undefined &&
33
+ !SLACK_SURFACES.includes(values.surface)) {
34
+ throw new Error("--surface には running か completed を指定してください");
35
+ }
36
+ return {
37
+ target,
38
+ ...(values.case !== undefined ? { case: values.case } : {}),
39
+ ...(values.surface !== undefined
40
+ ? { surface: values.surface }
41
+ : {}),
42
+ ...(values.channel !== undefined ? { channel: values.channel } : {}),
43
+ };
44
+ };
45
+ const PREVIEWERS = {
46
+ slack: {
47
+ casesPath: "/slack-views/cases",
48
+ run: ({ name, files, cases, options, api, log }) => {
49
+ const channel = options.channel ?? process.env.MAWARU_PREVIEW_CHANNEL;
50
+ if (!channel) {
51
+ throw new Error("送り先のチャンネルを --channel か repo の .env の MAWARU_PREVIEW_CHANNEL で指定してください(#名前 かチャンネル ID)");
52
+ }
53
+ return previewSlack({
54
+ name,
55
+ files,
56
+ cases: cases,
57
+ surfaces: options.surface ? [options.surface] : SLACK_SURFACES,
58
+ channel,
59
+ api,
60
+ log,
61
+ });
62
+ },
63
+ },
64
+ };
65
+ // 終了コードを返す(描画に失敗したものがあれば 1)。テストはここを呼ぶ
66
+ export const runPreviewWith = async (params) => {
67
+ const { root, options, api, log } = params;
68
+ const view = resolveView(root, options.target);
69
+ const files = readViewFiles(root, view);
70
+ const previewer = PREVIEWERS[view.kind];
71
+ const { cases } = expectOk("ケースの読み込み", await api("POST", previewer.casesPath, { files }));
72
+ const picked = options.case === undefined
73
+ ? cases
74
+ : cases.filter((c) => c.name === options.case);
75
+ if (picked.length === 0) {
76
+ throw new Error(options.case === undefined
77
+ ? "cases.ts にケースがありません"
78
+ : `ケース「${options.case}」がありません(ある: ${cases.map((c) => c.name).join(", ")})`);
79
+ }
80
+ const failed = await previewer.run({
81
+ name: view.name,
82
+ files,
83
+ cases: picked,
84
+ options,
85
+ api,
86
+ log,
87
+ });
88
+ if (failed > 0)
89
+ log(`失敗 ${failed} 件`);
90
+ return failed > 0 ? 1 : 0;
91
+ };
92
+ export const runPreview = async (args) => {
93
+ const options = parsePreviewArgs(args);
94
+ const root = process.cwd();
95
+ const env = join(root, ".env");
96
+ if (existsSync(env))
97
+ process.loadEnvFile(env);
98
+ const tenantId = process.env.MAWARU_TENANT_ID;
99
+ if (!tenantId) {
100
+ throw new Error("MAWARU_TENANT_ID が未設定です。repo の .env に MAWARU_TENANT_ID=<テナントの ID> を書いてください。");
101
+ }
102
+ const credentials = requireCredentials();
103
+ return runPreviewWith({
104
+ root,
105
+ options,
106
+ api: mawaruApiFor(credentials, tenantId),
107
+ log: (line) => console.log(line),
108
+ });
109
+ };
@@ -0,0 +1,9 @@
1
+ export declare const VIEW_KINDS: readonly ["slack"];
2
+ export type ViewKind = (typeof VIEW_KINDS)[number];
3
+ export type ViewTarget = {
4
+ kind: ViewKind;
5
+ name: string;
6
+ };
7
+ export declare const listViews: (root: string) => ViewTarget[];
8
+ export declare const resolveView: (root: string, spec: string) => ViewTarget;
9
+ export declare const readViewFiles: (root: string, view: ViewTarget) => Record<string, string>;
@@ -0,0 +1,64 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // 手元プレビュー(`mawaru preview`)が読む、実行 repo の views/<種類>/<名前>/ のファイル。
4
+ // 描画は mawaru API がやるので、ここは「対象を決めてファイルを集める」だけで、種類をまたいで共通
5
+ // (docs/tasks/wip/Slackビューの顧客repo配置.md の「手元プレビュー」)
6
+ // 手元プレビューできるビューの種類(views/ 直下のディレクトリ名)。承認ビューを repo へ移すときに
7
+ // "approval" を足す(previewCommand.ts の振り分けにも足す)
8
+ export const VIEW_KINDS = ["slack"];
9
+ const dirsIn = (dir) => {
10
+ try {
11
+ return readdirSync(dir, { withFileTypes: true })
12
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
13
+ .map((entry) => entry.name)
14
+ .sort();
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ };
20
+ // views/<種類>/ 直下のディレクトリ(=ビュー)。無ければ空
21
+ export const listViews = (root) => VIEW_KINDS.flatMap((kind) => dirsIn(join(root, "views", kind)).map((name) => ({ kind, name })));
22
+ const labelOf = (view) => `${view.kind}/${view.name}`;
23
+ // 指定(views/slack/pr・slack/pr・pr)をビューに決める。一覧にあるものだけを受ける
24
+ // (../ でディレクトリの外を読ませない)。決まらなければ、あるビューを添えて失敗する
25
+ export const resolveView = (root, spec) => {
26
+ const normalized = spec
27
+ .replace(/^\.\//, "")
28
+ .replace(/^views\//, "")
29
+ .replace(/\/+$/, "");
30
+ const views = listViews(root);
31
+ const found = normalized.includes("/")
32
+ ? views.filter((view) => labelOf(view) === normalized)
33
+ : views.filter((view) => view.name === normalized);
34
+ if (found.length === 1 && found[0])
35
+ return found[0];
36
+ const known = views.length === 0
37
+ ? "views/ にビューがありません"
38
+ : `ある対象: ${views.map(labelOf).join(", ")}`;
39
+ throw new Error(found.length > 1
40
+ ? `${spec} は複数の種類にあります。<種類>/<名前> で指定してください(${found.map(labelOf).join(", ")})`
41
+ : `${spec} が見つかりません(${known})`);
42
+ };
43
+ // mawaru API へ送る files({ ビューのディレクトリからの相対パス(/ 区切り): 中身 })。
44
+ // .ts だけをサブディレクトリまで集める(上限の検証は API 側)
45
+ export const readViewFiles = (root, view) => {
46
+ const dir = join(root, "views", view.kind, view.name);
47
+ const files = {};
48
+ const walk = (relative) => {
49
+ for (const entry of readdirSync(join(dir, relative), {
50
+ withFileTypes: true,
51
+ })) {
52
+ if (entry.name.startsWith("."))
53
+ continue;
54
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
55
+ if (entry.isDirectory())
56
+ walk(path);
57
+ else if (entry.isFile() && entry.name.endsWith(".ts")) {
58
+ files[path] = readFileSync(join(dir, path), "utf8");
59
+ }
60
+ }
61
+ };
62
+ walk("");
63
+ return files;
64
+ };
@@ -0,0 +1,24 @@
1
+ import type { SlackViewCase } from "../slack-view.js";
2
+ import { type MawaruApi } from "./previewApi.js";
3
+ export type SlackSurface = "running" | "completed";
4
+ export declare const SLACK_SURFACES: SlackSurface[];
5
+ export declare const previewBodyOf: (files: Record<string, string>, testCase: SlackViewCase, surface: SlackSurface) => {
6
+ refs?: {} | null | undefined;
7
+ output_schema?: Record<string, unknown> | undefined;
8
+ output?: {} | null | undefined;
9
+ input_schema?: Record<string, unknown> | undefined;
10
+ files: Record<string, string>;
11
+ surface: SlackSurface;
12
+ kind: "start" | "join" | "ai" | "human" | "program" | "guardrail" | "wait" | "end" | "component";
13
+ node_name: string;
14
+ input: unknown;
15
+ };
16
+ export declare const previewSlack: (params: {
17
+ name: string;
18
+ files: Record<string, string>;
19
+ cases: SlackViewCase[];
20
+ surfaces: SlackSurface[];
21
+ channel: string;
22
+ api: MawaruApi;
23
+ log: (line: string) => void;
24
+ }) => Promise<number>;
@@ -0,0 +1,67 @@
1
+ import { expectOk } from "./previewApi.js";
2
+ export const SLACK_SURFACES = ["running", "completed"];
3
+ const SURFACE_LABEL = {
4
+ running: "実行中",
5
+ completed: "完了",
6
+ };
7
+ // ケース(cases.ts の SlackViewCase)を mawaru のプレビュー API の body にする
8
+ export const previewBodyOf = (files, testCase, surface) => ({
9
+ files,
10
+ surface,
11
+ kind: testCase.kind ?? "human",
12
+ node_name: testCase.nodeName ?? testCase.name,
13
+ input: testCase.input,
14
+ ...(testCase.inputSchema !== undefined
15
+ ? { input_schema: testCase.inputSchema }
16
+ : {}),
17
+ ...(testCase.output !== undefined ? { output: testCase.output } : {}),
18
+ ...(testCase.outputSchema !== undefined
19
+ ? { output_schema: testCase.outputSchema }
20
+ : {}),
21
+ ...(testCase.refs !== undefined ? { refs: testCase.refs } : {}),
22
+ });
23
+ // 送り先:チャンネル ID はそのまま、#名前(または名前)は mawaru のボットが見えるチャンネルから引き直す
24
+ const CHANNEL_ID = /^[CGD][A-Z0-9]{6,}$/;
25
+ const resolveChannel = async (api, channel) => {
26
+ if (!channel.startsWith("#") && CHANNEL_ID.test(channel))
27
+ return channel;
28
+ const name = channel.replace(/^#/, "");
29
+ const body = expectOk("チャンネルの一覧の取得", await api("GET", "/integrations/slack/channels"));
30
+ const found = (body.channels ?? []).find((c) => c.name === name);
31
+ if (!found) {
32
+ throw new Error(`#${name} が見つかりません(mawaru のボットが参加しているチャンネルを指定してください)`);
33
+ }
34
+ return found.id;
35
+ };
36
+ // 送ったうちの、描画に失敗した(フォールバックした理由がある)数を返す
37
+ export const previewSlack = async (params) => {
38
+ const { name, files, cases, surfaces, api, log } = params;
39
+ const channelId = await resolveChannel(api, params.channel);
40
+ log(`🧪 ${name}:${cases.length} ケース × ${surfaces.length} 面を ${params.channel} に送ります`);
41
+ let thread;
42
+ let failed = 0;
43
+ for (const testCase of cases) {
44
+ for (const surface of surfaces) {
45
+ const result = expectOk("Slack への送信", await api("POST", "/slack-views/test-post", {
46
+ ...previewBodyOf(files, testCase, surface),
47
+ channel_id: channelId,
48
+ ...(thread
49
+ ? { thread_ts: thread }
50
+ : { header: `🧪 ${name} のプレビュー` }),
51
+ }));
52
+ thread ??= result.thread_ts;
53
+ const label = `${testCase.name}/${SURFACE_LABEL[surface]}`;
54
+ if (result.error) {
55
+ failed += 1;
56
+ log(`⚠ ${label}:フォールバック(${result.error})`);
57
+ }
58
+ else if (result.fallback) {
59
+ log(`– ${label}:未実装(mawaru の既定の表示)`);
60
+ }
61
+ else {
62
+ log(`✔ ${label}`);
63
+ }
64
+ }
65
+ }
66
+ return failed;
67
+ };
@@ -78,8 +78,8 @@ export declare const aiManifestSchema: z.ZodObject<{
78
78
  "gpt-5.6-luna": "gpt-5.6-luna";
79
79
  }>>;
80
80
  auth_mode: z.ZodOptional<z.ZodEnum<{
81
- subscription: "subscription";
82
81
  api: "api";
82
+ subscription: "subscription";
83
83
  }>>;
84
84
  skills: z.ZodDefault<z.ZodArray<z.ZodString>>;
85
85
  env: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -235,9 +235,10 @@ export const graphNodeSchema = z.object({
235
235
  position_y: z.number(),
236
236
  // 表示アイコン(全 kind 共通の任意設定。human はフロントが担当から導出するので通常設定しない)
237
237
  icon: nodeIconSchema.optional(),
238
- // Slack ビュー(全 kind 共通の任意設定。承認待ち・完了メッセージの見せ方)。
239
- // human の view と同じ「不透明なキー」で、実在・可視性の検証は保存時に mawaru 側が行う
240
- // (カタログは非公開。@mawaru/common の slack-view.ts)。
238
+ // Slack ビュー(全 kind 共通の任意設定。実行中(human は承認待ち)・完了メッセージの見せ方)。
239
+ // human の view と同じ「不透明なキー」で、選べる key は GET slack-views が返す。
240
+ // <slug>/<name> は実行 repo の views/slack/<name>/(@mawaru/sdk/slack-view の契約で書く)。
241
+ // 保存時に mawaru が見るのは common/* の実在と <slug>/* の可視性だけ。
241
242
  // 省略=承認待ちは承認ボタンだけ・完了は ✅ の1行(データは出さない)。
242
243
  // ポートの型には一切影響しない
243
244
  slack_view: z.string().min(1).optional(),
package/dist/cli.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { relative } from "node:path";
3
3
  import { clearCredentials, loadCredentials } from "./_cli/credentials.js";
4
4
  import { runLogin } from "./_cli/login.js";
5
+ import { runPreview } from "./_cli/previewCommand.js";
5
6
  import { runSessionResume } from "./_cli/sessionCommand.js";
6
7
  import { runInit } from "./init.js";
7
8
  import { runTypegen } from "./typegen.js";
8
9
  import { validateRepo } from "./validate.js";
9
- // 使い方: mawaru <init|typegen|validate|login|logout|session> [引数]
10
+ // 使い方: mawaru <init|typegen|validate|login|logout|session|preview> [引数]
10
11
  const USAGE = `使い方: mawaru <command> [引数]
11
12
 
12
13
  コマンド:
@@ -16,7 +17,9 @@ const USAGE = `使い方: mawaru <command> [引数]
16
17
  login ブラウザで承認して mawaru にログインする(~/.mawaru/credentials.json)
17
18
  logout 保存した資格情報を消す
18
19
  session resume <step URL>
19
- AI ノードのセッションを手元に復元して CLI(claude / codex)の resume で再開する`;
20
+ AI ノードのセッションを手元に復元して CLI(claude / codex)の resume で再開する
21
+ preview <種類>/<名前> [--case <ケース名>] [--surface running|completed] [--channel <#名前|ID>]
22
+ views/ のビューを push せずに手元のファイルで確かめる(slack:cases.ts のケースを Slack に送る)`;
20
23
  const [command, ...rest] = process.argv.slice(2);
21
24
  // init / typegen / validate は repo ルートを任意で受ける(省略時はカレント)
22
25
  const root = rest[0] ?? process.cwd();
@@ -92,6 +95,15 @@ switch (command) {
92
95
  }
93
96
  break;
94
97
  }
98
+ case "preview": {
99
+ try {
100
+ process.exit(await runPreview(rest));
101
+ }
102
+ catch (e) {
103
+ fail(e instanceof Error ? e.message : String(e));
104
+ }
105
+ break;
106
+ }
95
107
  default: {
96
108
  console.error(USAGE);
97
109
  process.exit(command === undefined || command === "--help" ? 0 : 1);
@@ -0,0 +1,58 @@
1
+ import type { JsonSchema, NodeKind } from "./_schemas/node.js";
2
+ export type SlackBlocks = unknown[];
3
+ export type FileRef = {
4
+ path: string;
5
+ name: string;
6
+ mime_type?: string;
7
+ size?: number;
8
+ };
9
+ export type { JsonSchema, NodeKind };
10
+ export type SlackViewMaterial<TIn = unknown, TOut = unknown> = {
11
+ input: TIn;
12
+ output?: TOut;
13
+ };
14
+ export type SlackViewContext<TIn = unknown> = {
15
+ kind: NodeKind;
16
+ input: TIn;
17
+ inputSchema: JsonSchema;
18
+ refs?: unknown;
19
+ additionalOutputs?: {
20
+ key: string;
21
+ name: string;
22
+ schema: JsonSchema;
23
+ }[];
24
+ nodeName: string;
25
+ stepUrl: string;
26
+ fileUrls: Record<string, string>;
27
+ };
28
+ export type SlackCompletedContext<TIn = unknown, TOut = unknown> = {
29
+ input: TIn;
30
+ output: TOut;
31
+ inputSchema: JsonSchema;
32
+ outputSchema: JsonSchema;
33
+ refs?: unknown;
34
+ nodeName: string;
35
+ runUrl: string;
36
+ fileUrls: Record<string, string>;
37
+ };
38
+ export type SlackView<TIn = unknown, TOut = unknown> = {
39
+ files?: (m: SlackViewMaterial<TIn, TOut>) => FileRef[];
40
+ renderRunning?: (ctx: SlackViewContext<TIn>) => SlackBlocks;
41
+ renderCompleted?: (ctx: SlackCompletedContext<TIn, TOut>) => SlackBlocks;
42
+ };
43
+ export type SlackViewCase = {
44
+ name: string;
45
+ kind?: NodeKind;
46
+ nodeName?: string;
47
+ input: unknown;
48
+ inputSchema?: JsonSchema;
49
+ output?: unknown;
50
+ outputSchema?: JsonSchema;
51
+ refs?: unknown;
52
+ };
53
+ export declare const decisionActionId: (portKey: string) => string;
54
+ export declare const decisionFormActionId: (portKey: string) => string;
55
+ export declare const parseDecisionActionId: (actionId: string | undefined) => {
56
+ port_key: string;
57
+ form: boolean;
58
+ } | null;
@@ -0,0 +1,21 @@
1
+ // ---- 決定ボタンのプロトコル ----
2
+ // ビューはボタンの action_id だけで「どの出口ポートへ流すか」を宣言する
3
+ // (step の特定は message ts の逆引き。value は使わない):
4
+ // decision:<port_key> … そのまま確定。ホストが { [port_key]: step.input } で確定する
5
+ // decision-form:<port_key> … そのノードの out ポート <port_key> のスキーマから生成した
6
+ // モーダルで値を集め、{ [port_key]: 入力値 } で確定する
7
+ // human の出口が approve / reject 以外に増えても、ビューがボタンを置くだけで流せる。
8
+ // 既存の "approve"(標準ビュー・投稿済みメッセージ)は従来の承認経路のまま動く
9
+ const DECISION_PREFIX = "decision:";
10
+ const DECISION_FORM_PREFIX = "decision-form:";
11
+ export const decisionActionId = (portKey) => `${DECISION_PREFIX}${portKey}`;
12
+ export const decisionFormActionId = (portKey) => `${DECISION_FORM_PREFIX}${portKey}`;
13
+ export const parseDecisionActionId = (actionId) => {
14
+ if (actionId?.startsWith(DECISION_FORM_PREFIX)) {
15
+ return { port_key: actionId.slice(DECISION_FORM_PREFIX.length), form: true };
16
+ }
17
+ if (actionId?.startsWith(DECISION_PREFIX)) {
18
+ return { port_key: actionId.slice(DECISION_PREFIX.length), form: false };
19
+ }
20
+ return null;
21
+ };
@@ -20,6 +20,8 @@ nodes/
20
20
  ai/<dir>/ # AI ノード定義:config.json
21
21
  hook/<dir>/ # NodeIOHook:config.json + main.ts(ノードに付く観測フック)
22
22
  skills/<dir>/ # AI ノードが参照する skill(SKILL.md)
23
+ views/
24
+ slack/<name>/ # Slack ビュー:index.ts(+手元プレビュー用の cases.ts)
23
25
  ```
24
26
 
25
27
  ## Program ノード(`nodes/program/<dir>/`)
@@ -70,6 +72,31 @@ skills/<dir>/ # AI ノードが参照する skill(SKILL.md)
70
72
  - ノードの入出力・外部シグナルを観測する独立実行。正は `_schemas/hook-manifest.ts`
71
73
  (`on`: input / output / signal の宣言・`env`)
72
74
 
75
+ ## Slack ビュー(`views/slack/<name>/`)
76
+
77
+ - ノードの Slack メッセージの本文を描く。**実行中**(全 kind。human は承認待ち)と**完了**の2面。
78
+ ノードの「Slack ビュー」に `<テナントの slug>/<name>` を選ぶと使われる(エディタの選択肢に
79
+ ディレクトリ名で出る)。タイトル行・追加出口のボタン・投稿は mawaru が描く
80
+ - `index.ts` — `export default { files?, renderRunning?, renderCompleted? } satisfies SlackView<入力の型>`。
81
+ 契約(型・ctx の中身・決定ボタンの `decisionActionId` / `decisionFormActionId`)の正は
82
+ `@mawaru/sdk/slack-view`。実装した関数の面だけが変わる
83
+ - **純関数**にする:mawaru が隔離環境(QuickJS)で動かすので、`fetch` / `process` / `require` / タイマー /
84
+ `console` は無い。1回の呼び出しは 200ms・32MB まで。ctx も戻り値も JSON で受け渡す
85
+ - import できるのは同じディレクトリ内の相対パスと `@mawaru/sdk/slack-view` だけ(npm パッケージは不可)。
86
+ ビルドしない(mawaru が TypeScript の型を外して読む。`enum` など型を外すだけで JS にならない書き方は不可)
87
+ - 入力は崩れて届くことがある(`input` が `null` 等)。例外を投げない書き方にする。失敗すると、承認待ちは
88
+ 承認ボタンだけ・完了は1行のフォールバックになる
89
+ - 版:ループの**公開版は publish 時のデフォルトブランチの commit に固定**される。push しただけでは本番の
90
+ 通知は変わらず、ループを publish し直すと次の run から変わる。テスト実行は push だけで変わる
91
+ - `cases.ts` — 手元プレビューのケース(`SlackViewCase[]` を `export default`)。本番の描画では読まない
92
+ - 手元プレビュー:`npx mawaru login` のあと、repo ルートで `npx mawaru preview slack/<name>`。push せずに
93
+ 手元のファイルのまま、`cases.ts` の全ケースを実行中・完了の両方の面で実際の Slack に送る(mawaru API が本番と
94
+ 同じ組み立てで描く。1回の実行ごとに見出しを1つ立て、全部をそのスレッドに入れる)
95
+ - `--case <ケース名>`・`--surface running|completed` で絞る。送り先は `--channel`(`#名前` かチャンネル ID)か
96
+ `.env` の `MAWARU_PREVIEW_CHANNEL`。`.env` の `MAWARU_TENANT_ID` も要る。送れるのはテナントの admin だけ
97
+ - 端末に1通ごとの結果が出る(`✔` 成功・`⚠` 描画に失敗して理由つき・`–` 未実装の面)。失敗が1つでもあれば exit 1。
98
+ ビューを直したら同じコマンドで送り直して確かめる
99
+
73
100
  ## loop(回路)の作成・更新
74
101
 
75
102
  - `.claude/skills/create-loop` スキルに従う(graph API の契約は `_schemas/graph.ts` が正)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mawaru/sdk",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,10 @@
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
+ },
14
+ "./slack-view": {
15
+ "types": "./dist/slack-view.d.ts",
16
+ "default": "./dist/slack-view.js"
13
17
  }
14
18
  },
15
19
  "files": [
@@ -18,7 +22,8 @@
18
22
  "templates",
19
23
  "docs",
20
24
  "_schemas",
21
- "index.ts"
25
+ "index.ts",
26
+ "slack-view.ts"
22
27
  ],
23
28
  "engines": {
24
29
  "node": ">=20"
@@ -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
 
package/slack-view.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { JsonSchema, NodeKind } from "./_schemas/node.js"
2
+
3
+ // Slack ビューの契約(`@mawaru/sdk/slack-view`)。ノードの Slack ビュー
4
+ // (nodes.slack_view_key)が実装する関数と、ホスト(mawaru)が渡す ctx の形の正。
5
+ // mawaru 内蔵のビュー(backend の _helpers/slackViews/)と、顧客の実行 repo の
6
+ // views/slack/<name>/ のビューが同じ契約で書く
7
+ // (docs/tasks/wip/Slackビューの顧客repo配置.md)。
8
+ //
9
+ // ビューは局面ごとの描画関数を**実装した分だけ**カスタマイズが効く任意のビュー:
10
+ // - renderRunning … 全 kind の実行中メッセージ(タイトル context 行より下の全ブロック)。
11
+ // human では承認待ちのメッセージになる。未実装なら human は承認ボタンだけの
12
+ // フォールバック、human 以外は従来のタイトル1行のまま
13
+ // - renderCompleted … 全 kind の完了メッセージ(✅ タイトル行より下の全ブロック)。
14
+ // 未実装なら従来の1行畳みのまま
15
+ // スレッドの解決・担当メンション・投稿・ts の保存・承認の認可はホストに残る。
16
+ //
17
+ // ビューは**純関数**:Slack API も DB も触らない(通知1本のために I/O するビューが
18
+ // 生まれると、ベストエフォート通知の失敗経路が読めなくなる)。必要な値は
19
+ // ホストが解決して ctx に載せる。ctx と戻り値は JSON で表現できる値だけにする
20
+ // (顧客 repo のビューは隔離環境で動き、値は JSON で受け渡す)。
21
+ //
22
+ // このファイルは実行時の import を持たない(型の import だけ):顧客 repo のビューは
23
+ // 隔離環境の中でこのファイルをそのまま import する
24
+
25
+ // Slack の Block Kit の blocks。形はビューの自由で、ホストは上限(50個)だけを守る
26
+ export type SlackBlocks = unknown[]
27
+
28
+ // ノード間を流れる file の値(storage path の参照)。署名 URL はホストが ctx.fileUrls で渡す
29
+ export type FileRef = {
30
+ path: string
31
+ name: string
32
+ mime_type?: string
33
+ size?: number
34
+ }
35
+
36
+ export type { JsonSchema, NodeKind }
37
+
38
+ // ビューが描く材料:input=step の入力、output=step の出力(選ばれた出口ポートの値。
39
+ // 実行中の時点では undefined)。files の宣言はこの material から行う
40
+ export type SlackViewMaterial<TIn = unknown, TOut = unknown> = {
41
+ input: TIn
42
+ output?: TOut
43
+ }
44
+
45
+ // 実行中メッセージの ctx(全 kind 共通)。TIn は in ポートの型
46
+ // (human なら承認ビューの宣言が転写されたポートの型)
47
+ export type SlackViewContext<TIn = unknown> = {
48
+ // ノードの kind。決定ボタン(承認・差し戻し)は human のときだけ意味を持つので、
49
+ // 複数の kind で使うビューはこれを見てボタンを描くか決める
50
+ kind: NodeKind
51
+ // ノードの in に流れてきた入力そのもの(ホストは加工しない)。
52
+ // human は [承認する] で確定するのもこの値なので、表示と承認内容は必ず一致する
53
+ input: TIn
54
+ // input の型(in ポートの schema そのもの)。未宣言のポートは {}。
55
+ // 標準ビューはこれで列と項目名を決める(宣言が無ければ値の実形から導出する)
56
+ inputSchema: JsonSchema
57
+ // run の袋(ループ変数。steps.refs=この step 開始時点のスナップショット)。
58
+ // 予約キー input が run の起動入力。省略=無し(古い run・プレビュー)
59
+ refs?: unknown
60
+ // ホストが接続済みの追加出口だけを渡す(approve / reject はビュー側が描く)。
61
+ // human 以外は出口がボタンにならないので常に無い
62
+ additionalOutputs?: { key: string; name: string; schema: JsonSchema }[]
63
+ nodeName: string
64
+ // step 画面(human なら承認画面)への URL。[修正する] / [確認する] のリンク先
65
+ stepUrl: string
66
+ // ビューが files で宣言したファイルの閲覧用署名 URL(path → URL)。
67
+ // ホストがベストエフォートで解決する:失敗した・tenant 外のパスは載らないので、
68
+ // ビューは「無いかもしれない」前提で描く(image ブロック等に使う)
69
+ fileUrls: Record<string, string>
70
+ }
71
+
72
+ // 完了メッセージの ctx。output は完了した step の出力そのもの(ホストは加工しない)
73
+ export type SlackCompletedContext<TIn = unknown, TOut = unknown> = {
74
+ input: TIn
75
+ output: TOut
76
+ // in ポートの schema と、選ばれた出口ポート(step.output_port_key)の schema。
77
+ // 未宣言のポートは {}
78
+ inputSchema: JsonSchema
79
+ outputSchema: JsonSchema
80
+ refs?: unknown
81
+ nodeName: string
82
+ // run 詳細画面への URL(完了メッセージからの誘導先)
83
+ runUrl: string
84
+ fileUrls: Record<string, string>
85
+ }
86
+
87
+ export type SlackView<TIn = unknown, TOut = unknown> = {
88
+ // ビューが画像等で使いたい material 内のファイル参照(純関数のまま宣言だけする)。
89
+ // 署名 URL の発行という I/O はホストの仕事で、結果は ctx.fileUrls で受け取る
90
+ files?: (m: SlackViewMaterial<TIn, TOut>) => FileRef[]
91
+ // 実行中(全 kind。human は承認待ち)。タイトル context 行より下の全ブロック
92
+ renderRunning?: (ctx: SlackViewContext<TIn>) => SlackBlocks
93
+ // 完了(全 kind)。✅ タイトル context 行より下の全ブロック
94
+ renderCompleted?: (ctx: SlackCompletedContext<TIn, TOut>) => SlackBlocks
95
+ }
96
+
97
+ // 手元プレビュー(npx mawaru preview)のケース。顧客 repo の
98
+ // views/slack/<name>/cases.ts が export default する。本番の描画では読まない
99
+ export type SlackViewCase = {
100
+ name: string
101
+ // ノードの kind。省略時は human(承認待ち)
102
+ kind?: NodeKind
103
+ // タイトル行のノード名。省略時は name
104
+ nodeName?: string
105
+ // ctx.input
106
+ input: unknown
107
+ // ctx.inputSchema。省略=未宣言({})
108
+ inputSchema?: JsonSchema
109
+ // 完了の ctx.output。省略時は input(approve が素通しのビューが大半)
110
+ output?: unknown
111
+ outputSchema?: JsonSchema
112
+ refs?: unknown
113
+ }
114
+
115
+ // ---- 決定ボタンのプロトコル ----
116
+ // ビューはボタンの action_id だけで「どの出口ポートへ流すか」を宣言する
117
+ // (step の特定は message ts の逆引き。value は使わない):
118
+ // decision:<port_key> … そのまま確定。ホストが { [port_key]: step.input } で確定する
119
+ // decision-form:<port_key> … そのノードの out ポート <port_key> のスキーマから生成した
120
+ // モーダルで値を集め、{ [port_key]: 入力値 } で確定する
121
+ // human の出口が approve / reject 以外に増えても、ビューがボタンを置くだけで流せる。
122
+ // 既存の "approve"(標準ビュー・投稿済みメッセージ)は従来の承認経路のまま動く
123
+
124
+ const DECISION_PREFIX = "decision:"
125
+ const DECISION_FORM_PREFIX = "decision-form:"
126
+
127
+ export const decisionActionId = (portKey: string): string =>
128
+ `${DECISION_PREFIX}${portKey}`
129
+
130
+ export const decisionFormActionId = (portKey: string): string =>
131
+ `${DECISION_FORM_PREFIX}${portKey}`
132
+
133
+ export const parseDecisionActionId = (
134
+ actionId: string | undefined,
135
+ ): { port_key: string; form: boolean } | null => {
136
+ if (actionId?.startsWith(DECISION_FORM_PREFIX)) {
137
+ return { port_key: actionId.slice(DECISION_FORM_PREFIX.length), form: true }
138
+ }
139
+ if (actionId?.startsWith(DECISION_PREFIX)) {
140
+ return { port_key: actionId.slice(DECISION_PREFIX.length), form: false }
141
+ }
142
+ return null
143
+ }
@@ -9,6 +9,7 @@
9
9
  - `nodes/program/<dir>/` — Program ノード(`config.json` + `main.ts`)。`main.ts` は
10
10
  `run(input, ctx)` を export し、戻り値は `{ <出口ポートのkey>: <データ> }` の単一キーのオブジェクト
11
11
  - `nodes/ai/<dir>/` — AI ノード定義(`config.json`。prompt / model / skills / 入出力スキーマ / env)
12
+ - `views/slack/<name>/` — Slack ビュー(実行中・承認待ち・完了メッセージの本文)。`index.ts` + 手元プレビュー用の `cases.ts`
12
13
  - `.github/workflows/mawaru-runner.yml` — mawaru 所有の実行シム。**編集しない**
13
14
  (`npx @mawaru/sdk init` が再生成する)
14
15
 
@@ -21,3 +22,5 @@ loop / node 開発にとりかかる前に、必ず `node_modules/@mawaru/sdk/do
21
22
  - 型生成:`npx mawaru typegen`(config.json → `types.d.ts`)
22
23
  - 規約検証:`npx mawaru validate`(CI 向け。error で exit 1)
23
24
  - ループの作成・更新:`.claude/skills/create-loop` スキルに従う
25
+ - Slack ビューの確認:`npx mawaru preview slack/<name>`(push せずに手元のファイルで `cases.ts` の全ケースを Slack に送る。
26
+ 失敗は理由が端末に出て exit 1。先に `npx mawaru login`、送り先は `.env` の `MAWARU_PREVIEW_CHANNEL`)