Compare commits

...

5 Commits

Author SHA1 Message Date
Akira
be5fd5a75b Merge branch 'main' of https://gitea.keinafarm.net/akira/windmill_workflow
# Conflicts:
#	docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md
2026-03-04 12:05:16 +09:00
Akira
9dec4b3ace ワークフロー全体の情報取得機能追加 2026-03-03 16:13:46 +09:00
Akira
d129777bf1 | 2026-03-03 | 運用単位を workflow package(flow + schedules)へ変更し、実装計画とRunbookを更新 |
| 2026-03-03 | `delete -> create` と hash 管理の運用ガード(preflight / fail closed / post-verify)およびCRLF対策を追記 |
2026-03-03 15:49:07 +09:00
Akira
5a0a668a8a 取り込み二回目 2026-03-03 15:42:12 +09:00
Akira
9c67910f3d サーバーからフローを取得 2026-03-03 15:25:09 +09:00
17 changed files with 1573 additions and 232 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -42,9 +42,10 @@
1. ローカルリポジトリは、サーバー側Gitをリモートにしない
2. Windmillの実体変更は **Windmill REST API 経由のみ**
3. 作業開始時にAPIでサーバー状態を取り込み、サーバーが新しければローカルへ同期
4. ローカル変更はAPIでサーバーへ反映
5. サーバー側の Git Sync Workflow は定期記録用途として継続
6. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う
4. **運用単位は「workflow packageflow + schedules」を基本**とする
5. ローカル変更はAPIでサーバーへ反映
6. サーバー側の Git Sync Workflow は定期記録用途として継続
7. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う
---
@@ -78,6 +79,13 @@
- ローカルファイルは「編集用ワークツリー + 監査ログ」
- ローカルGitはサーバー同期の必須経路ではない
### 管理単位(重要)
- 単体オブジェクト運用scriptだけ、flowだけは補助用途
- 標準運用は **workflow package 単位** とする
- `flow` 本体
- その `flow path` に紐づく `schedules``is_flow=true` かつ `script_path == flow.path`
### 既存の主要オブジェクト(例)
- script: `u/admin/alexa_speak`
@@ -98,11 +106,18 @@
- サーバー `updated_at` / `hash` とローカル管理メタ情報を比較
- サーバーが新しければローカルを更新
### 対象API
### 対象API(オブジェクト単体)
- `GET /api/w/{workspace}/scripts/get/p/{path}`
- `GET /api/w/{workspace}/flows/get/{path}`
- `GET /api/w/{workspace}/schedules/get/{path}`(必要時)
- `GET /api/w/{workspace}/schedules/get/{path}`
### workflow package Pull標準
1. `GET /flows/get/{flow_path}` で flow 本体取得
2. `GET /schedules/list` で schedule 一覧取得
3. `script_path == flow_path` の schedule 群を抽出
4. ローカルへ一括保存flow + schedules
## 5.2 Pushローカル -> サーバー)
@@ -132,6 +147,14 @@
1. `DELETE /flows/delete/{path}`
2. `POST /flows/create`
### schedule反映APIworkflow package の一部)
- workflow package Push時は、対象 flow に紐づく schedule も同時同期する
- 原則:
1. サーバー現行 schedule`script_path == flow_path`)一覧取得
2. ローカル定義との差分計算(追加・更新・削除)
3. `DELETE /schedules/delete/{path}``POST /schedules/create` で収束
---
## 6. 競合時の動作仕様
@@ -148,6 +171,15 @@
- 削除再作成前に最新を必ずPullしてローカル保存
- 競合が疑われる場合は自動実行せず手動確認にフォールバック
- **Preflight必須**: Push直前に `remote_index` の既知hashとサーバー現在hashを比較し、不一致ならPush中断fail closed
- Push後は `post-verify`再取得して期待JSON一致確認を必須化
### schedulePush競合
- `schedule.path` 重複や同名上書きに注意
- workflow package Push時は、対象 flow の schedule 群をまとめて同期し、中途半端な状態を残さない
- 失敗時は flow と schedules の両方を再取得して整合を確認
- schedule 同期も Preflight/`post-verify` の対象に含める
---
@@ -157,7 +189,7 @@
- 本ドキュメント作成
## Phase 1: API運用コマンド整備次タスク
## Phase 1: API運用コマンド整備完了
`wm-api.sh` へ追加:
@@ -168,17 +200,26 @@
5. `pull-all`scripts/flowsの一覧取得 + 一括保存)
6. `status-remote`ローカルとサーバーのhash比較
## Phase 2: メタ情報管理
## Phase 2: workflow package 対応(次タスク)
- `state/remote_index.json` を導入し、`path -> hash/updated_at` を保持
- Pull前後で差分判定可能にする
`wm-api.sh` へ追加:
## Phase 3: 標準化
1. `pull-workflow <flow_path>`flow + schedules 一括取得)
2. `push-workflow <workflow-dir>`flow反映 + schedules差分同期
3. `status-workflow [flow_path]`workflow単位の差分表示
4. `pull-all-workflows`flow全件を workflow package で取得)
## Phase 3: メタ情報管理
- `state/remote_index.json` を拡張し、`scripts/flows/schedules` を保持
- `state/workflow_index.json` を導入し、`workflow -> flow_hash + schedule_hashes` を保持
## Phase 4: 標準化
- `docs/flow-manage` に操作例を固定
- 「作業開始時は必ずpull」を運用ルール化
## Phase 4: 半自動化(任意)
## Phase 5: 半自動化(任意)
- AI/スクリプトで
- 変更検知
@@ -193,14 +234,16 @@
1. `pull-all` でサーバー最新を取得
2. ローカル編集
3. `push-script` / `push-flow` で反映
4. 必要に応じてローカルGitにコミット
3. workflow修正時は `push-workflow` で反映flow + schedules
4. script単体修正時のみ `push-script` を使う
5. 必要に応じてローカルGitにコミット
### 8.2 サーバーで変更されたものを取り込む
1. `status-remote` 実行
2. サーバー新規/更新分を `pull-*` で取得
3. ローカル履歴としてコミット(任意)
2. workflow単位の変更は `status-workflow` / `pull-workflow` で取得
3. 単体変更は `pull-*` で取得
4. ローカル履歴としてコミット(任意)
### 8.3 `alexa_speak` 更新例(現在の具体例)
@@ -233,7 +276,8 @@
1. Push前に対象オブジェクトをバックアップ保存必須
2. Push後に `post-verify`(再取得して期待値比較)を必須化
3. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
3. Push直前に `preflight hash check` を必須化し、不一致時はPush停止fail closed
4. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
### 10.2 標準復旧手順
@@ -251,6 +295,13 @@
2. 依存スケジュールの有効性を確認
3. 関連ジョブの手動実行で動作確認
### 10.4 delete -> create 運用ガード(必須)
1. `push-flow` / `push-workflow` 前に対象flowのバックアップJSONを必ず保存
2. Preflightで hash 不一致なら自動Pushしない手動レビューへフォールバック
3. Push後に flow と schedules を再取得し、ローカル期待値と一致確認
4. 不一致時は即時にバックアップから復元し、復旧ログを残す
---
## 11. Windmill依存を薄くする方針必須
@@ -261,7 +312,7 @@ Windmill API依存は避けられないため、依存点を最小化する。
- `scripts`: `list/get/create`
- `flows`: `list/get/create/delete`
- `schedules`: `list/get/create/delete`必要時
- `schedules`: `list/get/create/delete`workflow package 同期で常用
上記以外のAPIは原則使わない。
@@ -283,18 +334,20 @@ Windmill API依存は避けられないため、依存点を最小化する。
以下を満たせば「このプロジェクトはサーバーのワークフローを管理するためのもの」と言える状態:
1. ローカルから scripts/flows の Pull/Push がAPIで完結
2. サーバー更新がローカル検知できるhash比較
3. 競合時の復旧手順が定義済み
1. ローカルから workflow packageflow + schedulesの Pull/Push がAPIで完結
2. サーバー更新が workflow 単位でローカル検知できる
3. 競合時の復旧手順が flow/schedule 両方で定義済み
4. 運用手順がこの文書だけで再現可能
---
## 13. 既知の注意点
1. `wm-api.sh`現状 `bash` 前提だが、Windowsで改行がCRLFだと shebang 実行失敗する場合があ
1. `wm-api.sh``bash` 前提WindowsCRLF混入で shebang 実行失敗し得るため、`.gitattributes``*.sh text eol=lf` を固定し、実行は `bash wm-api.sh ...` を標準とする
2. フロー更新は環境により `PUT` できないため削除再作成を標準とする
3. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認す
3. 削除再作成は `version_id/hash/edited_at` を更新するため、Preflight hash check がないと競合上書きを見落とす可能性があ
4. 1つのflowに複数scheduleが紐づくことがあるため、`script_path` ベースで束ねて管理する
5. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認する
---
@@ -304,4 +357,6 @@ Windmill API依存は避けられないため、依存点を最小化する。
|------|----------|
| 2026-03-03 | 初版作成API一本化方針、同期仕様、実装計画、Runbookを定義 |
| 2026-03-03 | 障害前提の復旧設計、Windmill依存を薄くする方針を必須要件として追記 |
| 2026-03-03 | 運用単位を workflow packageflow + schedulesへ変更し、実装計画とRunbookを更新 |
| 2026-03-03 | `delete -> create` と hash 管理の運用ガードpreflight / fail closed / post-verifyおよびCRLF対策を追記 |
| 2026-03-04 | `u/admin/alexa_speak` のAPI反映後にUIドロップダウンが即時反映されない事象と運用回避策`Edit -> Deploy`)を追記 |

