@geekbeer/minion 4.4.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,6 +108,29 @@ function variableRoutes(fastify, _opts, done) {
108
108
  return { success: true, scopes: variableStore.listVariableScopes() }
109
109
  })
110
110
 
111
+ // Move a variable from one workspace scope to another. Body:
112
+ // { from_workspace_id, to_workspace_id } — omit/empty string targets the
113
+ // minion-wide bucket. The destination is never overwritten on conflict.
114
+ fastify.post('/api/variables/:key/move', async (request, reply) => {
115
+ if (!verifyToken(request)) {
116
+ return reply.code(401).send({ error: 'Unauthorized' })
117
+ }
118
+ const { key } = request.params
119
+ const from = typeof request.body?.from_workspace_id === 'string' ? request.body.from_workspace_id : ''
120
+ const to = typeof request.body?.to_workspace_id === 'string' ? request.body.to_workspace_id : ''
121
+ const result = variableStore.moveVariable(from, to, key)
122
+ if (result.status === 'same_scope') {
123
+ return reply.code(400).send({ error: 'Source and destination scopes are the same.' })
124
+ }
125
+ if (result.status === 'not_found') {
126
+ return reply.code(404).send({ error: `Variable not found: ${key}` })
127
+ }
128
+ if (result.status === 'conflict') {
129
+ return reply.code(409).send({ error: `A variable with the same key already exists in the destination scope: ${key}` })
130
+ }
131
+ return { success: true, key, from_workspace_id: result.from, to_workspace_id: result.to }
132
+ })
133
+
111
134
  // ─── Secrets (sensitive, workspace-scoped) ────────────────────────────
112
135
  //
113
136
  // Secrets are scoped per workspace. Pass ?workspace_id=<uuid> to target a