24
flows/git_sync.flow.json Normal file
View File

@@ -0,0 +1,24 @@
{
"path": "u/antigravity/git_sync",
"summary": "Git Sync Workflow",
"description": "Automatically sync Windmill workflows to Git repository (sync branch)",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "",
"type": "rawscript",
"content": "#!/bin/bash\nset -e\nexport PATH=/usr/bin:/usr/local/bin:/usr/sbin:/sbin:/bin:$PATH\n\nGREEN=\"\\033[0;32m\"\nYELLOW=\"\\033[1;33m\"\nRED=\"\\033[0;31m\"\nNC=\"\\033[0m\"\n\necho -e \"${GREEN}=== Windmill Workflow Git Sync ===${NC}\"\n\nREPO_ROOT=\"/workspace\"\nWMILL_DIR=\"${REPO_ROOT}/workflows\"\n\nif ! command -v wmill &> /dev/null; then\n echo -e \"${YELLOW}Installing windmill-cli...${NC}\"\n npm install -g windmill-cli\n export PATH=$(npm prefix -g)/bin:$PATH\nfi\n\ngit config --global --add safe.directory \"$REPO_ROOT\"\ngit config --global user.email \"bot@keinafarm.net\"\ngit config --global user.name \"Windmill Bot\"\n\n# sync ブランチを使用\nCURRENT_BRANCH=$(git -C \"$REPO_ROOT\" rev-parse --abbrev-ref HEAD)\nif [ \"$CURRENT_BRANCH\" != \"sync\" ]; then\n echo -e \"${YELLOW}Switching to sync branch...${NC}\"\n git -C \"$REPO_ROOT\" fetch origin sync\n git -C \"$REPO_ROOT\" checkout sync\nfi\n\necho -e \"${YELLOW}Pulling from origin/sync...${NC}\"\ngit -C \"$REPO_ROOT\" pull --rebase origin sync || {\n echo -e \"${RED}Failed to pull from remote. Continuing...${NC}\"\n}\n\necho -e \"${YELLOW}Pulling from Windmill...${NC}\"\ncd \"$WMILL_DIR\"\nwmill sync pull --config-dir /workspace/wmill_config --skip-variables --skip-secrets --skip-resources --yes || exit 1\n\ncd \"$REPO_ROOT\"\nif [[ -n $(git status --porcelain) ]]; then\n echo -e \"${YELLOW}Changes detected, committing to Git...${NC}\"\n git add -A\n TIMESTAMP=$(date \"+%Y-%m-%d %H:%M:%S\")\n git commit -m \"Auto-sync: ${TIMESTAMP}\"\n echo -e \"${YELLOW}Pushing to Gitea (sync branch)...${NC}\"\n git push origin sync || {\n echo -e \"${RED}Failed to push.${NC}\"\n exit 1\n }\n echo -e \"${GREEN}Changes pushed to Gitea (sync branch)${NC}\"\nelse\n echo -e \"${GREEN}No changes detected${NC}\"\nfi\n\necho -e \"${GREEN}=== Sync Complete ===${NC}\"\n",
"language": "bash",
"input_transforms": {}
}
}
]
},
"schema": {
"type": "object",
"properties": {},
"required": []
}
}

View File

@@ -0,0 +1,38 @@
{
"path": "u/akiracraftwork/hourly_chime",
"summary": "鳩時計機能",
"description": "",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "{\n \"dependencies\": {}\n}\n//bun.lock\n<empty>",
"type": "rawscript",
"content": "export async function main(\n device: string = \"オフィスの右エコー\",\n prefix: string = \"現在時刻は\",\n suffix: string = \"です\"\n) {\n const now = new Date();\n const hhmm = new Intl.DateTimeFormat(\"ja-JP\", {\n timeZone: \"Asia/Tokyo\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n hour12: false,\n }).format(now); // 例: 09:30\n\n const [h, m] = hhmm.split(\":\");\n const text = `${prefix}${Number(h)}時${Number(m)}分${suffix}`;\n\n const res = await fetch(\"http://alexa_api:3500/speak\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ device, text }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`alexa-api error ${res.status}: ${body}`);\n }\n\n return { ok: true, device, text };\n}\n",
"language": "bun",
"input_transforms": {
"device": {
"type": "static",
"value": "オフィスの右エコー"
},
"prefix": {
"type": "static",
"value": "現在時刻は"
},
"suffix": {
"type": "static",
"value": "です"
}
}
}
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {},
"required": [],
"type": "object"
}
}

View File