@@ -118,11 +141,6 @@ function variableRoutes(fastify, _opts, done) {
118
141
  // Values are never returned via the API by design — only key names. Secrets
119
142
  // never leave the minion: the HQ proxy is a pure pass-through.
120
143
 
121
- function readWorkspaceId(request) {
122
- const rawWs = request.query?.workspace_id
123
- return (typeof rawWs === 'string') ? rawWs : ''
124
- }
125
-
126
144
  fastify.get('/api/secrets', async (request, reply) => {
127
145
  if (!verifyToken(request)) {
128
146
  return reply.code(401).send({ error: 'Unauthorized' })
@@ -175,6 +193,30 @@ function variableRoutes(fastify, _opts, done) {
175
193
  return { success: true, scopes: variableStore.listSecretScopes() }
176
194
  })
177
195
 
196
+ // Move a secret from one workspace scope to another. Body:
197
+ // { from_workspace_id, to_workspace_id } — omit/empty string targets the
198
+ // minion-wide bucket. The value is moved within the minion (it never leaves);
199
+ // the destination is never overwritten on conflict.
200
+ fastify.post('/api/secrets/:key/move', async (request, reply) => {
201
+ if (!verifyToken(request)) {
202
+ return reply.code(401).send({ error: 'Unauthorized' })
203
+ }
204
+ const { key } = request.params
205
+ const from = typeof request.body?.from_workspace_id === 'string' ? request.body.from_workspace_id : ''
206
+ const to = typeof request.body?.to_workspace_id === 'string' ? request.body.to_workspace_id : ''
207
+ const result = variableStore.moveSecret(from, to, key)
208
+ if (result.status === 'same_scope') {
209
+ return reply.code(400).send({ error: 'Source and destination scopes are the same.' })
210
+ }
211
+ if (result.status === 'not_found') {
212
+ return reply.code(404).send({ error: `Secret not found: ${key}` })
213
+ }
214
+ if (result.status === 'conflict') {
215
+ return reply.code(409).send({ error: `A secret with the same key already exists in the destination scope: ${key}` })
216
+ }
217
+ return { success: true, key, from_workspace_id: result.from, to_workspace_id: result.to }
218
+ })
219
+
178
220
  done()
179
221
  }
180
222
 
@@ -177,6 +177,36 @@ function deleteEntry(table, workspaceId, key) {
177
177
  return { removed: info.changes > 0, wsId }
178
178
  }
179
179
 
180
+ /**
181
+ * Move a single entry from one workspace scope to another, atomically.
182
+ *
183
+ * Used to fix entries that were registered under the wrong workspace scope.
184
+ * The destination is NEVER overwritten: if the key already exists at the
185
+ * destination, the move is refused (`status: 'conflict'`) so no value is lost.
186
+ *
187
+ * @returns {{status: 'moved'|'not_found'|'conflict'|'same_scope', value?: string, from?: string, to?: string}}
188
+ */
189
+ function moveEntry(table, fromWorkspaceId, toWorkspaceId, key) {
190
+ const from = normalizeWorkspaceId(fromWorkspaceId)
191
+ const to = normalizeWorkspaceId(toWorkspaceId)
192
+ if (from === to) return { status: 'same_scope' }
193
+
194
+ const db = getDb()
195
+ const src = db.prepare(`SELECT value FROM ${table} WHERE workspace_id = ? AND key = ?`).get(from, key)
196
+ if (!src) return { status: 'not_found' }
197
+
198
+ const dst = db.prepare(`SELECT 1 FROM ${table} WHERE workspace_id = ? AND key = ?`).get(to, key)
199
+ if (dst) return { status: 'conflict' }
200
+
201
+ const tx = db.transaction(() => {
202
+ db.prepare(`INSERT INTO ${table} (workspace_id, key, value, updated_at) VALUES (?, ?, ?, ?)`)
203
+ .run(to, key, src.value, Date.now())
204
+ db.prepare(`DELETE FROM ${table} WHERE workspace_id = ? AND key = ?`).run(from, key)
205
+ })
206
+ tx()
207
+ return { status: 'moved', value: src.value, from, to }
208
+ }
209
+
180
210
  function listScopes(table) {
181
211
  const db = getDb()
182
212
  const rows = db.prepare(`SELECT DISTINCT workspace_id FROM ${table}`).all()
@@ -219,6 +249,19 @@ function removeVariable(workspaceId, key) {
219
249
  return removed
220
250
  }
221
251
 
252
+ /**
253
+ * Move a variable from one workspace scope to another. The destination is not
254
+ * overwritten on conflict — see `moveEntry`.
255
+ */
256
+ function moveVariable(fromWorkspaceId, toWorkspaceId, key) {
257
+ migrateLegacyVariablesFile()
258
+ const r = moveEntry('variables', fromWorkspaceId, toWorkspaceId, key)
259
+ if (r.status === 'moved') {
260
+ console.log(`[VariableStore] Moved variable ${key}: ${r.from || 'minion-wide'} -> ${r.to || 'minion-wide'}`)
261
+ }
262
+ return r
263
+ }
264
+
222
265
  function listVariableKeys(workspaceId) {
223
266
  migrateLegacyVariablesFile()
224
267
  const rows = getRowsForScope('variables', workspaceId)
@@ -280,6 +323,24 @@ function removeSecret(workspaceId, key) {
280
323
  return removed
281
324
  }
282
325
 
326
+ /**
327
+ * Move a secret from one workspace scope to another. The destination is not
328
+ * overwritten on conflict — see `moveEntry`.
329
+ *
330
+ * Keeps `process.env` in sync: leaving the minion-wide bucket removes the env
331
+ * var; entering it sets the env var. WS-scoped secrets never touch process.env.
332
+ */
333
+ function moveSecret(fromWorkspaceId, toWorkspaceId, key) {
334
+ migrateLegacySecretsFile()
335
+ const r = moveEntry('secrets', fromWorkspaceId, toWorkspaceId, key)
336
+ if (r.status === 'moved') {
337
+ if (r.from === '') delete process.env[key]
338
+ if (r.to === '') process.env[key] = String(r.value ?? '')
339
+ console.log(`[VariableStore] Moved secret ${key}: ${r.from || 'minion-wide'} -> ${r.to || 'minion-wide'}`)
340
+ }
341
+ return r
342
+ }
343
+
283
344
  function listSecretScopes() {
284
345
  migrateLegacySecretsFile()
285
346
  return listScopes('secrets')
@@ -345,6 +406,7 @@ module.exports = {
345
406
  getVariable,
346
407
  setVariable,
347
408
  removeVariable,
409
+ moveVariable,
348
410
  listVariableKeys,
349
411
  listVariableScopes,
350
412
  migrateLegacyVariablesFile,
@@ -355,6 +417,7 @@ module.exports = {
355
417
  listSecretKeys,
356
418
  setSecret,
357
419
  removeSecret,
420
+ moveSecret,
358
421
  listSecretScopes,
359
422
  migrateLegacySecretsFile,
360
423
  }
@@ -932,6 +932,20 @@ Response:
932
932
 
933
933
  同名キーがある場合はワークスペース別が優先される。`/api/secrets/*` エンドポイントは `?workspace_id=<uuid>` クエリパラメータでスコープを指定する。省略または空文字でミニオン全体を操作する。シークレット値はHQ DBには保存されず、HQ APIはpass-throughとして中継するのみ。
934
934
 
935
+ #### スコープ間の移動
936
+
937
+ 誤ったワークスペーススコープに登録してしまったシークレット/変数は、削除・再登録せずに別スコープへ**移動**できる。移動はミニオン内部で1トランザクションとして実行され、シークレット値はミニオンの外に出ない。
938
+
939
+ | Method | Endpoint | Description |
940
+ |--------|----------|-------------|
941
+ | POST | `/api/variables/:key/move` | 変数を別スコープへ移動。Body: `{from_workspace_id, to_workspace_id}` |
942
+ | POST | `/api/secrets/:key/move` | シークレットを別スコープへ移動。Body: `{from_workspace_id, to_workspace_id}` |
943
+
944
+ - `from_workspace_id` / `to_workspace_id` は省略または空文字 `""` でミニオン全体スコープを指す。
945
+ - 移動先に同名キーが既に存在する場合、**上書きせず `409` を返す**(値の消失を防ぐため)。先に移動先の既存キーを削除すること。
946
+ - 移動元にキーが無い場合は `404`、移動元と移動先が同一スコープの場合は `400`。
947
+ - シークレットを移動した場合、ミニオン全体スコープから外れると `process.env` から除去され、ミニオン全体スコープに入ると `process.env` に反映される。
948
+
935
949
  ### Project Tasks
936
950
 
937
951
  タスクは5段階Kanban (`backlog`/`todo`/`doing`/`review`/`done`)で管理される。親子関係は2階層まで(孫タスク禁止)。担当は **ミニオンか人間の二択**(両方は不可、後勝ち null)。
@@ -2160,6 +2174,8 @@ POST `/api/minion/workspaces/:id/notes` body:
2160
2174
 
2161
2175
  PATCH body は workspace 版と共通: `{title?, content?, change_summary?}` (status は workspace 経由のみ受理)。
2162
2176
 
2177
+ > **ロックされたノート (423 Locked):** ユーザーが「ロック」したノート(パスワード等の保護用)は、ミニオンからの更新がすべて拒否され、`HTTP 423` (`{"code":"note_locked"}`) が返る。**ロックの解除は人間のみが HQ ダッシュボードで行える。ミニオンは解除できない。** 423 を受け取ったら、上書きを試み続けず、必要なら threads でユーザーにロック解除を依頼すること。ノート詳細(GET)では `is_locked` / `is_masked` フィールドで状態を確認できる(`is_masked` は HQ UI 上の表示マスクで、API レスポンスの本文には影響しない)。
2178
+
2163
2179
  #### hq CLI ラッパー
2164
2180
 
2165
2181
  ```bash
@@ -425,6 +425,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
425
425
  '成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
426
426
  '',
427
427
  '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
428
+ ' - ロックされたノートの更新は `HTTP 423`(`note_locked`)で拒否される。解除は人間のみ可能なので、上書きを繰り返さずユーザーに依頼すること。',
428
429
  '- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
429
430
  '- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
430
431
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.4.0",
3
+ "version": "4.5.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -238,7 +238,7 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
238
238
  - `MINION_ROUTINE_NAME` — ルーティン名
239
239
  - `MINION_ROUTINE_WORKSPACE_ID` — ルーティンが特定ワークスペースにバインドされている場合のワークスペースUUID(未バインドなら空文字)
240
240
 
241
- **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。
241
+ **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。誤ったスコープに登録してしまった変数・シークレットは `POST /api/variables/:key/move`・`POST /api/secrets/:key/move`(body: `{from_workspace_id, to_workspace_id}`)で削除・再登録せずに別スコープへ移動できる。移動先に同名キーがある場合は上書きせず `409` を返す。
242
242
 
243
243
  DAGワークフローでは、HQ側スコープ(workspace/project/dag_workflow)の変数は **`POST /api/dag/minion/claim-node` のレスポンス `template_vars`** に乗って降ってきて、ミニオン側で minion-local 変数とマージしたうえで `POST /api/skills/fetch/:name?vars=...` の base64 JSON に詰めて HQ に渡し、HQ 側で SKILL.md の `{{VAR}}` を置換して返す。つまり HQ-scope 変数の更新は **次のノード claim から反映される**(既存のミニオン上 SKILL.md は次回 fetch で上書きされる)。DAGワークフロー単位の変数は `dag_workflow_variables` テーブルに格納され、DAGエディタの "Variables" ボタンから編集可能。
244
244
 
@@ -485,6 +485,8 @@ hq note search --workspace <workspace_id> "キーワード"
485
485
 
486
486
  **削除はミニオンから実行できない**(事故防止のため人間操作限定)。不要なノートは `hq note update ... --status archived` (または PATCH の `status: "archived"`) でアーカイブすること。
487
487
 
488
+ **ロックされたノートは更新できない。** ユーザーがパスワード等を保護するために「ロック」したノートは、`hq note update` / PATCH が `HTTP 423`(`{"code":"note_locked"}`)で拒否される。**ロック解除は人間のみ**が HQ ダッシュボードで行えるため、ミニオンは解除できない。423 を受けたら上書きを繰り返さず、更新が必要な場合は threads でユーザーにロック解除を依頼すること。
489
+
488
490
  ### ノート内のユーザーメンション
489
491
 
490
492
  人間ユーザーがノート編集UIで `Ctrl+I` を押すと、自分の発言を示すアバター付きチップが行頭に挿入される。マークダウン上は次の形式で永続化される:
@@ -490,6 +490,7 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
490
490
  '成果物の保存先は以下のルールに従うこと。`/home/minion/` 直下にファイルを保存しないこと。',
491
491
  '',
492
492
  '- **テキスト成果物**(レポート、調査結果、要約等)→ ノートに保存: `hq note create <project_id> --title "タイトル" --content "本文"` (プロジェクト紐づけなしは `hq note create --workspace <ws_id> ...`)',
493
+ ' - ロックされたノートの更新は `HTTP 423`(`note_locked`)で拒否される。解除は人間のみ可能なので、上書きを繰り返さずユーザーに依頼すること。',
493
494
  '- **バイナリファイル**(PDF、画像、ZIP等)→ `~/files/` に配置(ユーザーがHQからダウンロード可能)',
494
495
  '- **一時ファイル** → `/tmp/` に配置(作業後に削除)',
495
496
  '',