@@ -0,0 +1,24 @@
{
"path": "f/dev/konnnichiha",
"summary": "Print greeting",
"description": "",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"content": "def main():\n print('こんにちは、世界')",
"language": "python3",
"input_transforms": {}
}
}
]
},
"schema": {
"type": "object",
"properties": {},
"required": []
}
}

File diff suppressed because one or more lines are too long

View File

@@ -6,14 +6,14 @@
"modules": [
{
"id": "a",
"summary": "変更確認・LINE通知",
"value": {
"lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.1.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\ntyping-extensions==4.15.0\nwmill==1.640.0",
"type": "rawscript",
"language": "python3",
"content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分\n try:\n last_checked = wmill.get_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\")\n if not last_checked:\n last_checked = None\n except Exception:\n last_checked = None\n\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知エラー時は状態を更新しない\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\", checked_at)\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n",
"input_transforms": {},
"lock": ""
}
"language": "python3",
"input_transforms": {}
},
"summary": "変更確認・LINE通知"
}
]
},

View File

@@ -6,70 +6,70 @@
"modules": [
{
"id": "a",
"summary": "Step1: 診断データ生成",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"language": "python3",
"content": "import uuid\nfrom datetime import datetime, timezone\n\ndef main():\n \"\"\"診断データを生成する\"\"\"\n now = datetime.now(timezone.utc)\n run_id = str(uuid.uuid4())\n check_value = 2 + 2\n \n result = {\n \"timestamp\": now.isoformat(),\n \"run_id\": run_id,\n \"check\": check_value,\n \"python_version\": __import__('sys').version\n }\n print(f\"[Step1] 診断データ生成完了\")\n print(f\" run_id: {run_id}\")\n print(f\" timestamp: {now.isoformat()}\")\n print(f\" check: {check_value}\")\n return result\n",
"input_transforms": {},
"lock": ""
}
"language": "python3",
"input_transforms": {}
},
"summary": "Step1: 診断データ生成"
},
{
"id": "b",
"summary": "Step2: データ検証",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"language": "python3",
"content": "from datetime import datetime, timezone\n\ndef main(step1_result: dict):\n \"\"\"Step1の結果を検証する\"\"\"\n errors = []\n \n # 計算チェック\n if step1_result.get(\"check\") != 4:\n errors.append(f\"計算エラー: expected 4, got {step1_result.get('check')}\")\n \n # run_idの存在チェック\n if not step1_result.get(\"run_id\"):\n errors.append(\"run_idが存在しない\")\n \n # timestampの存在チェック\n if not step1_result.get(\"timestamp\"):\n errors.append(\"timestampが存在しない\")\n \n if errors:\n error_msg = \"; \".join(errors)\n print(f\"[Step2] 検証失敗: {error_msg}\")\n raise Exception(f\"検証失敗: {error_msg}\")\n \n print(f\"[Step2] データ検証OK\")\n print(f\" 計算チェック: 2+2={step1_result['check']} ✓\")\n print(f\" run_id: {step1_result['run_id']} ✓\")\n print(f\" timestamp: {step1_result['timestamp']} ✓\")\n \n return {\n \"verification\": \"PASS\",\n \"step1_data\": step1_result\n }\n",
"language": "python3",
"input_transforms": {
"step1_result": {
"type": "javascript",
"expr": "results.a"
"expr": "results.a",
"type": "javascript"
}
}
},
"lock": ""
}
"summary": "Step2: データ検証"
},
{
"id": "c",
"summary": "Step3: HTTPヘルスチェック",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"language": "python3",
"content": "import urllib.request\nimport ssl\n\ndef main(verification_result: dict):\n \"\"\"Windmillサーバー自身へのHTTPチェック\"\"\"\n url = \"https://windmill.keinafarm.net/api/version\"\n \n # SSL検証をスキップ自己署名証明書対応\n ctx = ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n \n try:\n req = urllib.request.Request(url)\n with urllib.request.urlopen(req, context=ctx, timeout=10) as response:\n status_code = response.status\n body = response.read().decode('utf-8')\n except Exception as e:\n print(f\"[Step3] HTTPチェック失敗: {e}\")\n raise Exception(f\"HTTPヘルスチェック失敗: {e}\")\n \n print(f\"[Step3] HTTPヘルスチェックOK\")\n print(f\" URL: {url}\")\n print(f\" Status: {status_code}\")\n print(f\" Version: {body}\")\n \n return {\n \"http_check\": \"PASS\",\n \"status_code\": status_code,\n \"server_version\": body\n }\n",
"language": "python3",
"input_transforms": {
"verification_result": {
"type": "javascript",
"expr": "results.b"
"expr": "results.b",
"type": "javascript"
}
}
},
"lock": ""
}
"summary": "Step3: HTTPヘルスチェック"
},
{
"id": "d",
"summary": "Step4: 年度判定 & 最終レポート",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"language": "python3",
"content": "from datetime import datetime, timezone\n\ndef main(step1_data: dict, verification: dict, http_check: dict):\n \"\"\"年度判定と最終診断レポートを生成\"\"\"\n now = datetime.now(timezone.utc)\n \n # 日本の年度判定4月始まり\n fiscal_year = now.year if now.month >= 4 else now.year - 1\n \n report = {\n \"status\": \"ALL OK\",\n \"fiscal_year\": fiscal_year,\n \"diagnostics\": {\n \"data_generation\": \"PASS\",\n \"data_verification\": verification.get(\"verification\", \"UNKNOWN\"),\n \"http_health\": http_check.get(\"http_check\", \"UNKNOWN\"),\n \"server_version\": http_check.get(\"server_version\", \"UNKNOWN\")\n },\n \"run_id\": step1_data.get(\"run_id\"),\n \"started_at\": step1_data.get(\"timestamp\"),\n \"completed_at\": now.isoformat()\n }\n \n print(\"\")\n print(\"========================================\")\n print(\" Windmill Heartbeat - 診断レポート\")\n print(\"========================================\")\n print(f\" Status: {report['status']}\")\n print(f\" 年度: {fiscal_year}年度\")\n print(f\" Run ID: {report['run_id']}\")\n print(f\" Server: {report['diagnostics']['server_version']}\")\n print(f\" 開始: {report['started_at']}\")\n print(f\" 完了: {report['completed_at']}\")\n print(\" ────────────────────────────────────\")\n print(f\" データ生成: PASS ✓\")\n print(f\" データ検証: {report['diagnostics']['data_verification']} ✓\")\n print(f\" HTTP確認: {report['diagnostics']['http_health']} ✓\")\n print(\"========================================\")\n print(\"\")\n \n return report\n",
"language": "python3",
"input_transforms": {
"http_check": {
"expr": "results.c",
"type": "javascript"
},
"step1_data": {
"type": "javascript",
"expr": "results.a"
"expr": "results.a",
"type": "javascript"
},
"verification": {
"type": "javascript",
"expr": "results.b"
},
"http_check": {
"type": "javascript",
"expr": "results.c"
"expr": "results.b",
"type": "javascript"
}
}
},
"lock": ""
}
"summary": "Step4: 年度判定 & 最終レポート"
}
]
},

24
flows/textout.flow.json Normal file
View File

@@ -0,0 +1,24 @@
{
"path": "f/dev/textout",
"summary": "Display current time on startup",
"description": "",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"content": "def main():\n from datetime import datetime\n print(datetime.now().strftime('%H:%M:%S'))",
"language": "python3",
"input_transforms": {}
}
}
]
},
"schema": {
"type": "object",
"properties": {},
"required": []
}
}

View File

@@ -0,0 +1,27 @@
{
"path": "f/weather/weather_sync",
"summary": "Weather Sync - 気象データ日次同期",
"description": "Open-Meteo から昨日の気象データを取得し、Keinasystem DB に保存する。毎朝6時実行。",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.2.25\ncharset-normalizer==3.4.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\nrequests==2.32.5\ntyping-extensions==4.15.0\nurllib3==2.6.3\nwmill==1.646.0",
"type": "rawscript",
"content": "import wmill\nimport requests\nimport datetime\n\nLATITUDE = 33.213\nLONGITUDE = 133.133\nTIMEZONE = \"Asia/Tokyo\"\n\nOPEN_METEO_URL = \"https://archive-api.open-meteo.com/v1/archive\"\nDAILY_VARS = [\n \"temperature_2m_mean\",\n \"temperature_2m_max\",\n \"temperature_2m_min\",\n \"sunshine_duration\",\n \"precipitation_sum\",\n \"wind_speed_10m_max\",\n \"surface_pressure_min\",\n]\n\n\ndef main():\n api_key = wmill.get_variable(\"u/admin/KEINASYSTEM_API_KEY\")\n base_url = wmill.get_variable(\"u/admin/KEINASYSTEM_API_URL\").rstrip(\"/\")\n sync_url = f\"{base_url}/api/weather/sync/\"\n\n yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()\n print(f\"Fetching weather data for {yesterday} ...\")\n\n params = {\n \"latitude\": LATITUDE,\n \"longitude\": LONGITUDE,\n \"start_date\": yesterday,\n \"end_date\": yesterday,\n \"daily\": DAILY_VARS,\n \"timezone\": TIMEZONE,\n }\n resp = requests.get(OPEN_METEO_URL, params=params, timeout=30)\n if resp.status_code != 200:\n raise Exception(f\"Open-Meteo API error: {resp.status_code} {resp.text[:300]}\")\n\n daily = resp.json().get(\"daily\", {})\n dates = daily.get(\"time\", [])\n if not dates:\n print(\"No data returned from Open-Meteo.\")\n return {\"status\": \"no_data\"}\n\n sunshine_raw = daily.get(\"sunshine_duration\", [])\n records = []\n for i, d in enumerate(dates):\n sun_sec = sunshine_raw[i]\n records.append({\n \"date\": d,\n \"temp_mean\": daily[\"temperature_2m_mean\"][i],\n \"temp_max\": daily[\"temperature_2m_max\"][i],\n \"temp_min\": daily[\"temperature_2m_min\"][i],\n \"sunshine_h\": round(sun_sec / 3600, 2) if sun_sec is not None else None,\n \"precip_mm\": daily[\"precipitation_sum\"][i],\n \"wind_max\": daily[\"wind_speed_10m_max\"][i],\n \"pressure_min\": daily[\"surface_pressure_min\"][i],\n })\n\n headers = {\n \"X-API-Key\": api_key,\n \"Content-Type\": \"application/json\",\n }\n post_resp = requests.post(sync_url, json=records, headers=headers, timeout=30)\n if post_resp.status_code not in (200, 201):\n raise Exception(f\"Keinasystem sync error: {post_resp.status_code} {post_resp.text[:300]}\")\n\n result = post_resp.json()\n print(f\"Sync complete: {result}\")\n return result\n",
"language": "python3",
"input_transforms": {}
},
"summary": "気象データ取得・同期"
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"order": [],
"properties": {},
"required": []
}
}

View File

@@ -1,69 +1,20 @@
/**
* alexa_speak.ts
* 指定した Echo デバイスにテキストを読み上げさせる Windmill スクリプト
*
* パラメータ:
* device - ドロップダウンから選択するデバイス(内部的にはシリアル番号)
* text - 読み上げるテキスト
*/
const ALEXA_API_URL = "http://alexa_api:3500";
type DeviceOption = { value: string; label: string };
const FALLBACK_DEVICE_OPTIONS: DeviceOption[] = [
{ value: "G0922H085165007R", label: "プレハブ (G0922H085165007R)" },
{ value: "G8M2DB08522600RL", label: "リビングエコー1 (G8M2DB08522600RL)" },
{ value: "G8M2DB08522503WF", label: "リビングエコー2 (G8M2DB08522503WF)" },
{ value: "G0922H08525302K5", label: "オフィスの右エコー (G0922H08525302K5)" },
{ value: "G0922H08525302J9", label: "オフィスの左エコー (G0922H08525302J9)" },
{ value: "G8M2HN08534302XH", label: "寝室のエコー (G8M2HN08534302XH)" },
];
// Windmill Dynamic Select: 引数名 `device` に対応する `DynSelect_device` と `device()` を定義
export type DynSelect_device = string;
export async function device(): Promise<DeviceOption[]> {
try {
const res = await fetch(`${ALEXA_API_URL}/devices`);
if (!res.ok) return FALLBACK_DEVICE_OPTIONS;
const devices = (await res.json()) as Array<{
name?: string;
serial?: string;
family?: string;
}>;
const options = devices
.filter((d) => d.family === "ECHO" && d.serial)
.map((d) => ({
value: d.serial as string,
label: `${d.name ?? d.serial} (${d.serial})`,
}))
.sort((a, b) => a.label.localeCompare(b.label, "ja"));
return options.length > 0 ? options : FALLBACK_DEVICE_OPTIONS;
} catch {
return FALLBACK_DEVICE_OPTIONS;
}
}
export async function main(
device: DynSelect_device,
device: string,
text: string,
): Promise<{ ok: boolean; device: string; text: string }> {
const ALEXA_API_URL = "http://alexa_api:3500";
const res = await fetch(`${ALEXA_API_URL}/speak`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device, text }),
body: JSON.stringify({ device, text }), // ← SSMLなし、素のテキスト
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
`alexa-api error ${res.status}: ${JSON.stringify(body)}`
);
throw new Error(`alexa-api error ${res.status}: ${JSON.stringify(body)}`);
}
return await res.json();
}

106
state/flows.list.json Normal file
View File

@@ -0,0 +1,106 @@
[
{
"workspace_id": "admins",
"path": "u/akiracraftwork/hourly_chime",
"summary": "鳩時計機能",
"description": "",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-03T05:37:39.969305Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/dev/textout",
"summary": "Display current time on startup",
"description": "",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-02T05:05:05.215985Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/dev/konnnichiha",
"summary": "Print greeting",
"description": "",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-02T04:53:56.968574Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "u/antigravity/git_sync",
"summary": "Git Sync Workflow",
"description": "Automatically sync Windmill workflows to Git repository (sync branch)",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-01T17:28:14.331046Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/weather/weather_sync",
"summary": "Weather Sync - 気象データ日次同期",
"description": "Open-Meteo から昨日の気象データを取得し、Keinasystem DB に保存する。毎朝6時実行。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-28T04:31:27.835748Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/mail/mail_filter",
"summary": "メールフィルタリング",
"description": "IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-24T06:41:54.748865Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/shiraou/shiraou_notification",
"summary": "白皇集落営農 変更通知",
"description": "shiraou.keinafarm.net の予約・実績変更をポーリングし、変更があればLINEで管理者に通知する。5分毎に実行。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-21T06:33:11.078673Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/app_custom/system_heartbeat",
"summary": "Windmill Heartbeat - システム自己診断",
"description": "Windmillの動作確認用ワークフロー。UUID生成、時刻取得、計算チェック、HTTPヘルスチェック、年度判定を行い、全ステップの正常性を検証する。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-21T03:43:55.495111Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
}
]

View File

@@ -0,0 +1,78 @@
{
"synced_at": "2026-03-03T07:09:55Z",
"workspace": "admins",
"scripts": {
"u/admin/alexa_speak": {
"hash": "3783872112d1a24c",
"updated_at": "2026-03-03T02:57:13.068287Z"
}
},
"flows": {
"u/akiracraftwork/hourly_chime": {
"updated_at": "2026-03-03T05:37:39.969305Z"
},
"f/dev/textout": {
"updated_at": "2026-03-02T05:05:05.215985Z"
},
"f/dev/konnnichiha": {
"updated_at": "2026-03-02T04:53:56.968574Z"
},
"u/antigravity/git_sync": {
"updated_at": "2026-03-01T17:28:14.331046Z"
},
"f/weather/weather_sync": {
"updated_at": "2026-02-28T04:31:27.835748Z"
},
"f/mail/mail_filter": {
"updated_at": "2026-02-24T06:41:54.748865Z"
},
"f/shiraou/shiraou_notification": {
"updated_at": "2026-02-21T06:33:11.078673Z"
},
"f/app_custom/system_heartbeat": {
"updated_at": "2026-02-21T03:43:55.495111Z"
}
},
"schedules": {
"u/akiracraftwork/hourly_chime": {
"schedule": "0 0 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/akiracraftwork/hourly_chime",
"is_flow": true,
"updated_at": "2026-03-03T04:44:03.309346Z"
},
"f/weather/weather_sync": {
"schedule": "0 0 6 * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/weather/weather_sync",
"is_flow": true,
"updated_at": "2026-02-28T04:31:41.375049Z"
},
"f/mail/mail_filter_schedule": {
"schedule": "0 */10 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/mail/mail_filter",
"is_flow": true,
"updated_at": "2026-02-24T06:42:06.977249Z"
},
"f/shiraou/shiraou_notification_every_5min": {
"schedule": "0 */5 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/shiraou/shiraou_notification",
"is_flow": true,
"updated_at": "2026-02-21T06:18:34.967961Z"
},
"u/antigravity/git_sync": {
"schedule": "0 */30 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/antigravity/git_sync",
"is_flow": true,
"updated_at": "2026-02-19T06:38:19.867037Z"
}
}
}

78
state/remote_index.json Normal file
View File

@@ -0,0 +1,78 @@
{
"synced_at": "2026-03-03T07:12:56Z",
"workspace": "admins",
"scripts": {
"u/admin/alexa_speak": {
"hash": "3783872112d1a24c",
"updated_at": "2026-03-03T02:57:13.068287Z"
}
},
"flows": {
"u/akiracraftwork/hourly_chime": {
"updated_at": "2026-03-03T05:37:39.969305Z"
},
"f/dev/textout": {
"updated_at": "2026-03-02T05:05:05.215985Z"
},
"f/dev/konnnichiha": {
"updated_at": "2026-03-02T04:53:56.968574Z"
},
"u/antigravity/git_sync": {
"updated_at": "2026-03-01T17:28:14.331046Z"
},
"f/weather/weather_sync": {
"updated_at": "2026-02-28T04:31:27.835748Z"
},
"f/mail/mail_filter": {
"updated_at": "2026-02-24T06:41:54.748865Z"
},
"f/shiraou/shiraou_notification": {
"updated_at": "2026-02-21T06:33:11.078673Z"
},
"f/app_custom/system_heartbeat": {
"updated_at": "2026-02-21T03:43:55.495111Z"
}
},
"schedules": {
"u/akiracraftwork/hourly_chime": {
"schedule": "0 0 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/akiracraftwork/hourly_chime",
"is_flow": true,
"updated_at": "2026-03-03T04:44:03.309346Z"
},
"f/weather/weather_sync": {
"schedule": "0 0 6 * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/weather/weather_sync",
"is_flow": true,
"updated_at": "2026-02-28T04:31:41.375049Z"
},
"f/mail/mail_filter_schedule": {
"schedule": "0 */10 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/mail/mail_filter",
"is_flow": true,
"updated_at": "2026-02-24T06:42:06.977249Z"
},
"f/shiraou/shiraou_notification_every_5min": {
"schedule": "0 */5 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/shiraou/shiraou_notification",
"is_flow": true,
"updated_at": "2026-02-21T06:18:34.967961Z"
},
"u/antigravity/git_sync": {
"schedule": "0 */30 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/antigravity/git_sync",
"is_flow": true,
"updated_at": "2026-02-19T06:38:19.867037Z"
}
}
}

67
state/schedules.list.json Normal file
View File

@@ -0,0 +1,67 @@
[
{
"workspace_id": "admins",
"path": "u/akiracraftwork/hourly_chime",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-03T04:44:03.309346Z",
"schedule": "0 0 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/akiracraftwork/hourly_chime",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "f/weather/weather_sync",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-28T04:31:41.375049Z",
"schedule": "0 0 6 * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/weather/weather_sync",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "f/mail/mail_filter_schedule",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-24T06:42:06.977249Z",
"schedule": "0 */10 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/mail/mail_filter",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "f/shiraou/shiraou_notification_every_5min",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-21T06:18:34.967961Z",
"schedule": "0 */5 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/shiraou/shiraou_notification",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "u/antigravity/git_sync",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-19T06:38:19.867037Z",
"schedule": "0 */30 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/antigravity/git_sync",
"is_flow": true,
"summary": null,
"extra_perms": {}
}
]

18
state/scripts.list.json Normal file
View File

@@ -0,0 +1,18 @@
[
{
"hash": "3783872112d1a24c",
"path": "u/admin/alexa_speak",
"summary": "Echo デバイスに TTS で読み上げ",
"created_at": "2026-03-03T02:57:13.068287Z",
"archived": false,
"extra_perms": {},
"language": "bun",
"starred": false,
"tag": null,
"description": "指定した Echo デバイスにテキストを読み上げさせる",
"has_draft": false,
"has_deploy_errors": false,
"ws_error_handler_muted": false,
"kind": "script"
}
]

962
wm-api.sh

File diff suppressed because it is too large Load Diff