sync: update from private repo (6fcb0d0)
This commit is contained in:
parent
21be01b699
commit
57685d995c
@ -65,12 +65,53 @@ ReadPdf({ path: "input/spec.pdf", query: "第\\d+条", query_mode: "regex" })
|
|||||||
### ReadExcel
|
### ReadExcel
|
||||||
|
|
||||||
```js
|
```js
|
||||||
ReadExcel({ file_path: "input/data.xlsx" })
|
ReadExcel({ path: "input/data.xlsx" })
|
||||||
// → 全シートのセル内容をテキスト形式で返す
|
// → 全シートのセル内容をテキスト形式で返す
|
||||||
```
|
```
|
||||||
|
|
||||||
巨大な Excel は token を食うので、シートを絞る場合は SplitExcelSheets を使う。
|
巨大な Excel は token を食うので、シートを絞る場合は SplitExcelSheets を使う。
|
||||||
|
|
||||||
|
#### include_styles — セル装飾の取得(オプション)
|
||||||
|
|
||||||
|
```js
|
||||||
|
ReadExcel({ path: "input/report.xlsx", include_styles: true })
|
||||||
|
// → 値テーブルの後に ### Styles セクションが追記される
|
||||||
|
```
|
||||||
|
|
||||||
|
デフォルトは `false`(後方互換)。`include_styles: true` を指定すると、値テーブルの直後に各シートの `### Styles` セクションが出力される。
|
||||||
|
|
||||||
|
**出力形式 — dedup legend + range map:**
|
||||||
|
|
||||||
|
```
|
||||||
|
### Styles — Sheet1
|
||||||
|
s1 = fill:#FFF2CC;font:bold
|
||||||
|
s2 = border:top(thin),bottom(double,#808080);numFmt:"0.00%"
|
||||||
|
|
||||||
|
s1: A1:D1,A5:D5
|
||||||
|
s2: B2
|
||||||
|
merges: B2:D4
|
||||||
|
```
|
||||||
|
|
||||||
|
- **legend**: スタイル ID (`s1`, `s2`, ...) とそのシグネチャを列挙。シグネチャは `fill` / `font` / `border` / `numFmt` / `align` のうち非デフォルト部分を `;` 区切りで結合した文字列。
|
||||||
|
- **range map**: 同一スタイルを持つセルを矩形にまとめて `A1:D3` 形式で列挙。単一セルは `A1`。
|
||||||
|
- **merges**: 結合セル範囲の一覧(`ws.model.merges` から取得)。
|
||||||
|
- **conditionalFormatting**: 条件付き書式が存在する場合 `present (effective styles not evaluated)` と表示(実際の表示色は評価しない)。
|
||||||
|
- **装飾のみ・値なしセルも捕捉**: スタイルスキャンは `includeEmpty:true` で実行するため、値がなく書式だけ設定されたセルも対象に含まれる。
|
||||||
|
- **`sheet`/`range` フィルタを尊重**: `include_styles` は値テーブルと同じ `sheet`/`range` フィルタでスキャン範囲を限定する。指定した範囲外のセルのスタイルと、範囲に交差しない結合セルは結果に含まれない。
|
||||||
|
- **デフォルト色の省略**: デフォルト色 (theme index 1 = 通常黒) に明示設定したフォント色はデフォルト扱いで省略される。同様に border の theme index 0(自動色)も省略される。
|
||||||
|
|
||||||
|
**テーマカラーの解決(ベストエフォート):**
|
||||||
|
|
||||||
|
テーマカラーはファイルの `theme1.xml` から読み取り、OOXML の lt/dk スワップを適用して `#RRGGBB` に解決する。解決できた場合は `theme(N,resolved=#RRGGBB)` の形式で表示し、`resolved=` は近似値であることに注意。テーマ XML が解析できない場合は `theme(N,tint=T)` のように生の参照のまま出力する。
|
||||||
|
|
||||||
|
**max_style_ranges — range 数の上限:**
|
||||||
|
|
||||||
|
`max_style_ranges`(デフォルト 250)で全シートを通じた range 出力数を制限する。上限に達した場合は `[styles truncated: max_style_ranges=N reached]` を表示し、以降のスタイルは省略される。値テーブルが先に出力されるため、スタイルセクションがトークン上限で切り捨てられても値は失われない。
|
||||||
|
|
||||||
|
**編集には python + openpyxl を推奨:**
|
||||||
|
|
||||||
|
ReadExcel はスタイルを読むだけで、書き込みは行わない。既存スタイルを保持したまま値を編集する場合は `Bash` ツールで python + openpyxl を使うこと(openpyxl は触れていないセルのスタイルを保持する)。
|
||||||
|
|
||||||
### ReadDocx
|
### ReadDocx
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
name: game-tweet-generator
|
|
||||||
description: |
|
|
||||||
指定したゲームアカウントの X (Twitter) 投稿を調査し、その情報を元に特定の SNS アカウント(例: NFTGamerJP)風のツイート文章を生成する。
|
|
||||||
選ぶべき場合: 「ゲームの最新情報を調べて、こんな風にツイートして」と指示されたとき
|
|
||||||
選ぶべきでない場合: 一般的な SNS 調査のみ、またはツイート生成以外の目的
|
|
||||||
triggers:
|
|
||||||
keywords:
|
|
||||||
- ゲームツイート作成
|
|
||||||
- ゲーム調査ツイート
|
|
||||||
- アカウント風ツイート
|
|
||||||
- ゲームアップデート調査
|
|
||||||
- ツイート文案作成
|
|
||||||
max_movements: 1
|
|
||||||
initial_movement: generate
|
|
||||||
movements:
|
|
||||||
- name: generate
|
|
||||||
edit: true
|
|
||||||
persona: researcher_and_writer
|
|
||||||
instruction: |
|
|
||||||
AVAX に関連するゲームアカウントの X (Twitter) 投稿を調査し、その情報を元にターゲットアカウント(例: NFTGamerJP)風のツイート文章を生成する。
|
|
||||||
|
|
||||||
## 関係するアカウント
|
|
||||||
|
|
||||||
- Off The Grid
|
|
||||||
- DeFi Kingdoms
|
|
||||||
- The Grotto
|
|
||||||
- Fort Block Games
|
|
||||||
- Beam
|
|
||||||
|
|
||||||
## ワークフロー
|
|
||||||
|
|
||||||
1. **ゲームアカウントの特定**
|
|
||||||
- Task instruction から対象となるゲームアカウントを抽出する
|
|
||||||
- 複数アカウント指定がある場合は主要なアカウントを優先
|
|
||||||
|
|
||||||
2. **最新投稿の調査**
|
|
||||||
- XUserPosts で対象アカウントの最新投稿を取得し、直近 1 週間以内を重点的に確認
|
|
||||||
- アップデート情報・イベント告知・コミュニティ反応などを選定
|
|
||||||
|
|
||||||
3. **詳細調査**
|
|
||||||
- 重要な投稿は XPostDetail でスレッド文脈・リプライを確認
|
|
||||||
- ゲームの最新動向が不足する場合は WebSearch で補完
|
|
||||||
|
|
||||||
4. **ターゲットアカウントのスタイル分析**
|
|
||||||
- 指定されたターゲットアカウントの過去投稿を XUserPosts で取得し、以下を分析:
|
|
||||||
- 使用する絵文字の種類と配置
|
|
||||||
- 文中の装飾(改行・区切り線等)とハッシュタグのパターン
|
|
||||||
- 情報提供とエンターテインメントのバランス
|
|
||||||
- 投稿の長さと構成
|
|
||||||
|
|
||||||
5. **ツイート文章の生成**
|
|
||||||
- 調査したゲーム情報を元に、ターゲットアカウントのスタイルに合わせた文章を作成
|
|
||||||
- 重要なアップデート・イベント情報の要約、適切な絵文字、関連ハッシュタグを含める
|
|
||||||
- 短め(簡潔版)と詳細版など複数バリエーションを提示する
|
|
||||||
|
|
||||||
## 原則
|
|
||||||
|
|
||||||
- 【必須】モデルの内部知識だけで情報を書かないこと。必ず実際のツイートデータを収集する
|
|
||||||
- 調査が一部失敗しても、取得できた情報で最善の提案を行う
|
|
||||||
- ターゲットアカウントのスタイルを参考にしつつ、情報に基づいた独自の文章を作ること(単なるコピーは不可)
|
|
||||||
|
|
||||||
## 完了方法
|
|
||||||
この piece は単一 movement のため、終了は必ず `complete` ツールで行う。`transition` は使わない。
|
|
||||||
|
|
||||||
- **ツイート文章を生成できた場合**: `complete({status: "success", result: "生成したツイート文章(複数バリエーション含む)と、根拠となった調査結果のサマリ"})`
|
|
||||||
- `result` がそのままユーザーに表示される最終出力。短いメモではなく完成形を入れる
|
|
||||||
- **調査対象や目的が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
|
||||||
|
|
||||||
allowed_tools:
|
|
||||||
- XSearch
|
|
||||||
- XUserPosts
|
|
||||||
- XPostDetail
|
|
||||||
- XFetchCardMedia
|
|
||||||
- WebSearch
|
|
||||||
- WebFetch
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
- Edit
|
|
||||||
- Glob
|
|
||||||
- Grep
|
|
||||||
- Bash
|
|
||||||
- 'mcp__*'
|
|
||||||
# default_next is the engine-internal fallback. Not exposed to the LLM.
|
|
||||||
default_next: COMPLETE
|
|
||||||
rules: []
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { Repository, localTaskRepoName } from '../db/repository.js';
|
import { Repository, localTaskRepoName } from '../db/repository.js';
|
||||||
@ -911,3 +911,120 @@ describe('POST /api/local/tasks/:id/continue', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regression: /continue must accept pieces that exist ONLY in the task
|
||||||
|
// owner's per-user dir. The old pieceExists(name) callback never checked
|
||||||
|
// that dir and always returned false → 400 piece_not_found for user-custom
|
||||||
|
// pieces even though the worker would have loaded them successfully.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('POST /api/local/tasks/:id/continue — user-custom piece resolution', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
let repo: Repository;
|
||||||
|
let app: express.Application;
|
||||||
|
let aliceUser: Express.User;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'lt-ucpiece-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
const real = repo.createUser({ email: 'uc@x.com', name: 'uc', role: 'user', status: 'active' });
|
||||||
|
aliceUser = {
|
||||||
|
...real,
|
||||||
|
orgIds: [],
|
||||||
|
defaultVisibility: 'private',
|
||||||
|
defaultVisibilityOrgId: null,
|
||||||
|
};
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate the server.ts pieceExists implementation:
|
||||||
|
// per-user dir → (global-custom skipped here) → builtin dir.
|
||||||
|
const builtinDir = join(tempDir, 'pieces');
|
||||||
|
const userFolderRoot = join(tempDir, 'users');
|
||||||
|
mkdirSync(builtinDir, { recursive: true });
|
||||||
|
// 'manual-writer' only exists in the builtin dir (used for the starter job).
|
||||||
|
writeFileSync(
|
||||||
|
join(builtinDir, 'manual-writer.yaml'),
|
||||||
|
'name: manual-writer\nmovements: []\n',
|
||||||
|
);
|
||||||
|
// 'my-custom-piece' exists ONLY in Alice's per-user dir.
|
||||||
|
const userPiecesPath = join(userFolderRoot, aliceUser.id, 'pieces');
|
||||||
|
mkdirSync(userPiecesPath, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(userPiecesPath, 'my-custom-piece.yaml'),
|
||||||
|
'name: my-custom-piece\nmovements: []\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
mountLocalTasksApi(app, {
|
||||||
|
repo,
|
||||||
|
worktreeDir: join(tempDir, 'workspaces'),
|
||||||
|
pieceExists: (name: string, ownerId?: string) => {
|
||||||
|
// Check owner's per-user dir first (mirrors worker resolution).
|
||||||
|
if (ownerId) {
|
||||||
|
if (existsSync(join(userFolderRoot, ownerId, 'pieces', `${name}.yaml`))) return true;
|
||||||
|
}
|
||||||
|
// Fall back to builtin dir.
|
||||||
|
return existsSync(join(builtinDir, `${name}.yaml`));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a piece that exists only in the owner per-user dir (regression: was 400)', async () => {
|
||||||
|
// Set up a task owned by alice with a terminal starter job.
|
||||||
|
const task = await repo.createLocalTask({
|
||||||
|
title: 't',
|
||||||
|
body: 'b',
|
||||||
|
pieceName: 'manual-writer',
|
||||||
|
ownerId: aliceUser.id,
|
||||||
|
});
|
||||||
|
const prev = await repo.createJob({
|
||||||
|
repo: localTaskRepoName(task.id),
|
||||||
|
issueNumber: task.id,
|
||||||
|
instruction: 'go',
|
||||||
|
pieceName: 'manual-writer',
|
||||||
|
ownerId: aliceUser.id,
|
||||||
|
});
|
||||||
|
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||||
|
|
||||||
|
// 'my-custom-piece' is ONLY in alice's per-user dir — should succeed.
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/local/tasks/${task.id}/continue`)
|
||||||
|
.send({ piece: 'my-custom-piece', instruction: 'continue with user-custom piece' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.jobId).toBeTruthy();
|
||||||
|
const newJob = await repo.getJob(res.body.jobId);
|
||||||
|
expect(newJob?.pieceName).toBe('my-custom-piece');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a piece that exists in neither builtin nor user-custom dir', async () => {
|
||||||
|
const task = await repo.createLocalTask({
|
||||||
|
title: 't',
|
||||||
|
body: 'b',
|
||||||
|
pieceName: 'manual-writer',
|
||||||
|
ownerId: aliceUser.id,
|
||||||
|
});
|
||||||
|
const prev = await repo.createJob({
|
||||||
|
repo: localTaskRepoName(task.id),
|
||||||
|
issueNumber: task.id,
|
||||||
|
instruction: 'go',
|
||||||
|
pieceName: 'manual-writer',
|
||||||
|
ownerId: aliceUser.id,
|
||||||
|
});
|
||||||
|
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/local/tasks/${task.id}/continue`)
|
||||||
|
.send({ piece: 'nonexistent-piece', instruction: 'go' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('piece_not_found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -18,8 +18,12 @@ export interface LocalTasksApiOptions {
|
|||||||
* Server-side validator for piece names accepted by the
|
* Server-side validator for piece names accepted by the
|
||||||
* /continue endpoint. Returns true if the piece is loadable.
|
* /continue endpoint. Returns true if the piece is loadable.
|
||||||
* When unset, /continue rejects all requests with 500 (misconfiguration).
|
* When unset, /continue rejects all requests with 500 (misconfiguration).
|
||||||
|
*
|
||||||
|
* `ownerId` is the task owner's user id; when provided the implementation
|
||||||
|
* MUST also check the owner's per-user piece dir so that user-custom pieces
|
||||||
|
* are accepted (matching the resolution order the worker uses at run time).
|
||||||
*/
|
*/
|
||||||
pieceExists?: (name: string) => boolean;
|
pieceExists?: (name: string, ownerId?: string) => boolean;
|
||||||
/**
|
/**
|
||||||
* Optional. When set, accepting browserSessionProfileId on task create
|
* Optional. When set, accepting browserSessionProfileId on task create
|
||||||
* verifies the profile belongs to the requesting user. Without it, the
|
* verifies the profile belongs to the requesting user. Without it, the
|
||||||
@ -506,7 +510,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
res.status(500).json({ error: 'piece_validation_unavailable' });
|
res.status(500).json({ error: 'piece_validation_unavailable' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!opts.pieceExists(piece)) {
|
if (!opts.pieceExists(piece, task?.ownerId ?? undefined)) {
|
||||||
res.status(400).json({ error: 'piece_not_found', piece });
|
res.status(400).json({ error: 'piece_not_found', piece });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { mountPiecesApi } from './pieces-api.js';
|
import { mountPiecesApi } from './pieces-api.js';
|
||||||
@ -295,13 +295,16 @@ describe('Pieces API (no auth — legacy behavior)', () => {
|
|||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE /api/pieces/:name deletes piece', async () => {
|
it('DELETE /api/pieces/:name — legacy no-auth pieces land in piecesDir (builtin source) and are non-deletable', async () => {
|
||||||
|
// In legacy no-auth mode with no customPiecesDir, POST writes to piecesDir.
|
||||||
|
// Those files are resolved as source='builtin' and are non-deletable.
|
||||||
await request(app).post('/api/pieces').send({
|
await request(app).post('/api/pieces').send({
|
||||||
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
|
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
|
||||||
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }],
|
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], default_next: 'COMPLETE', rules: [] }],
|
||||||
});
|
});
|
||||||
const res = await request(app).delete('/api/pieces/deleteme');
|
const res = await request(app).delete('/api/pieces/deleteme');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE /api/pieces/general is forbidden', async () => {
|
it('DELETE /api/pieces/general is forbidden', async () => {
|
||||||
@ -371,16 +374,21 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
|||||||
expect(byName['bob-tool']).toBeUndefined();
|
expect(byName['bob-tool']).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
|
it("GET /api/pieces — user-custom and built-in both appear when same name (no hiding)", async () => {
|
||||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
|
||||||
|
|
||||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
.get('/api/pieces');
|
.get('/api/pieces');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const general = res.body.pieces.find((p: any) => p.name === 'general');
|
const generals = res.body.pieces.filter((p: any) => p.name === 'general');
|
||||||
expect(general.source).toBe('user-custom');
|
// Both builtin and user-custom should appear
|
||||||
expect(general.description).toBe('alice override');
|
expect(generals).toHaveLength(2);
|
||||||
|
const customGeneral = generals.find((p: any) => p.source === 'user-custom');
|
||||||
|
const builtinGeneral = generals.find((p: any) => p.source === 'builtin');
|
||||||
|
expect(customGeneral).toBeDefined();
|
||||||
|
expect(customGeneral.description).toBe('alice override');
|
||||||
|
expect(builtinGeneral).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
|
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
|
||||||
@ -399,7 +407,9 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
|||||||
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
|
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
|
it('POST /api/pieces by admin writes to user-custom dir (not piecesDir)', async () => {
|
||||||
|
// POST always targets user-custom dir regardless of admin role.
|
||||||
|
// Admins edit built-ins via PUT on existing ones.
|
||||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
||||||
.post('/api/pieces')
|
.post('/api/pieces')
|
||||||
.send({
|
.send({
|
||||||
@ -410,7 +420,9 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
|||||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(true);
|
// Piece lands in admin's user-custom dir, NOT in piecesDir.
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'admin-piece.yaml'))).toBe(true);
|
||||||
|
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
||||||
@ -479,10 +491,11 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
|||||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('admin can edit and delete built-in pieces', async () => {
|
it('admin can edit built-in pieces (PUT 200) but NOT delete them (DELETE 403)', async () => {
|
||||||
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
|
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
|
||||||
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||||
|
|
||||||
|
// Admin CAN edit (PUT) a built-in
|
||||||
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
|
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
|
||||||
name: 'admin-target',
|
name: 'admin-target',
|
||||||
description: 'after',
|
description: 'after',
|
||||||
@ -492,8 +505,538 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
|||||||
});
|
});
|
||||||
expect(putRes.status).toBe(200);
|
expect(putRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Admin CANNOT delete a built-in — 403 for everyone
|
||||||
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
|
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
|
||||||
|
expect(delRes.status).toBe(403);
|
||||||
|
expect(delRes.body.ok).toBe(false);
|
||||||
|
expect(delRes.body.error).toMatch(/cannot delete a built-in/i);
|
||||||
|
// File must still exist
|
||||||
|
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Task 2A: built-in must not be hidden by same-named custom ---
|
||||||
|
it("GET /api/pieces — built-in is NOT hidden when user has a same-named custom", async () => {
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice chat override'));
|
||||||
|
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Both the user-custom AND the builtin should appear
|
||||||
|
const chatPieces = res.body.pieces.filter((p: any) => p.name === 'chat');
|
||||||
|
expect(chatPieces).toHaveLength(2);
|
||||||
|
const sources = chatPieces.map((p: any) => p.source).sort();
|
||||||
|
expect(sources).toEqual(['builtin', 'user-custom']);
|
||||||
|
// Custom has the custom description, builtin has the original
|
||||||
|
const customChat = chatPieces.find((p: any) => p.source === 'user-custom');
|
||||||
|
const builtinChat = chatPieces.find((p: any) => p.source === 'builtin');
|
||||||
|
expect(customChat.description).toBe('alice chat override');
|
||||||
|
expect(builtinChat.description).toBe('built-in chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Task 2B: CreatePiece rejects name collision with built-in ---
|
||||||
|
it('POST /api/pieces rejects custom creation with a built-in name', async () => {
|
||||||
|
// 'general' and 'chat' are in piecesDir (built-in)
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.post('/api/pieces')
|
||||||
|
.send({
|
||||||
|
name: 'chat',
|
||||||
|
description: 'my chat',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.error).toMatch(/built-in/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/pieces with a fresh custom name still works for non-admin', async () => {
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.post('/api/pieces')
|
||||||
|
.send({
|
||||||
|
name: 'fresh-custom',
|
||||||
|
description: 'fresh',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Task 2C: regression — non-admin DELETE of built-in → 403 ---
|
||||||
|
it('non-admin DELETE of built-in returns 403', async () => {
|
||||||
|
writeFileSync(join(piecesDir, 'deletable-builtin.yaml'), makeMinimalPieceYaml('deletable-builtin', 'del'));
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.delete('/api/pieces/deletable-builtin');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(existsSync(join(piecesDir, 'deletable-builtin.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fix 2: GET /api/pieces/:name?source=builtin fetches the specific source ---
|
||||||
|
it('GET /api/pieces/:name?source=builtin returns builtin even when user-custom exists with same name', async () => {
|
||||||
|
// Alice has a user-custom 'chat' that overrides by default priority
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||||
|
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces/chat?source=builtin');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.source).toBe('builtin');
|
||||||
|
expect(res.body.piece.description).toBe('built-in chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/pieces/:name without ?source uses priority resolution (user-custom first)', async () => {
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||||
|
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces/chat');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.source).toBe('user-custom');
|
||||||
|
expect(res.body.piece.description).toBe('alice custom chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fix 3: DELETE of a user-custom named 'chat' by its owner must succeed ---
|
||||||
|
it("DELETE /api/pieces/chat on owner's user-custom named 'chat' returns 200 (guard only blocks builtin)", async () => {
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||||
|
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.delete('/api/pieces/chat');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// User-custom file removed; built-in chat still present
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(false);
|
||||||
|
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/pieces/chat on builtin by non-admin returns 403', async () => {
|
||||||
|
// No user-custom for alice, so findPieceForCaller resolves to builtin
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.delete('/api/pieces/chat');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fix source param for PUT/DELETE (P1-b) ---
|
||||||
|
|
||||||
|
it('PUT /api/pieces/chat?source=builtin by admin updates the builtin, not a same-named custom', async () => {
|
||||||
|
// Alice (admin) has a user-custom chat AND there is a builtin chat.
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom chat'));
|
||||||
|
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await request(adminApp).put('/api/pieces/chat?source=builtin').send({
|
||||||
|
name: 'chat',
|
||||||
|
description: 'builtin updated',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Builtin was updated
|
||||||
|
const builtinContent = readFileSync(join(piecesDir, 'chat.yaml'), 'utf-8');
|
||||||
|
expect(builtinContent).toContain('builtin updated');
|
||||||
|
// Custom is untouched
|
||||||
|
const customContent = readFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), 'utf-8');
|
||||||
|
expect(customContent).toContain('admin custom chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/pieces/chat?source=builtin by admin deletes the builtin, not same-named custom', async () => {
|
||||||
|
// admin1 has a user-custom chat AND builtin chat
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom'));
|
||||||
|
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await request(adminApp).delete('/api/pieces/chat?source=builtin');
|
||||||
|
// Built-in pieces are non-deletable for everyone (including admins)
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
// Both still intact
|
||||||
|
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/pieces/extra?source=user-custom targets user-custom, not builtin', async () => {
|
||||||
|
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'builtin extra'));
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'), makeMinimalPieceYaml('extra', 'alice extra'));
|
||||||
|
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.delete('/api/pieces/extra?source=user-custom');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// User-custom removed
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'))).toBe(false);
|
||||||
|
// Builtin untouched
|
||||||
|
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// P2 fix: GET /api/pieces/:name (no ?source) includes source in response body
|
||||||
|
// so the UI can derive the correct read-only state regardless of URL params.
|
||||||
|
it('GET /api/pieces/general (no ?source) returns source: builtin in the body', async () => {
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces/general');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.piece.name).toBe('general');
|
||||||
|
expect(res.body.source).toBe('builtin');
|
||||||
|
expect(res.body.custom).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/pieces creates in user-custom dir for admin (not piecesDir)', async () => {
|
||||||
|
// Regression for P2: POST always targets user-custom, admin is no exception.
|
||||||
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
||||||
|
.post('/api/pieces')
|
||||||
|
.send({
|
||||||
|
name: 'admin-new-custom',
|
||||||
|
description: 'admin custom piece',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'admin-new-custom.yaml'))).toBe(true);
|
||||||
|
expect(existsSync(join(piecesDir, 'admin-new-custom.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Finding 2 regression: authenticated POST with userPiecesRootDir UNSET → 503
|
||||||
|
// Never falls through to shared/builtin dirs for an authenticated user.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Pieces API (Finding 2: POST auth fallback hole)', () => {
|
||||||
|
let piecesDir: string;
|
||||||
|
|
||||||
|
function makeAppWithAuthButNoUserPiecesRoot(user: UserShape | null): express.Application {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
if (user) (req as any).user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
// Intentionally: no userPiecesRootDir configured, no customPiecesDir.
|
||||||
|
mountPiecesApi(app, { piecesDir });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-f2-'));
|
||||||
|
piecesDir = join(tempDir, 'pieces');
|
||||||
|
mkdirSync(piecesDir);
|
||||||
|
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated non-admin POST with userPiecesRootDir unset returns 503 (does not write shared/builtin)', async () => {
|
||||||
|
const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'alice', role: 'user' });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/pieces')
|
||||||
|
.send({
|
||||||
|
name: 'should-not-exist',
|
||||||
|
description: 'fallback hole test',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error).toMatch(/not configured/i);
|
||||||
|
// Must NOT have written anything to the builtin dir.
|
||||||
|
expect(existsSync(join(piecesDir, 'should-not-exist.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated admin POST with userPiecesRootDir unset also returns 503', async () => {
|
||||||
|
const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'admin1', role: 'admin' });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/pieces')
|
||||||
|
.send({
|
||||||
|
name: 'admin-fallback-hole',
|
||||||
|
description: 'admin hole test',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
// Must NOT have written anything to the builtin dir.
|
||||||
|
expect(existsSync(join(piecesDir, 'admin-fallback-hole.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unauthenticated POST with no userPiecesRootDir falls back to piecesDir (legacy no-auth mode)', async () => {
|
||||||
|
// No user: genuine no-auth legacy mode — should still work (existing behavior).
|
||||||
|
const app = makeAppWithAuthButNoUserPiecesRoot(null);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/pieces')
|
||||||
|
.send({
|
||||||
|
name: 'legacy-fallback',
|
||||||
|
description: 'legacy',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(existsSync(join(piecesDir, 'legacy-fallback.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fix 1: invalid ?source param → 400 (no fallback to priority resolution)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Pieces API (Fix 1: invalid ?source → 400, no destructive fallback)', () => {
|
||||||
|
let piecesDir: string;
|
||||||
|
let userPiecesRootDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix1-'));
|
||||||
|
piecesDir = join(tempDir, 'pieces');
|
||||||
|
userPiecesRootDir = join(tempDir, 'users');
|
||||||
|
mkdirSync(piecesDir);
|
||||||
|
mkdirSync(userPiecesRootDir);
|
||||||
|
// A builtin and a user-custom with the same name
|
||||||
|
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'builtin chat'));
|
||||||
|
// Alice has a user-custom 'chat'
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeApp(user: UserShape | null) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
if (user) (req as any).user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET /api/pieces/:name?source=builtinn (typo) → 400 (not priority-fallback)', async () => {
|
||||||
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces/chat?source=builtinn');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error).toMatch(/invalid source/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/pieces/chat?source=builtinn (typo) → 400, does NOT delete user-custom', async () => {
|
||||||
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||||
|
.delete('/api/pieces/chat?source=builtinn');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error).toMatch(/invalid source/i);
|
||||||
|
// User-custom must be untouched
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/pieces/chat?source=user_custom (underscore typo) → 400, does NOT mutate user-custom', async () => {
|
||||||
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||||
|
.put('/api/pieces/chat?source=user_custom')
|
||||||
|
.send({
|
||||||
|
name: 'chat',
|
||||||
|
description: 'hijacked',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.ok).toBe(false);
|
||||||
|
expect(res.body.error).toMatch(/invalid source/i);
|
||||||
|
// User-custom description must be unchanged
|
||||||
|
const content = readFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), 'utf-8');
|
||||||
|
expect(content).toContain('alice custom chat');
|
||||||
|
expect(content).not.toContain('hijacked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('valid ?source=builtin still works (not rejected)', async () => {
|
||||||
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces/chat?source=builtin');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.source).toBe('builtin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('absent ?source still does priority resolution (not rejected)', async () => {
|
||||||
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
||||||
|
.get('/api/pieces/chat');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// priority: user-custom wins
|
||||||
|
expect(res.body.source).toBe('user-custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fix 2: POST /api/pieces returns actual source in response body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Pieces API (Fix 2: POST returns actual source)', () => {
|
||||||
|
let piecesDir: string;
|
||||||
|
let userPiecesRootDir: string;
|
||||||
|
|
||||||
|
function minimalPieceBody(name: string) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: 'test',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-'));
|
||||||
|
piecesDir = join(tempDir, 'pieces');
|
||||||
|
userPiecesRootDir = join(tempDir, 'users');
|
||||||
|
mkdirSync(piecesDir);
|
||||||
|
mkdirSync(userPiecesRootDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated POST returns source: user-custom', async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { (req as any).user = { id: 'alice', role: 'user' }; next(); });
|
||||||
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||||
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-piece'));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.source).toBe('user-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy (no-auth) POST with customPiecesDir returns source: global-custom', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-gc-'));
|
||||||
|
const gcDir = join(tempDir, 'custom');
|
||||||
|
mkdirSync(join(tempDir, 'pieces'));
|
||||||
|
mkdirSync(gcDir);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// No user set — legacy no-auth mode
|
||||||
|
mountPiecesApi(app, { piecesDir: join(tempDir, 'pieces'), customPiecesDir: gcDir });
|
||||||
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('gc-piece'));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.source).toBe('global-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy (no-auth) POST without customPiecesDir returns source: builtin', async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// No user, no customPiecesDir
|
||||||
|
mountPiecesApi(app, { piecesDir });
|
||||||
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('legacy-piece'));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.source).toBe('builtin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-auth POST with userPiecesRootDir returns source: user-custom (regression fix)', async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// No user, but userPiecesRootDir is configured (the fixed path)
|
||||||
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||||
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('noauth-custom'));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.source).toBe('user-custom');
|
||||||
|
// Piece is in data/users/local/pieces, NOT in piecesDir
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'noauth-custom.yaml'))).toBe(true);
|
||||||
|
expect(existsSync(join(piecesDir, 'noauth-custom.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regression fix: no-auth + userPiecesRootDir → 'local' user-custom namespace
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Pieces API (regression: no-auth uses local user-custom, not piecesDir)', () => {
|
||||||
|
let piecesDir: string;
|
||||||
|
let userPiecesRootDir: string;
|
||||||
|
|
||||||
|
function minimalPieceBody(name: string) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: 'test',
|
||||||
|
max_movements: 1,
|
||||||
|
initial_movement: 'only',
|
||||||
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-noauth-local-'));
|
||||||
|
piecesDir = join(tempDir, 'pieces');
|
||||||
|
userPiecesRootDir = join(tempDir, 'users');
|
||||||
|
mkdirSync(piecesDir);
|
||||||
|
mkdirSync(userPiecesRootDir);
|
||||||
|
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeNoAuthApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// No user middleware — simulates no-auth mode
|
||||||
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('no-auth POST creates under local user-custom dir, NOT in piecesDir', async () => {
|
||||||
|
const app = makeNoAuthApp();
|
||||||
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-noauth-piece'));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.source).toBe('user-custom');
|
||||||
|
// Must be in the 'local' user-custom dir
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'my-noauth-piece.yaml'))).toBe(true);
|
||||||
|
// Must NOT be in bundled piecesDir
|
||||||
|
expect(existsSync(join(piecesDir, 'my-noauth-piece.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-auth created piece (with userPiecesRootDir) is DELETABLE — DELETE returns 200', async () => {
|
||||||
|
const app = makeNoAuthApp();
|
||||||
|
// Create it first
|
||||||
|
await request(app).post('/api/pieces').send(minimalPieceBody('deletable-noauth'));
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(true);
|
||||||
|
|
||||||
|
// Now delete — must succeed (not 403)
|
||||||
|
const delRes = await request(app).delete('/api/pieces/deletable-noauth');
|
||||||
expect(delRes.status).toBe(200);
|
expect(delRes.status).toBe(200);
|
||||||
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
|
expect(delRes.body.ok).toBe(true);
|
||||||
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bundled built-in piece is still non-deletable (403) for no-auth callers', async () => {
|
||||||
|
const app = makeNoAuthApp();
|
||||||
|
const res = await request(app).delete('/api/pieces/general');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
||||||
|
expect(existsSync(join(piecesDir, 'general.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-auth LIST includes the local user-custom piece (source=user-custom)', async () => {
|
||||||
|
// Pre-create a piece in the 'local' user-custom dir
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(userPiecesRootDir, 'local', 'pieces', 'local-custom.yaml'),
|
||||||
|
makeMinimalPieceYaml('local-custom', 'no-auth local piece'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = makeNoAuthApp();
|
||||||
|
const res = await request(app).get('/api/pieces');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
|
||||||
|
expect(byName['local-custom']).toBeDefined();
|
||||||
|
expect(byName['local-custom'].source).toBe('user-custom');
|
||||||
|
expect(byName['local-custom'].ownerId).toBe('local');
|
||||||
|
// Built-in also visible
|
||||||
|
expect(byName['general']).toBeDefined();
|
||||||
|
expect(byName['general'].source).toBe('builtin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-auth GET resolves user-custom piece from local dir by priority', async () => {
|
||||||
|
mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(userPiecesRootDir, 'local', 'pieces', 'my-piece.yaml'),
|
||||||
|
makeMinimalPieceYaml('my-piece', 'local custom'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = makeNoAuthApp();
|
||||||
|
const res = await request(app).get('/api/pieces/my-piece');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.source).toBe('user-custom');
|
||||||
|
expect(res.body.ownerId).toBe('local');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -100,6 +100,19 @@ function validateName(name: string): boolean {
|
|||||||
return VALID_PIECE_NAME.test(name);
|
return VALID_PIECE_NAME.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_SOURCES = new Set<string>(['builtin', 'user-custom', 'global-custom']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `source` is absent (caller wants priority resolution).
|
||||||
|
* Returns false when `source` is a known valid value.
|
||||||
|
* Returns a 400 error string when `source` is present but unrecognised.
|
||||||
|
*/
|
||||||
|
function parseSourceParam(source: string | undefined): { valid: true; value: PieceSource | undefined } | { valid: false; error: string } {
|
||||||
|
if (source === undefined) return { valid: true, value: undefined };
|
||||||
|
if (VALID_SOURCES.has(source)) return { valid: true, value: source as PieceSource };
|
||||||
|
return { valid: false, error: 'Invalid source' };
|
||||||
|
}
|
||||||
|
|
||||||
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
|
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
|
||||||
if (customPiecesDir) {
|
if (customPiecesDir) {
|
||||||
const customPath = join(customPiecesDir, `${name}.yaml`);
|
const customPath = join(customPiecesDir, `${name}.yaml`);
|
||||||
@ -122,6 +135,9 @@ export interface PiecesApiOptions {
|
|||||||
userPiecesRootDir?: string;
|
userPiecesRootDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */
|
||||||
|
const LOCAL_OWNER = 'local';
|
||||||
|
|
||||||
type AuthedUser = { id: string; role?: string };
|
type AuthedUser = { id: string; role?: string };
|
||||||
|
|
||||||
function getUser(req: Request): AuthedUser | undefined {
|
function getUser(req: Request): AuthedUser | undefined {
|
||||||
@ -137,6 +153,7 @@ function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
|
|||||||
/**
|
/**
|
||||||
* Lookup priority for a given caller:
|
* Lookup priority for a given caller:
|
||||||
* 1. Caller's own user-custom dir (overrides everything below).
|
* 1. Caller's own user-custom dir (overrides everything below).
|
||||||
|
* No-auth callers use the 'local' owner id.
|
||||||
* 2. Global custom dir (admin-managed, all users see).
|
* 2. Global custom dir (admin-managed, all users see).
|
||||||
* 3. Built-in dir.
|
* 3. Built-in dir.
|
||||||
*/
|
*/
|
||||||
@ -145,9 +162,10 @@ function findPieceForCaller(
|
|||||||
user: AuthedUser | undefined,
|
user: AuthedUser | undefined,
|
||||||
name: string,
|
name: string,
|
||||||
): { path: string; source: PieceSource; ownerId?: string } | null {
|
): { path: string; source: PieceSource; ownerId?: string } | null {
|
||||||
if (opts.userPiecesRootDir && user) {
|
if (opts.userPiecesRootDir) {
|
||||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, user.id), `${name}.yaml`);
|
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||||
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId: user.id };
|
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${name}.yaml`);
|
||||||
|
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId };
|
||||||
}
|
}
|
||||||
if (opts.customPiecesDir) {
|
if (opts.customPiecesDir) {
|
||||||
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
|
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
|
||||||
@ -178,30 +196,31 @@ export function mountPiecesApi(
|
|||||||
app.get('/api/pieces', (req: Request, res: Response) => {
|
app.get('/api/pieces', (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const user = getUser(req);
|
const user = getUser(req);
|
||||||
const seen = new Set<string>();
|
|
||||||
const pieces: PieceSummary[] = [];
|
const pieces: PieceSummary[] = [];
|
||||||
|
|
||||||
// Order matters: user-custom overrides global-custom, which overrides built-in.
|
// Build custom sources (user-custom first, then global-custom).
|
||||||
const sources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
// Within custom umbrella: dedup by name so user-custom wins over global-custom.
|
||||||
if (opts.userPiecesRootDir && user) {
|
// Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom.
|
||||||
const ucDir = userPiecesDir(opts.userPiecesRootDir, user.id);
|
const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
||||||
if (existsSync(ucDir)) sources.push({ dir: ucDir, source: 'user-custom', ownerId: user.id });
|
if (opts.userPiecesRootDir) {
|
||||||
|
// No-auth callers use the 'local' owner id, mirroring worker and piece-catalog defaults.
|
||||||
|
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||||
|
const ucDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
|
||||||
|
if (existsSync(ucDir)) customSources.push({ dir: ucDir, source: 'user-custom', ownerId });
|
||||||
}
|
}
|
||||||
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
|
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
|
||||||
sources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
||||||
}
|
|
||||||
if (existsSync(opts.piecesDir)) {
|
|
||||||
sources.push({ dir: opts.piecesDir, source: 'builtin' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { dir, source, ownerId } of sources) {
|
// Emit custom pieces (dedup within custom umbrella only).
|
||||||
|
const seenCustom = new Set<string>();
|
||||||
|
for (const { dir, source, ownerId } of customSources) {
|
||||||
for (const f of listPieceFiles(dir)) {
|
for (const f of listPieceFiles(dir)) {
|
||||||
try {
|
try {
|
||||||
const p = loadPieceFile(f);
|
const p = loadPieceFile(f);
|
||||||
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||||
if (seen.has(name)) continue;
|
if (seenCustom.has(name)) continue;
|
||||||
seen.add(name);
|
seenCustom.add(name);
|
||||||
// Drift is meaningful only for global-custom that shadows a built-in.
|
|
||||||
let drift: DriftStatus | undefined;
|
let drift: DriftStatus | undefined;
|
||||||
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
|
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
|
||||||
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
|
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
|
||||||
@ -212,7 +231,7 @@ export function mountPiecesApi(
|
|||||||
description: p.description,
|
description: p.description,
|
||||||
triggers: p.triggers,
|
triggers: p.triggers,
|
||||||
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
||||||
custom: source !== 'builtin',
|
custom: true,
|
||||||
source,
|
source,
|
||||||
ownerId,
|
ownerId,
|
||||||
drift,
|
drift,
|
||||||
@ -222,6 +241,27 @@ export function mountPiecesApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always emit ALL built-ins (never hidden by custom pieces of the same name).
|
||||||
|
if (existsSync(opts.piecesDir)) {
|
||||||
|
for (const f of listPieceFiles(opts.piecesDir)) {
|
||||||
|
try {
|
||||||
|
const p = loadPieceFile(f);
|
||||||
|
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||||
|
pieces.push({
|
||||||
|
name,
|
||||||
|
description: p.description,
|
||||||
|
triggers: p.triggers,
|
||||||
|
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
||||||
|
custom: false,
|
||||||
|
source: 'builtin',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip malformed piece files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ pieces });
|
res.json({ pieces });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: `Failed to list pieces: ${e}` });
|
res.status(500).json({ error: `Failed to list pieces: ${e}` });
|
||||||
@ -232,7 +272,31 @@ export function mountPiecesApi(
|
|||||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||||
try {
|
try {
|
||||||
const user = getUser(req);
|
const user = getUser(req);
|
||||||
const found = findPieceForCaller(opts, user, req.params.name);
|
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||||
|
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||||
|
const requestedSource = sourceParsed.value;
|
||||||
|
|
||||||
|
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||||
|
|
||||||
|
if (requestedSource === 'builtin') {
|
||||||
|
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||||
|
} else if (requestedSource === 'user-custom') {
|
||||||
|
if (opts.userPiecesRootDir) {
|
||||||
|
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||||
|
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||||
|
}
|
||||||
|
} else if (requestedSource === 'global-custom') {
|
||||||
|
if (opts.customPiecesDir) {
|
||||||
|
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No source param: priority resolution (user-custom > global-custom > builtin).
|
||||||
|
found = findPieceForCaller(opts, user, req.params.name);
|
||||||
|
}
|
||||||
|
|
||||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||||
const piece = loadPieceFile(found.path);
|
const piece = loadPieceFile(found.path);
|
||||||
res.json({
|
res.json({
|
||||||
@ -253,7 +317,29 @@ export function mountPiecesApi(
|
|||||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||||
try {
|
try {
|
||||||
const user = getUser(req);
|
const user = getUser(req);
|
||||||
const found = findPieceForCaller(opts, user, req.params.name);
|
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||||
|
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||||
|
const requestedSource = sourceParsed.value;
|
||||||
|
|
||||||
|
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||||
|
if (requestedSource === 'builtin') {
|
||||||
|
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||||
|
} else if (requestedSource === 'user-custom') {
|
||||||
|
if (opts.userPiecesRootDir) {
|
||||||
|
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||||
|
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||||
|
}
|
||||||
|
} else if (requestedSource === 'global-custom') {
|
||||||
|
if (opts.customPiecesDir) {
|
||||||
|
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
found = findPieceForCaller(opts, user, req.params.name);
|
||||||
|
}
|
||||||
|
|
||||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||||
|
|
||||||
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
|
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
|
||||||
@ -262,7 +348,7 @@ export function mountPiecesApi(
|
|||||||
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
|
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
|
} else if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
|
||||||
// Different user's user-custom — and not admin. Should be unreachable since
|
// Different user's user-custom — and not admin. Should be unreachable since
|
||||||
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
|
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
|
||||||
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
|
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
|
||||||
@ -293,33 +379,55 @@ export function mountPiecesApi(
|
|||||||
if (error) { res.status(400).json({ ok: false, error }); return; }
|
if (error) { res.status(400).json({ ok: false, error }); return; }
|
||||||
|
|
||||||
const user = getUser(req);
|
const user = getUser(req);
|
||||||
const adminOrLegacy = isAdminOrLegacy(user);
|
|
||||||
|
|
||||||
// Determine destination dir:
|
// POST always creates in user-custom dir. "+" is always Create Custom.
|
||||||
// - admin / legacy → preserve existing behavior (write to piecesDir).
|
// Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins.
|
||||||
// - non-admin user → write to their user-custom dir.
|
// No-auth / legacy with userPiecesRootDir: use the 'local' owner id so pieces are
|
||||||
|
// user-custom (deletable) and never pollute piecesDir.
|
||||||
|
// No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir.
|
||||||
let destDir: string;
|
let destDir: string;
|
||||||
if (adminOrLegacy) {
|
let createdSource: PieceSource;
|
||||||
destDir = opts.piecesDir;
|
if (opts.userPiecesRootDir) {
|
||||||
|
// Both authenticated and no-auth go to user-custom dir when userPiecesRootDir is set.
|
||||||
|
// Authenticated users: 503 guard below is only needed when root is NOT set.
|
||||||
|
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||||
|
destDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
|
||||||
|
mkdirSync(destDir, { recursive: true });
|
||||||
|
createdSource = 'user-custom';
|
||||||
|
} else if (user) {
|
||||||
|
// Authenticated caller but userPiecesRootDir is not configured — cannot safely write.
|
||||||
|
res.status(503).json({ ok: false, error: 'User piece storage not configured' });
|
||||||
|
return;
|
||||||
|
} else if (opts.customPiecesDir) {
|
||||||
|
// Legacy (no-auth) with global custom dir configured.
|
||||||
|
destDir = opts.customPiecesDir;
|
||||||
|
mkdirSync(destDir, { recursive: true });
|
||||||
|
createdSource = 'global-custom';
|
||||||
} else {
|
} else {
|
||||||
if (!opts.userPiecesRootDir) {
|
// Pure legacy single-user: fall back to piecesDir (existing behavior).
|
||||||
res.status(503).json({ ok: false, error: 'User pieces directory not configured on this server' });
|
destDir = opts.piecesDir;
|
||||||
|
createdSource = 'builtin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always reject if the name collides with a built-in — Custom and Default are
|
||||||
|
// separate namespaces. (This also covers the admin case, since admins should
|
||||||
|
// use PUT to update an existing built-in, not POST to create a duplicate.)
|
||||||
|
const builtinPath = join(opts.piecesDir, `${req.body.name}.yaml`);
|
||||||
|
if (existsSync(builtinPath) && destDir !== opts.piecesDir) {
|
||||||
|
res.status(409).json({ ok: false, error: `"${req.body.name}" は組み込み (built-in) Piece と同名です。Custom Piece には別名を付けてください。` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
destDir = userPiecesDir(opts.userPiecesRootDir, user!.id);
|
|
||||||
mkdirSync(destDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject if any visible-to-caller piece with this name already exists
|
// Reject if the caller's visible piece with this name already exists
|
||||||
// (built-in, global-custom, or caller's user-custom).
|
// (global-custom or caller's own user-custom).
|
||||||
if (findPieceForCaller(opts, user, req.body.name)) {
|
if (findPieceForCaller(opts, user, req.body.name)) {
|
||||||
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
|
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = join(destDir, `${req.body.name}.yaml`);
|
const filePath = join(destDir, `${req.body.name}.yaml`);
|
||||||
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
|
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
|
||||||
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} actor=${user?.id ?? 'legacy'}`);
|
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} source=${createdSource} actor=${user?.id ?? 'legacy'}`);
|
||||||
res.status(201).json({ ok: true });
|
res.status(201).json({ ok: true, source: createdSource });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: `Failed to create piece: ${e}` });
|
res.status(500).json({ error: `Failed to create piece: ${e}` });
|
||||||
}
|
}
|
||||||
@ -327,24 +435,52 @@ export function mountPiecesApi(
|
|||||||
|
|
||||||
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
|
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
|
||||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||||
if (req.params.name === 'general' || req.params.name === 'chat') {
|
|
||||||
res.status(403).json({ ok: false, error: 'Cannot delete general piece' }); return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const user = getUser(req);
|
const user = getUser(req);
|
||||||
const found = findPieceForCaller(opts, user, req.params.name);
|
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||||
|
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||||
|
const requestedSource = sourceParsed.value;
|
||||||
|
|
||||||
|
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||||
|
if (requestedSource === 'builtin') {
|
||||||
|
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||||
|
} else if (requestedSource === 'user-custom') {
|
||||||
|
if (opts.userPiecesRootDir) {
|
||||||
|
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||||
|
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||||
|
}
|
||||||
|
} else if (requestedSource === 'global-custom') {
|
||||||
|
if (opts.customPiecesDir) {
|
||||||
|
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||||
|
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
found = findPieceForCaller(opts, user, req.params.name);
|
||||||
|
}
|
||||||
|
|
||||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||||
|
|
||||||
// Authz mirrors PUT: built-in / global-custom → admin; user-custom → owner.
|
// Built-in (Default) pieces are non-deletable for everyone — including admins.
|
||||||
if (found.source !== 'user-custom') {
|
// This covers general/chat and all other built-ins. Admins may still EDIT
|
||||||
|
// built-ins via PUT; only deletion is prohibited.
|
||||||
|
if (found.source === 'builtin') {
|
||||||
|
res.status(403).json({ ok: false, error: 'Cannot delete a built-in (Default) piece' }); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authz for non-builtin sources: global-custom → admin; user-custom → owner.
|
||||||
|
if (found.source === 'global-custom') {
|
||||||
if (!isAdminOrLegacy(user)) {
|
if (!isAdminOrLegacy(user)) {
|
||||||
res.status(403).json({ ok: false, error: 'Only admins can delete built-in or global-custom pieces' });
|
res.status(403).json({ ok: false, error: 'Only admins can delete global-custom pieces' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
|
} else if (found.source === 'user-custom') {
|
||||||
|
if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
|
||||||
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
|
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
unlinkSync(found.path);
|
unlinkSync(found.path);
|
||||||
logger.info(`[pieces-api] deleted piece=${req.params.name} source=${found.source} actor=${user?.id ?? 'legacy'}`);
|
logger.info(`[pieces-api] deleted piece=${req.params.name} source=${found.source} actor=${user?.id ?? 'legacy'}`);
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import { createWorkerMetrics, type WorkerMetrics } from '../metrics/worker-metri
|
|||||||
import { createMetricsHandler } from '../metrics/http-handler.js';
|
import { createMetricsHandler } from '../metrics/http-handler.js';
|
||||||
import { buildDirectProbe, buildProxyProbe } from '../engine/backend-probes.js';
|
import { buildDirectProbe, buildProxyProbe } from '../engine/backend-probes.js';
|
||||||
import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
|
import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
|
||||||
|
import { userPiecesDir } from '../user-folder/paths.js';
|
||||||
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
|
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
|
||||||
import type { AuthConfig } from '../config.js';
|
import type { AuthConfig } from '../config.js';
|
||||||
import { isKeyConfigured } from '../mcp/crypto.js';
|
import { isKeyConfigured } from '../mcp/crypto.js';
|
||||||
@ -777,7 +778,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
generateTitle: opts.generateTitle,
|
generateTitle: opts.generateTitle,
|
||||||
selectPiece: opts.selectPiece,
|
selectPiece: opts.selectPiece,
|
||||||
pieceExists: opts.piecesDir
|
pieceExists: opts.piecesDir
|
||||||
? (name: string) => findPieceFile(name, opts.piecesDir!, opts.customPiecesDir) !== null
|
? (name: string, ownerId?: string) => {
|
||||||
|
// Mirror the worker's per-user → global-custom → builtin resolution order.
|
||||||
|
// 1. Owner's per-user dir (matches what worker uses at job run time).
|
||||||
|
// No-auth tasks (ownerId null) fall back to 'local', mirroring the worker.
|
||||||
|
const ufl = loadConfig().userFolderRoot ?? './data/users';
|
||||||
|
const ownerForPieces = ownerId ?? 'local';
|
||||||
|
if (existsSync(join(userPiecesDir(ufl, ownerForPieces), `${name}.yaml`))) return true;
|
||||||
|
// 2. Global-custom + builtin via existing helper.
|
||||||
|
return findPieceFile(name, opts.piecesDir!, opts.customPiecesDir) !== null;
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
sessRepo,
|
sessRepo,
|
||||||
getMaxUploadMb: opts.configManager
|
getMaxUploadMb: opts.configManager
|
||||||
|
|||||||
@ -396,9 +396,9 @@ movements:
|
|||||||
expect(() => loadPiece('multi', 'pieces', tempDir)).not.toThrow();
|
expect(() => loadPiece('multi', 'pieces', tempDir)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('all 13 bundled pieces load without validation errors', () => {
|
it('all 12 bundled pieces load without validation errors', () => {
|
||||||
const piecesDir = join(process.cwd(), 'pieces');
|
const piecesDir = join(process.cwd(), 'pieces');
|
||||||
const names = ['brainstorming', 'chat', 'data-process', 'game-tweet-generator', 'general',
|
const names = ['brainstorming', 'chat', 'data-process', 'general',
|
||||||
'office-process', 'piece-builder', 'research', 'slide', 'sns-research',
|
'office-process', 'piece-builder', 'research', 'slide', 'sns-research',
|
||||||
'ssh-console', 'ssh-ops', 'x-ai-digest'];
|
'ssh-console', 'ssh-ops', 'x-ai-digest'];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
@ -1111,3 +1111,50 @@ describe('allowed_ssh_connections validation (Phase 4)', () => {
|
|||||||
expect(() => validatePieceDef(piece)).toThrow(/Piece "ssh-test" has invalid allowed_ssh_connections/);
|
expect(() => validatePieceDef(piece)).toThrow(/Piece "ssh-test" has invalid allowed_ssh_connections/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Task 1: loadPiece multi-dir support ---
|
||||||
|
describe('loadPiece multi-dir (string | string[])', () => {
|
||||||
|
it('resolves from a list of custom dirs (per-user wins over builtin name miss)', () => {
|
||||||
|
const dirA = mkdtempSync(join(tmpdir(), 'pa-')); // empty
|
||||||
|
const dirB = mkdtempSync(join(tmpdir(), 'pb-'));
|
||||||
|
writeFileSync(
|
||||||
|
join(dirB, 'mycustom.yaml'),
|
||||||
|
`name: mycustom\ndescription: d\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
|
||||||
|
);
|
||||||
|
// array form: searches dirA then dirB then builtin
|
||||||
|
const p = loadPiece('mycustom', 'pieces', [dirA, dirB]);
|
||||||
|
expect(p.name).toBe('mycustom');
|
||||||
|
// builtin still resolvable when not in any custom dir
|
||||||
|
expect(() => loadPiece('chat', 'pieces', [dirA, dirB])).not.toThrow();
|
||||||
|
rmSync(dirA, { recursive: true });
|
||||||
|
rmSync(dirB, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first dir wins when same name appears in two custom dirs', () => {
|
||||||
|
const dirA = mkdtempSync(join(tmpdir(), 'pa-'));
|
||||||
|
const dirB = mkdtempSync(join(tmpdir(), 'pb-'));
|
||||||
|
writeFileSync(
|
||||||
|
join(dirA, 'dup.yaml'),
|
||||||
|
`name: dup\ndescription: from-a\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dirB, 'dup.yaml'),
|
||||||
|
`name: dup\ndescription: from-b\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
|
||||||
|
);
|
||||||
|
const p = loadPiece('dup', 'pieces', [dirA, dirB]);
|
||||||
|
expect(p.description).toBe('from-a');
|
||||||
|
rmSync(dirA, { recursive: true });
|
||||||
|
rmSync(dirB, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('string form still works (backward compat)', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'pc-'));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'strcompat.yaml'),
|
||||||
|
`name: strcompat\ndescription: str\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
|
||||||
|
);
|
||||||
|
const p = loadPiece('strcompat', 'pieces', dir);
|
||||||
|
expect(p.name).toBe('strcompat');
|
||||||
|
rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -209,29 +209,22 @@ export function validateAllowedSshConnections(piece: PieceDef): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pieces/ ディレクトリから piece 定義を読み込む(customPiecesDir を優先探索)
|
// pieces/ ディレクトリから piece 定義を読み込む(customPiecesDir を優先探索)
|
||||||
export function loadPiece(pieceName: string, piecesDir: string = 'pieces', customPiecesDir?: string): PieceDef {
|
// customPiecesDir は string | string[] を受け付ける(配列の場合は先頭から順に探索)
|
||||||
|
export function loadPiece(pieceName: string, piecesDir: string = 'pieces', customPiecesDir?: string | string[]): PieceDef {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
let source: string;
|
let source: string;
|
||||||
if (customPiecesDir) {
|
const customDirs = customPiecesDir == null ? [] : (Array.isArray(customPiecesDir) ? customPiecesDir : [customPiecesDir]);
|
||||||
const customPath = join(customPiecesDir, `${pieceName}.yaml`);
|
const found = customDirs
|
||||||
if (existsSync(customPath)) {
|
.map((d) => join(d, `${pieceName}.yaml`))
|
||||||
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=custom path=${customPath}`);
|
.find((p) => existsSync(p));
|
||||||
raw = readFileSync(customPath, 'utf-8');
|
if (found) {
|
||||||
|
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=custom path=${found}`);
|
||||||
|
raw = readFileSync(found, 'utf-8');
|
||||||
source = 'custom';
|
source = 'custom';
|
||||||
} else {
|
} else {
|
||||||
const filePath = join(piecesDir, `${pieceName}.yaml`);
|
const filePath = join(piecesDir, `${pieceName}.yaml`);
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${customPiecesDir}, ${piecesDir}]`);
|
logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${[...customDirs, piecesDir].join(', ')}]`);
|
||||||
throw new Error(`Piece not found: ${pieceName}`);
|
|
||||||
}
|
|
||||||
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=builtin path=${filePath}`);
|
|
||||||
raw = readFileSync(filePath, 'utf-8');
|
|
||||||
source = 'builtin';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const filePath = join(piecesDir, `${pieceName}.yaml`);
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${piecesDir}]`);
|
|
||||||
throw new Error(`Piece not found: ${pieceName}`);
|
throw new Error(`Piece not found: ${pieceName}`);
|
||||||
}
|
}
|
||||||
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=builtin path=${filePath}`);
|
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=builtin path=${filePath}`);
|
||||||
@ -252,11 +245,12 @@ export function loadPiece(pieceName: string, piecesDir: string = 'pieces', custo
|
|||||||
/**
|
/**
|
||||||
* pieces/ ディレクトリ内の全 Piece から triggers を読み込む(customPiecesDir があれば優先、同名は custom が勝つ)
|
* pieces/ ディレクトリ内の全 Piece から triggers を読み込む(customPiecesDir があれば優先、同名は custom が勝つ)
|
||||||
*/
|
*/
|
||||||
export function loadAllPieceTriggers(piecesDir: string = 'pieces', customPiecesDir?: string): Array<{ name: string; keywords: string[] }> {
|
export function loadAllPieceTriggers(piecesDir: string = 'pieces', customPiecesDir?: string | string[]): Array<{ name: string; keywords: string[] }> {
|
||||||
const triggers: Array<{ name: string; keywords: string[] }> = [];
|
const triggers: Array<{ name: string; keywords: string[] }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
const dirs = customPiecesDir ? [customPiecesDir, piecesDir] : [piecesDir];
|
const customDirsArr = Array.isArray(customPiecesDir) ? customPiecesDir : (customPiecesDir ? [customPiecesDir] : []);
|
||||||
|
const dirs = [...customDirsArr, piecesDir];
|
||||||
logger.info(`[piece-runner] loadAllPieceTriggers scanning dirs=[${dirs.join(', ')}]`);
|
logger.info(`[piece-runner] loadAllPieceTriggers scanning dirs=[${dirs.join(', ')}]`);
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
@ -299,7 +293,7 @@ export async function runPiece(
|
|||||||
abortController?: AbortController;
|
abortController?: AbortController;
|
||||||
safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off' };
|
safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off' };
|
||||||
searchFilter?: SearchFilterConfig;
|
searchFilter?: SearchFilterConfig;
|
||||||
customPiecesDir?: string;
|
customPiecesDir?: string | string[];
|
||||||
contextManager?: ContextManager;
|
contextManager?: ContextManager;
|
||||||
vlmEnabled?: boolean;
|
vlmEnabled?: boolean;
|
||||||
/** Phase 5: parent's job id, used to populate MemoryHandoff.parentJobId
|
/** Phase 5: parent's job id, used to populate MemoryHandoff.parentJobId
|
||||||
@ -657,7 +651,7 @@ function prepareMovementContext(
|
|||||||
options?: {
|
options?: {
|
||||||
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
|
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
|
||||||
searchFilter?: SearchFilterConfig;
|
searchFilter?: SearchFilterConfig;
|
||||||
customPiecesDir?: string;
|
customPiecesDir?: string | string[];
|
||||||
vlmEnabled?: boolean;
|
vlmEnabled?: boolean;
|
||||||
/** Traceability T-1: per-run event logger threaded into ToolContext. */
|
/** Traceability T-1: per-run event logger threaded into ToolContext. */
|
||||||
eventLogger?: EventLogger;
|
eventLogger?: EventLogger;
|
||||||
@ -724,6 +718,9 @@ function prepareMovementContext(
|
|||||||
toolsConfig,
|
toolsConfig,
|
||||||
eventLogger: options?.eventLogger,
|
eventLogger: options?.eventLogger,
|
||||||
searchFilter: options?.searchFilter,
|
searchFilter: options?.searchFilter,
|
||||||
|
// Pass the full array so in-agent reads (ListPieces/GetPiece) can see ALL
|
||||||
|
// custom dirs (per-user + global-custom). CreatePiece writes to dirs[0]
|
||||||
|
// (the per-user dir) — handled inside pieces.ts.
|
||||||
customPiecesDir: options?.customPiecesDir,
|
customPiecesDir: options?.customPiecesDir,
|
||||||
spawnSubTask: options?.spawnSubTask,
|
spawnSubTask: options?.spawnSubTask,
|
||||||
missionBrief: options?.missionBrief,
|
missionBrief: options?.missionBrief,
|
||||||
|
|||||||
142
src/engine/tools/excel-styles.test.ts
Normal file
142
src/engine/tools/excel-styles.test.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import { mkdtempSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { resolveThemePalette, applyTint, extractSheetStyles } from './excel-styles.js';
|
||||||
|
|
||||||
|
async function roundTrip(build: (ws: ExcelJS.Worksheet) => void): Promise<ExcelJS.Worksheet> {
|
||||||
|
const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('S'); build(ws);
|
||||||
|
const p = join(mkdtempSync(join(tmpdir(), 'xls-')), 't.xlsx');
|
||||||
|
await wb.xlsx.writeFile(p);
|
||||||
|
const wb2 = new ExcelJS.Workbook(); await wb2.xlsx.readFile(p);
|
||||||
|
return wb2.getWorksheet('S')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveThemePalette', () => {
|
||||||
|
it('parses srgbClr + sysClr and applies the lt/dk swap', () => {
|
||||||
|
const xml = `<a:theme><a:themeElements><a:clrScheme name="x">
|
||||||
|
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
|
||||||
|
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
|
||||||
|
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
||||||
|
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
||||||
|
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
|
||||||
|
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
||||||
|
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
||||||
|
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
||||||
|
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
|
||||||
|
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
||||||
|
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
||||||
|
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
||||||
|
</a:clrScheme></a:themeElements></a:theme>`;
|
||||||
|
const pal = resolveThemePalette(xml);
|
||||||
|
expect(pal[0]).toBe('#FFFFFF'); // theme idx 0 = Light1 = lt1
|
||||||
|
expect(pal[1]).toBe('#000000'); // theme idx 1 = Dark1 = dk1
|
||||||
|
expect(pal[4]).toBe('#4472C4'); // accent1
|
||||||
|
});
|
||||||
|
it('returns [] on missing scheme', () => { expect(resolveThemePalette(undefined)).toEqual([]); });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTint', () => {
|
||||||
|
it('lightens with positive tint and darkens with negative', () => {
|
||||||
|
const lighter = applyTint('#4472C4', 0.4);
|
||||||
|
const darker = applyTint('#4472C4', -0.4);
|
||||||
|
expect(lighter).not.toBe('#4472C4');
|
||||||
|
expect(darker).not.toBe('#4472C4');
|
||||||
|
// positive tint raises luminance
|
||||||
|
const lum = (h: string) => parseInt(h.slice(1,3),16)+parseInt(h.slice(3,5),16)+parseInt(h.slice(5,7),16);
|
||||||
|
expect(lum(lighter)).toBeGreaterThan(lum('#4472C4'));
|
||||||
|
expect(lum(darker)).toBeLessThan(lum('#4472C4'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractSheetStyles', () => {
|
||||||
|
it('dedups identical fills into one legend entry with a merged range', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
for (const a of ['A1','B1','C1','D1']) ws.getCell(a).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FFFFF2CC' } };
|
||||||
|
});
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.legend.length).toBe(1);
|
||||||
|
expect(r.legend[0].sig).toContain('#FFF2CC');
|
||||||
|
expect(r.assignments[0].ranges).toEqual(['A1:D1']); // horizontal run merged
|
||||||
|
});
|
||||||
|
it('captures a decorated but EMPTY cell', async () => {
|
||||||
|
const ws = await roundTrip((ws) => { ws.getCell('B5').fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FFFF0000' } }; });
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.assignments.some(a => a.ranges.includes('B5'))).toBe(true);
|
||||||
|
});
|
||||||
|
it('reports font bold + color', async () => {
|
||||||
|
const ws = await roundTrip((ws) => { ws.getCell('A1').value='x'; ws.getCell('A1').font={ bold:true, color:{ argb:'FF9C0006' } }; });
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.legend[0].sig).toMatch(/font:.*bold/);
|
||||||
|
expect(r.legend[0].sig).toContain('#9C0006');
|
||||||
|
});
|
||||||
|
it('merges vertically-adjacent identical column-runs into a rectangle', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
for (let row=2; row<=20; row++) ws.getCell(`B${row}`).fill={ type:'pattern', pattern:'solid', fgColor:{ argb:'FF00FF00' } };
|
||||||
|
});
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.assignments[0].ranges).toEqual(['B2:B20']);
|
||||||
|
});
|
||||||
|
it('respects max_style_ranges cap', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
// 5 disjoint single cells with distinct fills (no merge possible)
|
||||||
|
const colors=['FFAA0000','FF00AA00','FF0000AA','FFAAAA00','FF00AAAA'];
|
||||||
|
colors.forEach((c,i)=>{ ws.getCell(`A${i*2+1}`).fill={type:'pattern',pattern:'solid',fgColor:{argb:c}}; });
|
||||||
|
});
|
||||||
|
const r = extractSheetStyles(ws, [], 2);
|
||||||
|
expect(r.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix 4 (M-1): merges
|
||||||
|
it('includes merged cell range in result merges', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
ws.getCell('A1').value = 'merged';
|
||||||
|
ws.mergeCells('A1:C2');
|
||||||
|
});
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.merges).toContain('A1:C2');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix 4 (M-2): conditional formatting — round-trip keeps conditionalFormattings
|
||||||
|
it('detects conditional formatting', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
ws.addConditionalFormatting({
|
||||||
|
ref: 'A1:A10',
|
||||||
|
rules: [{ type: 'expression', formulae: ['TRUE'], style: { fill: { type: 'pattern', pattern: 'solid', bgColor: { argb: 'FFFF0000' } } } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// ExcelJS preserves conditionalFormattings after round-trip (empirically verified)
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.conditionalFormatting).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix 4 (M-3): border + numFmt + alignment
|
||||||
|
it('captures border, numFmt, and alignment in cell signature', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
const cell = ws.getCell('B2');
|
||||||
|
cell.value = 0.42;
|
||||||
|
cell.border = { bottom: { style: 'thin' } };
|
||||||
|
cell.numFmt = '0.00%';
|
||||||
|
cell.alignment = { horizontal: 'center' };
|
||||||
|
});
|
||||||
|
const r = extractSheetStyles(ws, [], 250);
|
||||||
|
expect(r.legend.length).toBeGreaterThan(0);
|
||||||
|
const sig = r.legend[0]!.sig;
|
||||||
|
expect(sig).toContain('border:');
|
||||||
|
expect(sig).toContain('numFmt:"0.00%"');
|
||||||
|
expect(sig).toContain('align:');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix 4 (I-1 range honoring): rangeBounds filters cells
|
||||||
|
it('honors rangeBounds — includes A1 but excludes Z99', async () => {
|
||||||
|
const ws = await roundTrip((ws) => {
|
||||||
|
ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0000FF' } };
|
||||||
|
ws.getCell('Z99').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF00FF00' } };
|
||||||
|
});
|
||||||
|
const r = extractSheetStyles(ws, [], 250, { minRow: 1, maxRow: 5, minCol: 1, maxCol: 5 });
|
||||||
|
const allRanges = r.assignments.flatMap(a => a.ranges);
|
||||||
|
expect(allRanges.some(rng => rng === 'A1' || rng.startsWith('A1:'))).toBe(true);
|
||||||
|
expect(allRanges.every(rng => !rng.includes('Z99') && !rng.startsWith('Z'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
515
src/engine/tools/excel-styles.ts
Normal file
515
src/engine/tools/excel-styles.ts
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
/**
|
||||||
|
* excel-styles.ts — Pure helper for ExcelJS style extraction.
|
||||||
|
* No dependency on office.ts internals; only imports exceljs types + stdlib.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
export interface SheetStyleResult {
|
||||||
|
legend: Array<{ id: string; sig: string }>; // s1 = "fill:#FFF2CC;font:bold,#9C0006"
|
||||||
|
assignments: Array<{ id: string; ranges: string[] }>; // s1: ['A1:D1','A10:D10']
|
||||||
|
merges: string[]; // ['A1:D1']
|
||||||
|
conditionalFormatting: boolean;
|
||||||
|
truncated: boolean; // true if range count hit the cap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse <a:clrScheme> from a theme1 XML string into a theme-index→'#RRGGBB' map.
|
||||||
|
* Returns [] if themeXml is missing/unparseable (caller then emits raw theme refs).
|
||||||
|
*/
|
||||||
|
export function resolveThemePalette(themeXml: string | undefined): string[] {
|
||||||
|
if (!themeXml) return [];
|
||||||
|
|
||||||
|
// Extract the clrScheme block
|
||||||
|
const schemeMatch = themeXml.match(/<a:clrScheme[^>]*>([\s\S]*?)<\/a:clrScheme>/);
|
||||||
|
if (!schemeMatch) return [];
|
||||||
|
const schemeXml = schemeMatch[1]!;
|
||||||
|
|
||||||
|
// The 12 entries in XML order: dk1, lt1, dk2, lt2, accent1-6, hlink, folHlink
|
||||||
|
const tagNames = ['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'];
|
||||||
|
|
||||||
|
const scheme: string[] = [];
|
||||||
|
for (const tag of tagNames) {
|
||||||
|
const tagMatch = schemeXml.match(new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`));
|
||||||
|
if (!tagMatch) {
|
||||||
|
// Missing entry
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const block = tagMatch[0]!;
|
||||||
|
|
||||||
|
// Try srgbClr first
|
||||||
|
const srgbMatch = block.match(/<a:srgbClr[^>]*val="([0-9A-Fa-f]{6})"/);
|
||||||
|
if (srgbMatch) {
|
||||||
|
scheme.push('#' + srgbMatch[1]!.toUpperCase());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try sysClr with lastClr
|
||||||
|
const sysMatch = block.match(/<a:sysClr[^>]*lastClr="([0-9A-Fa-f]{6})"/);
|
||||||
|
if (sysMatch) {
|
||||||
|
scheme.push('#' + sysMatch[1]!.toUpperCase());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrecognized
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.length < 12) return [];
|
||||||
|
|
||||||
|
// Apply the well-known lt/dk swap:
|
||||||
|
// themeIndex 0=Light1=lt1=scheme[1], 1=Dark1=dk1=scheme[0],
|
||||||
|
// 2=Light2=lt2=scheme[3], 3=Dark2=dk2=scheme[2],
|
||||||
|
// 4-9=accent1-6=scheme[4..9], 10=hlink=scheme[10], 11=folHlink=scheme[11]
|
||||||
|
return [
|
||||||
|
scheme[1]!, // 0: Light1 (lt1)
|
||||||
|
scheme[0]!, // 1: Dark1 (dk1)
|
||||||
|
scheme[3]!, // 2: Light2 (lt2)
|
||||||
|
scheme[2]!, // 3: Dark2 (dk2)
|
||||||
|
scheme[4]!, // 4: accent1
|
||||||
|
scheme[5]!, // 5: accent2
|
||||||
|
scheme[6]!, // 6: accent3
|
||||||
|
scheme[7]!, // 7: accent4
|
||||||
|
scheme[8]!, // 8: accent5
|
||||||
|
scheme[9]!, // 9: accent6
|
||||||
|
scheme[10]!, // 10: hlink
|
||||||
|
scheme[11]!, // 11: folHlink
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OOXML tint applied to a '#RRGGBB' hex. tint in [-1,1]. Best-effort (HSL lum).
|
||||||
|
* if (tint < 0) L = L * (1 + tint); else L = L * (1 - tint) + tint
|
||||||
|
*/
|
||||||
|
export function applyTint(hexRgb: string, tint: number): string {
|
||||||
|
if (tint === 0) return hexRgb;
|
||||||
|
|
||||||
|
// Parse hex to [0,1]
|
||||||
|
const r = parseInt(hexRgb.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hexRgb.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hexRgb.slice(5, 7), 16) / 255;
|
||||||
|
|
||||||
|
// RGB to HSL
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h = 0;
|
||||||
|
let s = 0;
|
||||||
|
let l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||||
|
case g: h = ((b - r) / d + 2) / 6; break;
|
||||||
|
case b: h = ((r - g) / d + 4) / 6; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tint to luminance
|
||||||
|
if (tint < 0) {
|
||||||
|
l = l * (1 + tint);
|
||||||
|
} else {
|
||||||
|
l = l * (1 - tint) + tint;
|
||||||
|
}
|
||||||
|
l = Math.max(0, Math.min(1, l));
|
||||||
|
|
||||||
|
// HSL back to RGB
|
||||||
|
function hue2rgb(p: number, q: number, t: number): number {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1/2) return q;
|
||||||
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rOut: number, gOut: number, bOut: number;
|
||||||
|
if (s === 0) {
|
||||||
|
rOut = gOut = bOut = l;
|
||||||
|
} else {
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
rOut = hue2rgb(p, q, h + 1/3);
|
||||||
|
gOut = hue2rgb(p, q, h);
|
||||||
|
bOut = hue2rgb(p, q, h - 1/3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toHex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0').toUpperCase();
|
||||||
|
return '#' + toHex(rOut) + toHex(gOut) + toHex(bOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
// Standard 56-color indexed palette (BIFF8/OOXML standard colors)
|
||||||
|
// Index 64 = system fg, 65 = system bg → omit (return undefined)
|
||||||
|
const INDEXED_COLORS: (string | undefined)[] = [
|
||||||
|
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||||
|
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||||
|
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#C0C0C0', '#808080',
|
||||||
|
'#9999FF', '#993366', '#FFFFCC', '#CCFFFF', '#660066', '#FF8080', '#0066CC', '#CCCCFF',
|
||||||
|
'#000080', '#FF00FF', '#FFFF00', '#00FFFF', '#800080', '#800000', '#008080', '#0000FF',
|
||||||
|
'#00CCFF', '#CCFFFF', '#CCFFCC', '#FFFF99', '#99CCFF', '#FF99CC', '#CC99FF', '#FFCC99',
|
||||||
|
'#3366FF', '#33CCCC', '#99CC00', '#FFCC00', '#FF9900', '#FF6600', '#666699', '#969696',
|
||||||
|
'#003366', '#339966', '#003300', '#333300', '#993300', '#993366', '#333399', '#333333',
|
||||||
|
undefined, // 64 = system fg
|
||||||
|
undefined, // 65 = system bg
|
||||||
|
];
|
||||||
|
|
||||||
|
type ExcelJSColor = { argb?: string; theme?: number; tint?: number; indexed?: number };
|
||||||
|
|
||||||
|
function renderColor(color: ExcelJSColor | undefined, palette: string[]): string | null {
|
||||||
|
if (!color) return null;
|
||||||
|
|
||||||
|
if (color.argb) {
|
||||||
|
// ARGB: 'FFRRGGBB' → '#RRGGBB'
|
||||||
|
const argb = color.argb;
|
||||||
|
if (argb.length === 8) {
|
||||||
|
return '#' + argb.slice(2).toUpperCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.theme !== undefined) {
|
||||||
|
const tint = color.tint ?? 0;
|
||||||
|
if (palette.length > 0 && palette[color.theme]) {
|
||||||
|
const resolved = applyTint(palette[color.theme]!, tint);
|
||||||
|
if (tint !== 0) {
|
||||||
|
return `theme(${color.theme},tint=${tint.toFixed(4)},resolved=${resolved})`;
|
||||||
|
}
|
||||||
|
return `theme(${color.theme},resolved=${resolved})`;
|
||||||
|
}
|
||||||
|
// Palette empty or missing this index — raw only
|
||||||
|
if (tint !== 0) {
|
||||||
|
return `theme(${color.theme},tint=${tint.toFixed(4)})`;
|
||||||
|
}
|
||||||
|
return `theme(${color.theme})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.indexed !== undefined) {
|
||||||
|
const hex = INDEXED_COLORS[color.indexed];
|
||||||
|
if (!hex) return null; // system fg/bg
|
||||||
|
return `indexed(${color.indexed},${hex})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a rendered color token represents the default color for the given theme index.
|
||||||
|
* Handles both `theme(N)` (no palette) and `theme(N,resolved=...)` (palette resolved) forms.
|
||||||
|
* Used to suppress the default font color (theme 1 = Dark1 ≈ black) and default border color
|
||||||
|
* (theme 0 = Light1 ≈ white/auto) from style signatures.
|
||||||
|
*/
|
||||||
|
function isDefaultColorToken(token: string, defaultThemeIndex: number): boolean {
|
||||||
|
// Exact bare form: "theme(N)"
|
||||||
|
if (token === `theme(${defaultThemeIndex})`) return true;
|
||||||
|
// Resolved form: "theme(N,resolved=#RRGGBB)" — tint variants are NOT default
|
||||||
|
if (token.startsWith(`theme(${defaultThemeIndex},resolved=`) && !token.includes(',tint=')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a canonical style signature string from non-default cell parts.
|
||||||
|
* Returns null if the cell has no non-default styling.
|
||||||
|
*/
|
||||||
|
function cellStyleSignature(
|
||||||
|
cell: ExcelJS.Cell,
|
||||||
|
palette: string[],
|
||||||
|
): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Fill: only pattern=solid (or other patterns) with fgColor
|
||||||
|
const fill = cell.fill as ExcelJS.Fill | undefined;
|
||||||
|
if (fill && (fill.type === 'pattern' || fill.type === 'gradient')) {
|
||||||
|
if (fill.type === 'pattern' && fill.pattern && fill.pattern !== 'none') {
|
||||||
|
const pf = fill as ExcelJS.FillPattern;
|
||||||
|
if (pf.fgColor) {
|
||||||
|
const colorStr = renderColor(pf.fgColor as ExcelJSColor, palette);
|
||||||
|
if (colorStr) {
|
||||||
|
parts.push(`fill:${colorStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fill.type === 'gradient') {
|
||||||
|
parts.push('fill:gradient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font: only explicit flags + explicit color
|
||||||
|
const font = cell.font as ExcelJS.Font | undefined;
|
||||||
|
if (font) {
|
||||||
|
const fontParts: string[] = [];
|
||||||
|
if (font.bold === true) fontParts.push('bold');
|
||||||
|
if (font.italic === true) fontParts.push('italic');
|
||||||
|
if (font.underline) fontParts.push('underline');
|
||||||
|
|
||||||
|
// Color: only include if explicitly set (not the default theme 1 / auto-color)
|
||||||
|
const fontColor = renderColor(font.color as ExcelJSColor | undefined, palette);
|
||||||
|
if (fontColor && !isDefaultColorToken(fontColor, 1)) {
|
||||||
|
fontParts.push(fontColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontParts.length > 0) {
|
||||||
|
parts.push(`font:${fontParts.join(',')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border: for each side with a style
|
||||||
|
const border = cell.border as ExcelJS.Borders | undefined;
|
||||||
|
if (border) {
|
||||||
|
const sides: string[] = [];
|
||||||
|
for (const side of ['top', 'bottom', 'left', 'right', 'diagonal'] as const) {
|
||||||
|
const edge = border[side] as ExcelJS.Border | undefined;
|
||||||
|
if (edge?.style) {
|
||||||
|
const colorStr = renderColor(edge.color as ExcelJSColor | undefined, palette);
|
||||||
|
if (colorStr && !isDefaultColorToken(colorStr, 0)) {
|
||||||
|
sides.push(`${side}(${edge.style},${colorStr})`);
|
||||||
|
} else {
|
||||||
|
sides.push(`${side}(${edge.style})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sides.length > 0) {
|
||||||
|
parts.push(`border:${sides.join(',')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// numFmt
|
||||||
|
const numFmt = cell.numFmt as string | undefined;
|
||||||
|
if (numFmt && numFmt !== 'General' && numFmt !== '') {
|
||||||
|
parts.push(`numFmt:"${numFmt}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment
|
||||||
|
const alignment = cell.alignment as ExcelJS.Alignment | undefined;
|
||||||
|
if (alignment) {
|
||||||
|
const alignParts: string[] = [];
|
||||||
|
if (alignment.horizontal) alignParts.push(alignment.horizontal);
|
||||||
|
if (alignment.vertical) alignParts.push(alignment.vertical);
|
||||||
|
if (alignment.wrapText) alignParts.push('wrap');
|
||||||
|
if (alignParts.length > 0) {
|
||||||
|
parts.push(`align:${alignParts.join(',')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return parts.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an Excel range like "A1:C3" into 1-based row/col bounds. Returns null on parse failure. */
|
||||||
|
function parseRange(rangeStr: string): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
|
||||||
|
const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
function letterToCol(letters: string): number {
|
||||||
|
let col = 0;
|
||||||
|
for (const ch of letters.toUpperCase()) {
|
||||||
|
col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
minRow: parseInt(match[2]!, 10),
|
||||||
|
maxRow: parseInt(match[4]!, 10),
|
||||||
|
minCol: letterToCol(match[1]!),
|
||||||
|
maxCol: letterToCol(match[3]!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert 1-based column index to Excel column letter(s). */
|
||||||
|
function colIndexToLetter(colIdx: number): string {
|
||||||
|
let s = '';
|
||||||
|
let n = colIdx;
|
||||||
|
while (n > 0) {
|
||||||
|
const rem = (n - 1) % 26;
|
||||||
|
s = String.fromCharCode(65 + rem) + s;
|
||||||
|
n = Math.floor((n - 1) / 26);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a worksheet (includeEmpty:true so decorated-but-empty cells count) and
|
||||||
|
* produce the dedup legend + range map. `palette` from resolveThemePalette.
|
||||||
|
*
|
||||||
|
* @param rangeBounds - When provided, only cells within [minRow..maxRow] x [minCol..maxCol]
|
||||||
|
* are scanned, and merges are filtered to those intersecting this region. This must match
|
||||||
|
* the same range filter applied to the values table so styles and values cover the same cells.
|
||||||
|
*/
|
||||||
|
export function extractSheetStyles(
|
||||||
|
ws: ExcelJS.Worksheet,
|
||||||
|
palette: string[],
|
||||||
|
maxRanges: number,
|
||||||
|
rangeBounds?: { minRow: number; maxRow: number; minCol: number; maxCol: number },
|
||||||
|
): SheetStyleResult {
|
||||||
|
// Step 1: Scan all cells, build sig→id map and per-sig cell list
|
||||||
|
const sigToId = new Map<string, string>();
|
||||||
|
const cellsBySig = new Map<string, Array<[number, number]>>();
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
ws.eachRow({ includeEmpty: true }, (row, r) => {
|
||||||
|
if (rangeBounds && (r < rangeBounds.minRow || r > rangeBounds.maxRow)) return;
|
||||||
|
row.eachCell({ includeEmpty: true }, (cell, c) => {
|
||||||
|
if (rangeBounds && (c < rangeBounds.minCol || c > rangeBounds.maxCol)) return;
|
||||||
|
const sig = cellStyleSignature(cell, palette);
|
||||||
|
if (!sig) return;
|
||||||
|
|
||||||
|
if (!sigToId.has(sig)) {
|
||||||
|
sigToId.set(sig, `s${++counter}`);
|
||||||
|
cellsBySig.set(sig, []);
|
||||||
|
}
|
||||||
|
cellsBySig.get(sig)!.push([r, c]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Build legend in id order
|
||||||
|
const idOrder: Array<{ id: string; sig: string }> = [];
|
||||||
|
for (const [sig, id] of sigToId) {
|
||||||
|
idOrder.push({ id, sig });
|
||||||
|
}
|
||||||
|
idOrder.sort((a, b) => {
|
||||||
|
const na = parseInt(a.id.slice(1), 10);
|
||||||
|
const nb = parseInt(b.id.slice(1), 10);
|
||||||
|
return na - nb;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: For each sig, group cells into rectangles
|
||||||
|
const assignments: Array<{ id: string; ranges: string[] }> = [];
|
||||||
|
let totalRanges = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
for (const { id, sig } of idOrder) {
|
||||||
|
if (truncated) break;
|
||||||
|
|
||||||
|
const cells = cellsBySig.get(sig)!;
|
||||||
|
|
||||||
|
// Bucket by row
|
||||||
|
const byRow = new Map<number, number[]>();
|
||||||
|
for (const [r, c] of cells) {
|
||||||
|
if (!byRow.has(r)) byRow.set(r, []);
|
||||||
|
byRow.get(r)!.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each row, sort and split into contiguous horizontal runs
|
||||||
|
// A run is (row, colStart, colEnd)
|
||||||
|
const allRuns: Array<{ r: number; c1: number; c2: number }> = [];
|
||||||
|
for (const [r, cols] of byRow) {
|
||||||
|
cols.sort((a, b) => a - b);
|
||||||
|
let start = cols[0]!;
|
||||||
|
let prev = cols[0]!;
|
||||||
|
for (let i = 1; i < cols.length; i++) {
|
||||||
|
if (cols[i]! !== prev + 1) {
|
||||||
|
allRuns.push({ r, c1: start, c2: prev });
|
||||||
|
start = cols[i]!;
|
||||||
|
}
|
||||||
|
prev = cols[i]!;
|
||||||
|
}
|
||||||
|
allRuns.push({ r, c1: start, c2: prev });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort runs by row, then c1
|
||||||
|
allRuns.sort((a, b) => a.r !== b.r ? a.r - b.r : a.c1 - b.c1);
|
||||||
|
|
||||||
|
// Merge vertically: greedy - open rectangles extended when next row has same c1,c2.
|
||||||
|
// O(K) implementation: openRects is keyed by (c1,c2); after finishing each row r,
|
||||||
|
// any rect not extended this row (r2 !== r) is flushed to closedRects immediately.
|
||||||
|
type Rect = { r1: number; r2: number; c1: number; c2: number };
|
||||||
|
// Key: `${c1},${c2}` → open rect
|
||||||
|
const openMap = new Map<string, Rect>();
|
||||||
|
const closedRects: Rect[] = [];
|
||||||
|
|
||||||
|
// Group runs by row so we can flush after each row
|
||||||
|
const runsByRow = new Map<number, Array<{ c1: number; c2: number }>>();
|
||||||
|
for (const run of allRuns) {
|
||||||
|
if (!runsByRow.has(run.r)) runsByRow.set(run.r, []);
|
||||||
|
runsByRow.get(run.r)!.push({ c1: run.c1, c2: run.c2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process rows in ascending order
|
||||||
|
const sortedRows = [...runsByRow.keys()].sort((a, b) => a - b);
|
||||||
|
for (const r of sortedRows) {
|
||||||
|
const runs = runsByRow.get(r)!;
|
||||||
|
const extendedKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const { c1, c2 } of runs) {
|
||||||
|
const key = `${c1},${c2}`;
|
||||||
|
const existing = openMap.get(key);
|
||||||
|
if (existing && existing.r2 === r - 1) {
|
||||||
|
// Extend the open rect into this row
|
||||||
|
existing.r2 = r;
|
||||||
|
extendedKeys.add(key);
|
||||||
|
} else {
|
||||||
|
// If there was a stale open rect with this key, close it first
|
||||||
|
if (existing) {
|
||||||
|
closedRects.push(existing);
|
||||||
|
}
|
||||||
|
// Open a new rect
|
||||||
|
openMap.set(key, { r1: r, r2: r, c1, c2 });
|
||||||
|
extendedKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any open rects that were NOT extended this row (r2 !== r)
|
||||||
|
for (const [key, rect] of openMap) {
|
||||||
|
if (rect.r2 !== r) {
|
||||||
|
closedRects.push(rect);
|
||||||
|
openMap.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all remaining open rects
|
||||||
|
for (const rect of openMap.values()) {
|
||||||
|
closedRects.push(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit ranges
|
||||||
|
const ranges: string[] = [];
|
||||||
|
for (const rect of closedRects) {
|
||||||
|
if (totalRanges >= maxRanges) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let rangeStr: string;
|
||||||
|
if (rect.r1 === rect.r2 && rect.c1 === rect.c2) {
|
||||||
|
rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}`;
|
||||||
|
} else {
|
||||||
|
rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}:${colIndexToLetter(rect.c2)}${rect.r2}`;
|
||||||
|
}
|
||||||
|
ranges.push(rangeStr);
|
||||||
|
totalRanges++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ranges.length > 0) {
|
||||||
|
assignments.push({ id, ranges });
|
||||||
|
}
|
||||||
|
if (truncated) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Merges from ws.model.merges (filtered to rangeBounds when provided)
|
||||||
|
const allMerges: string[] = (ws.model.merges as string[] | undefined) ?? [];
|
||||||
|
const merges: string[] = rangeBounds
|
||||||
|
? allMerges.filter((m) => {
|
||||||
|
// Parse merge range "A1:C3" and check for intersection with rangeBounds
|
||||||
|
const mp = parseRange(m);
|
||||||
|
if (!mp) return true; // unparseable: keep it
|
||||||
|
// Two rectangles intersect if NOT (one is completely to the side/above/below the other)
|
||||||
|
return !(mp.maxRow < rangeBounds.minRow || mp.minRow > rangeBounds.maxRow ||
|
||||||
|
mp.maxCol < rangeBounds.minCol || mp.minCol > rangeBounds.maxCol);
|
||||||
|
})
|
||||||
|
: allMerges;
|
||||||
|
|
||||||
|
// Step 5: Conditional formatting
|
||||||
|
const conditionalFormatting = Boolean(
|
||||||
|
((ws as unknown) as { conditionalFormattings?: unknown[] }).conditionalFormattings?.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
legend: idOrder,
|
||||||
|
assignments,
|
||||||
|
merges,
|
||||||
|
conditionalFormatting,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -358,4 +358,27 @@ describe('Read* tools — format mismatch rejection (issue #246)', () => {
|
|||||||
expect(result?.isError).toBe(false);
|
expect(result?.isError).toBe(false);
|
||||||
expect(result?.output).toContain('Sheet1');
|
expect(result?.output).toContain('Sheet1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ReadExcel includes a Styles section only when include_styles=true', async () => {
|
||||||
|
workspacePath = makeWorkspace();
|
||||||
|
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
|
||||||
|
|
||||||
|
const ExcelJS = (await import('exceljs')).default;
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
const ws = wb.addWorksheet('Sheet1');
|
||||||
|
ws.getCell('A1').value = 'Header';
|
||||||
|
ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF2CC' } };
|
||||||
|
ws.getCell('A1').font = { bold: true };
|
||||||
|
await wb.xlsx.writeFile(path.join(workspacePath, 'input', 'styled.xlsx'));
|
||||||
|
|
||||||
|
// Without include_styles: no Styles section (backward compat)
|
||||||
|
const plain = await executeTool('ReadExcel', { path: 'input/styled.xlsx' }, makeContext(workspacePath));
|
||||||
|
expect(plain!.output).not.toContain('### Styles');
|
||||||
|
|
||||||
|
// With include_styles: Styles section present with fill color and font bold
|
||||||
|
const styled = await executeTool('ReadExcel', { path: 'input/styled.xlsx', include_styles: true }, makeContext(workspacePath));
|
||||||
|
expect(styled!.output).toContain('### Styles');
|
||||||
|
expect(styled!.output).toContain('#FFF2CC');
|
||||||
|
expect(styled!.output).toMatch(/bold/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { PDFParse } from 'pdf-parse';
|
|||||||
import { ToolDef } from '../../llm/openai-compat.js';
|
import { ToolDef } from '../../llm/openai-compat.js';
|
||||||
import type { ToolContext, ToolResult } from './core.js';
|
import type { ToolContext, ToolResult } from './core.js';
|
||||||
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
|
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
|
||||||
|
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { callVisionModel, resolveImagePath } from './image.js';
|
import { callVisionModel, resolveImagePath } from './image.js';
|
||||||
import type {
|
import type {
|
||||||
@ -204,7 +205,7 @@ const READ_EXCEL_DEF: ToolDef = {
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'ReadExcel',
|
name: 'ReadExcel',
|
||||||
description: 'Excel (.xlsx) を読み取りテキストで返す。詳細は ReadToolDoc({ name: "ReadExcel" })。',
|
description: 'Excel (.xlsx) を読み取りテキストで返す。include_styles:true でセル装飾も取得可。詳細は ReadToolDoc({ name: "ReadExcel" })。',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@ -212,6 +213,8 @@ const READ_EXCEL_DEF: ToolDef = {
|
|||||||
sheet: { type: 'string', description: 'シート名(省略時は全シート)' },
|
sheet: { type: 'string', description: 'シート名(省略時は全シート)' },
|
||||||
range: { type: 'string', description: 'セル範囲(例: A1:D10、省略時はシート全体)' },
|
range: { type: 'string', description: 'セル範囲(例: A1:D10、省略時はシート全体)' },
|
||||||
max_cells: { type: 'number', description: '最大セル数(デフォルト: 1000)' },
|
max_cells: { type: 'number', description: '最大セル数(デフォルト: 1000)' },
|
||||||
|
include_styles: { type: 'boolean', description: 'true でセル装飾(背景色/フォント/罫線/書式/結合)を ### Styles として追記。デフォルト false' },
|
||||||
|
max_style_ranges: { type: 'number', description: 'include_styles 時に出力する style range の上限(デフォルト 250)' },
|
||||||
},
|
},
|
||||||
required: ['path'],
|
required: ['path'],
|
||||||
},
|
},
|
||||||
@ -538,6 +541,8 @@ async function executeReadExcel(
|
|||||||
const sheetFilter = typeof input['sheet'] === 'string' ? input['sheet'] : undefined;
|
const sheetFilter = typeof input['sheet'] === 'string' ? input['sheet'] : undefined;
|
||||||
const rangeFilter = typeof input['range'] === 'string' ? input['range'] : undefined;
|
const rangeFilter = typeof input['range'] === 'string' ? input['range'] : undefined;
|
||||||
const maxCells = typeof input['max_cells'] === 'number' ? input['max_cells'] : 1000;
|
const maxCells = typeof input['max_cells'] === 'number' ? input['max_cells'] : 1000;
|
||||||
|
const includeStyles = input['include_styles'] === true;
|
||||||
|
const maxStyleRanges = typeof input['max_style_ranges'] === 'number' ? input['max_style_ranges'] : 250;
|
||||||
|
|
||||||
let resolved: string;
|
let resolved: string;
|
||||||
try {
|
try {
|
||||||
@ -583,6 +588,9 @@ async function executeReadExcel(
|
|||||||
return { output: `Failed to read Excel file: ${(e as Error).message}`, isError: true };
|
return { output: `Failed to read Excel file: ${(e as Error).message}`, isError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const themeXml = includeStyles ? ((wb.model as unknown as { themes?: { theme1?: string } }).themes?.theme1) : undefined;
|
||||||
|
const palette = includeStyles ? resolveThemePalette(themeXml) : [];
|
||||||
|
|
||||||
const filename = path.basename(resolved);
|
const filename = path.basename(resolved);
|
||||||
const sheetBlocks: ExcelSheetBlock[] = [];
|
const sheetBlocks: ExcelSheetBlock[] = [];
|
||||||
let totalCells = 0;
|
let totalCells = 0;
|
||||||
@ -697,6 +705,26 @@ async function executeReadExcel(
|
|||||||
parts.push('');
|
parts.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeStyles) {
|
||||||
|
let rangeBudget = maxStyleRanges;
|
||||||
|
for (const ws of wb.worksheets) {
|
||||||
|
if (sheetFilter && ws.name !== sheetFilter) continue;
|
||||||
|
const styleBounds = rangeFilter ? parseRange(rangeFilter) : null;
|
||||||
|
const styles = extractSheetStyles(ws, palette, rangeBudget, styleBounds ?? undefined);
|
||||||
|
rangeBudget -= styles.assignments.reduce((n, a) => n + a.ranges.length, 0);
|
||||||
|
if (styles.legend.length === 0 && styles.merges.length === 0 && !styles.conditionalFormatting) continue;
|
||||||
|
parts.push(`### Styles — ${ws.name}`);
|
||||||
|
for (const { id, sig } of styles.legend) parts.push(`${id} = ${sig}`);
|
||||||
|
parts.push('');
|
||||||
|
for (const { id, ranges } of styles.assignments) parts.push(`${id}: ${ranges.join(',')}`);
|
||||||
|
if (styles.merges.length) parts.push(`merges: ${styles.merges.join(',')}`);
|
||||||
|
if (styles.conditionalFormatting) parts.push('conditionalFormatting: present (effective styles not evaluated)');
|
||||||
|
if (styles.truncated || rangeBudget <= 0) parts.push(`[styles truncated: max_style_ranges=${maxStyleRanges} reached]`);
|
||||||
|
parts.push('');
|
||||||
|
if (rangeBudget <= 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.warnings.length > 0) {
|
if (doc.warnings.length > 0) {
|
||||||
parts.push('### Warnings');
|
parts.push('### Warnings');
|
||||||
for (const w of doc.warnings) {
|
for (const w of doc.warnings) {
|
||||||
|
|||||||
@ -52,8 +52,11 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const builtinPath = join('pieces', `${piece}.yaml`);
|
const builtinPath = join('pieces', `${piece}.yaml`);
|
||||||
const customPath = ctx.customPiecesDir ? join(ctx.customPiecesDir, `${piece}.yaml`) : null;
|
const customDirs = ctx.customPiecesDir
|
||||||
if (!existsSync(builtinPath) && !(customPath && existsSync(customPath))) {
|
? (Array.isArray(ctx.customPiecesDir) ? ctx.customPiecesDir : [ctx.customPiecesDir])
|
||||||
|
: [];
|
||||||
|
const customExists = customDirs.some(d => existsSync(join(d, `${piece}.yaml`)));
|
||||||
|
if (!existsSync(builtinPath) && !customExists) {
|
||||||
return {
|
return {
|
||||||
output: `指定されたピース "${piece}" が見つかりません。利用可能なピースを確認してください。`,
|
output: `指定されたピース "${piece}" が見つかりません。利用可能なピースを確認してください。`,
|
||||||
isError: true,
|
isError: true,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, cpSync } from 'fs';
|
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, cpSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { executeTool } from './pieces.js';
|
import { executeTool } from './pieces.js';
|
||||||
@ -87,6 +87,111 @@ movements:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('customPiecesDir as array — P1-a regression', () => {
|
||||||
|
/**
|
||||||
|
* When worker passes customPiecesDir=[userDir, globalDir] to ToolContext,
|
||||||
|
* ListPieces and GetPiece must see pieces from BOTH dirs.
|
||||||
|
* CreatePiece must write to dirs[0] (per-user dir, not global).
|
||||||
|
*/
|
||||||
|
let userDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userDir = mkdtempSync(join(tmpdir(), 'pieces-user-'));
|
||||||
|
globalDir = mkdtempSync(join(tmpdir(), 'pieces-global-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(userDir, { recursive: true, force: true });
|
||||||
|
rmSync(globalDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPiece = `name: user-only-piece
|
||||||
|
description: user piece
|
||||||
|
max_movements: 1
|
||||||
|
initial_movement: do
|
||||||
|
movements:
|
||||||
|
- name: do
|
||||||
|
edit: false
|
||||||
|
persona: p
|
||||||
|
instruction: i
|
||||||
|
allowed_tools: []
|
||||||
|
rules:
|
||||||
|
- condition: done
|
||||||
|
next: do
|
||||||
|
`;
|
||||||
|
|
||||||
|
const globalPiece = `name: global-only-piece
|
||||||
|
description: global piece
|
||||||
|
max_movements: 1
|
||||||
|
initial_movement: do
|
||||||
|
movements:
|
||||||
|
- name: do
|
||||||
|
edit: false
|
||||||
|
persona: p
|
||||||
|
instruction: i
|
||||||
|
allowed_tools: []
|
||||||
|
rules:
|
||||||
|
- condition: done
|
||||||
|
next: do
|
||||||
|
`;
|
||||||
|
|
||||||
|
function arrayCtx(): ToolContext {
|
||||||
|
return { workspacePath: '/tmp/dummy', editAllowed: false, customPiecesDir: [userDir, globalDir] };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ListPieces returns pieces from BOTH per-user and global-custom dirs', async () => {
|
||||||
|
writeFileSync(join(userDir, 'user-only-piece.yaml'), userPiece, 'utf-8');
|
||||||
|
writeFileSync(join(globalDir, 'global-only-piece.yaml'), globalPiece, 'utf-8');
|
||||||
|
|
||||||
|
const result = await executeTool('ListPieces', {}, arrayCtx());
|
||||||
|
expect(result?.isError).toBe(false);
|
||||||
|
expect(result?.output).toContain('user-only-piece');
|
||||||
|
expect(result?.output).toContain('global-only-piece');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetPiece finds a piece in the second custom dir (global-custom)', async () => {
|
||||||
|
writeFileSync(join(globalDir, 'global-only-piece.yaml'), globalPiece, 'utf-8');
|
||||||
|
|
||||||
|
const result = await executeTool('GetPiece', { name: 'global-only-piece' }, arrayCtx());
|
||||||
|
expect(result?.isError).toBe(false);
|
||||||
|
expect(result?.output).toContain('global piece');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetPiece prefers dirs[0] (per-user) over dirs[1] (global) for same name', async () => {
|
||||||
|
const userVersion = userPiece.replace('description: user piece', 'description: per-user version');
|
||||||
|
writeFileSync(join(userDir, 'global-only-piece.yaml'), userVersion, 'utf-8');
|
||||||
|
writeFileSync(join(globalDir, 'global-only-piece.yaml'), globalPiece, 'utf-8');
|
||||||
|
|
||||||
|
const result = await executeTool('GetPiece', { name: 'global-only-piece' }, arrayCtx());
|
||||||
|
expect(result?.isError).toBe(false);
|
||||||
|
expect(result?.output).toContain('per-user version');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CreatePiece writes to dirs[0] (per-user dir), not global dir', async () => {
|
||||||
|
const newPiece = `name: brand-new-piece
|
||||||
|
description: new
|
||||||
|
max_movements: 5
|
||||||
|
initial_movement: do
|
||||||
|
movements:
|
||||||
|
- name: do
|
||||||
|
edit: false
|
||||||
|
persona: p
|
||||||
|
instruction: i
|
||||||
|
allowed_tools: []
|
||||||
|
rules:
|
||||||
|
- condition: done
|
||||||
|
next: do
|
||||||
|
`;
|
||||||
|
const result = await executeTool('CreatePiece', { yaml_content: newPiece }, arrayCtx());
|
||||||
|
expect(result?.isError).toBe(false);
|
||||||
|
// Must land in userDir (dirs[0])
|
||||||
|
expect(existsSync(join(userDir, 'brand-new-piece.yaml'))).toBe(true);
|
||||||
|
// Must NOT land in globalDir (dirs[1])
|
||||||
|
expect(existsSync(join(globalDir, 'brand-new-piece.yaml'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('UpdatePiece', () => {
|
describe('UpdatePiece', () => {
|
||||||
// Uses the real bundled `chat.yaml` to exercise the built-in guard. The
|
// Uses the real bundled `chat.yaml` to exercise the built-in guard. The
|
||||||
// file's pre-update content is captured up-front so we can detect any
|
// file's pre-update content is captured up-front so we can detect any
|
||||||
|
|||||||
@ -7,10 +7,20 @@ import type { ToolContext, ToolResult } from './core.js';
|
|||||||
const BUILTIN_PIECES_DIR = resolve(process.cwd(), 'pieces');
|
const BUILTIN_PIECES_DIR = resolve(process.cwd(), 'pieces');
|
||||||
const VALID_NAME = /^[a-z0-9-]+$/;
|
const VALID_NAME = /^[a-z0-9-]+$/;
|
||||||
|
|
||||||
function findPiecePath(name: string, customDir: string | undefined): string | null {
|
/** Normalise `string | string[] | undefined` → `string[]` (may be empty). */
|
||||||
if (customDir) {
|
function toCustomDirs(customDir: string | string[] | undefined): string[] {
|
||||||
const customPath = join(customDir, `${name}.yaml`);
|
if (!customDir) return [];
|
||||||
if (existsSync(customPath)) return customPath;
|
return Array.isArray(customDir) ? customDir : [customDir];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search all custom dirs in order, then fall back to built-in.
|
||||||
|
* Returns the first path that exists, or null.
|
||||||
|
*/
|
||||||
|
function findPiecePath(name: string, customDir: string | string[] | undefined): string | null {
|
||||||
|
for (const d of toCustomDirs(customDir)) {
|
||||||
|
const p = join(d, `${name}.yaml`);
|
||||||
|
if (existsSync(p)) return p;
|
||||||
}
|
}
|
||||||
const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`);
|
const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`);
|
||||||
if (existsSync(builtinPath)) return builtinPath;
|
if (existsSync(builtinPath)) return builtinPath;
|
||||||
@ -19,17 +29,16 @@ function findPiecePath(name: string, customDir: string | undefined): string | nu
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A piece is "built-in" when it lives only under the bundled BUILTIN_PIECES_DIR
|
* A piece is "built-in" when it lives only under the bundled BUILTIN_PIECES_DIR
|
||||||
* (no override in customDir). Built-ins are git-tracked and shipped with the
|
* (no override in any custom dir). Built-ins are git-tracked and shipped with the
|
||||||
* app — letting the LLM rewrite them in place corrupts the install (a real
|
* app — letting the LLM rewrite them in place corrupts the install (a real
|
||||||
* incident: the agent silently replaced game-tweet-generator with a version
|
* incident: the agent silently replaced game-tweet-generator with a version
|
||||||
* missing max_movements, making every subsequent run abort instantly). The
|
* missing max_movements, making every subsequent run abort instantly). The
|
||||||
* LLM should use CreatePiece with a new name to derive a customized variant
|
* LLM should use CreatePiece with a new name to derive a customized variant
|
||||||
* instead.
|
* instead.
|
||||||
*/
|
*/
|
||||||
function isBuiltinOnly(name: string, customDir: string | undefined): boolean {
|
function isBuiltinOnly(name: string, customDir: string | string[] | undefined): boolean {
|
||||||
if (customDir) {
|
for (const d of toCustomDirs(customDir)) {
|
||||||
const customPath = join(customDir, `${name}.yaml`);
|
if (existsSync(join(d, `${name}.yaml`))) return false;
|
||||||
if (existsSync(customPath)) return false;
|
|
||||||
}
|
}
|
||||||
const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`);
|
const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`);
|
||||||
return existsSync(builtinPath);
|
return existsSync(builtinPath);
|
||||||
@ -156,7 +165,9 @@ function executeListPieces(ctx: ToolContext): ToolResult {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const pieces: Array<{ name: string; description: string; keywords: string[]; custom: boolean }> = [];
|
const pieces: Array<{ name: string; description: string; keywords: string[]; custom: boolean }> = [];
|
||||||
const dirs: Array<{ dir: string; custom: boolean }> = [];
|
const dirs: Array<{ dir: string; custom: boolean }> = [];
|
||||||
if (ctx.customPiecesDir && existsSync(ctx.customPiecesDir)) dirs.push({ dir: ctx.customPiecesDir, custom: true });
|
for (const d of toCustomDirs(ctx.customPiecesDir)) {
|
||||||
|
if (existsSync(d)) dirs.push({ dir: d, custom: true });
|
||||||
|
}
|
||||||
dirs.push({ dir: BUILTIN_PIECES_DIR, custom: false });
|
dirs.push({ dir: BUILTIN_PIECES_DIR, custom: false });
|
||||||
|
|
||||||
for (const { dir, custom } of dirs) {
|
for (const { dir, custom } of dirs) {
|
||||||
@ -231,8 +242,9 @@ function executeCreatePiece(input: Record<string, unknown>, ctx: ToolContext): T
|
|||||||
return { output: `Piece "${piece.name}" already exists. Use UpdatePiece to modify it.`, isError: true };
|
return { output: `Piece "${piece.name}" already exists. Use UpdatePiece to modify it.`, isError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// カスタムディレクトリがあればそこに、なければ builtin に書き込み
|
// Write to the FIRST custom dir (per-user dir), or fall back to builtin if no custom dirs.
|
||||||
const targetDir = ctx.customPiecesDir ?? BUILTIN_PIECES_DIR;
|
const customDirs = toCustomDirs(ctx.customPiecesDir);
|
||||||
|
const targetDir = customDirs[0] ?? BUILTIN_PIECES_DIR;
|
||||||
mkdirSync(targetDir, { recursive: true });
|
mkdirSync(targetDir, { recursive: true });
|
||||||
const filePath = join(targetDir, `${piece.name}.yaml`);
|
const filePath = join(targetDir, `${piece.name}.yaml`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, mkdtempSync } from
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { buildRetryHandoffSummary, Worker } from './worker.js';
|
import { buildRetryHandoffSummary, Worker } from './worker.js';
|
||||||
|
import { userPiecesDir } from './user-folder/paths.js';
|
||||||
|
import { loadPiece } from './engine/piece-runner.js';
|
||||||
import type { AppConfig } from './config.js';
|
import type { AppConfig } from './config.js';
|
||||||
import type { Job } from './db/repository.js';
|
import type { Job } from './db/repository.js';
|
||||||
|
|
||||||
@ -358,3 +360,53 @@ describe('Worker', () => {
|
|||||||
void worker; // worker インスタンスを参照して lint 警告を回避
|
void worker; // worker インスタンスを参照して lint 警告を回避
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regression: null-ownerId job resolves pieces from data/users/local/pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Worker piece resolution for null-ownerId job', () => {
|
||||||
|
it('a no-auth job (ownerId null) can load a piece from data/users/local/pieces', () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'worker-piece-res-'));
|
||||||
|
const piecesDir = join(tempDir, 'pieces');
|
||||||
|
const userFolderRoot = join(tempDir, 'users');
|
||||||
|
mkdirSync(piecesDir);
|
||||||
|
|
||||||
|
// Write a piece under local user-custom dir (where no-auth POST now writes)
|
||||||
|
const localPiecesDir = userPiecesDir(userFolderRoot, 'local');
|
||||||
|
mkdirSync(localPiecesDir, { recursive: true });
|
||||||
|
writeFileSync(join(localPiecesDir, 'local-piece.yaml'), [
|
||||||
|
'name: local-piece',
|
||||||
|
'description: local custom piece',
|
||||||
|
'max_movements: 1',
|
||||||
|
'initial_movement: only',
|
||||||
|
'movements:',
|
||||||
|
' - name: only',
|
||||||
|
' edit: false',
|
||||||
|
' persona: p',
|
||||||
|
' instruction: i',
|
||||||
|
' allowed_tools: [Read]',
|
||||||
|
' default_next: COMPLETE',
|
||||||
|
' rules: []',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
// Simulate what worker.ts does: ownerForPieces = job.ownerId ?? 'local'
|
||||||
|
const ownerForPieces = (null as string | null) ?? 'local';
|
||||||
|
const customPieceDirs = [userPiecesDir(userFolderRoot, ownerForPieces)].filter(Boolean);
|
||||||
|
|
||||||
|
// loadPiece should find the piece in the local user-custom dir
|
||||||
|
const piece = loadPiece('local-piece', piecesDir, customPieceDirs);
|
||||||
|
expect(piece.name).toBe('local-piece');
|
||||||
|
expect(piece.description).toBe('local custom piece');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a null-ownerId job resolves the local user-custom dir path consistently', () => {
|
||||||
|
const userFolderRoot = '/data/users';
|
||||||
|
// The fixed worker uses: job.ownerId ?? 'local'
|
||||||
|
const ownerForNullJob = (null as string | null) ?? 'local';
|
||||||
|
const resolvedDir = userPiecesDir(userFolderRoot, ownerForNullJob);
|
||||||
|
expect(resolvedDir).toContain('local');
|
||||||
|
// Same dir that no-auth POST writes to
|
||||||
|
expect(resolvedDir).toBe(userPiecesDir(userFolderRoot, 'local'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Repository, Job, localTaskRepoName, type JobRole } from './db/repository.js';
|
import { Repository, Job, localTaskRepoName, type JobRole } from './db/repository.js';
|
||||||
|
import { userPiecesDir } from './user-folder/paths.js';
|
||||||
import { BrowserSessionRepo } from './db/browser-session-repo.js';
|
import { BrowserSessionRepo } from './db/browser-session-repo.js';
|
||||||
import { assertProfileOwner } from './engine/browser-session-auth.js';
|
import { assertProfileOwner } from './engine/browser-session-auth.js';
|
||||||
import { initMasterKey, decryptUserDek, decryptStateBlob } from './crypto/sessions.js';
|
import { initMasterKey, decryptUserDek, decryptStateBlob } from './crypto/sessions.js';
|
||||||
@ -922,9 +923,17 @@ export class Worker {
|
|||||||
// watchdog 誤検知防止: runPiece 実行中に updated_at を定期更新
|
// watchdog 誤検知防止: runPiece 実行中に updated_at を定期更新
|
||||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
try {
|
try {
|
||||||
// Piece 読み込み
|
// Piece 読み込み: per-user カスタムディレクトリ → global カスタムディレクトリ → builtin の順に探索
|
||||||
logger.info(`[worker:${this.workerId}] job ${jobId} loadPiece piece=${job.pieceName} customPiecesDir=${this.config.customPiecesDir ?? 'none'} piecesDir=pieces`);
|
// No-auth jobs (ownerId null) resolve pieces from data/users/local/pieces, matching
|
||||||
const piece = loadPiece(job.pieceName, 'pieces', this.config.customPiecesDir);
|
// where no-auth POST now writes (LOCAL_OWNER='local' in pieces-api.ts).
|
||||||
|
const userFolderRoot = this.config.userFolderRoot ?? './data/users';
|
||||||
|
const ownerForPieces = job.ownerId ?? 'local';
|
||||||
|
const customPieceDirs = [
|
||||||
|
userPiecesDir(userFolderRoot, ownerForPieces),
|
||||||
|
this.config.customPiecesDir,
|
||||||
|
].filter((d): d is string => !!d);
|
||||||
|
logger.info(`[worker:${this.workerId}] job ${jobId} loadPiece piece=${job.pieceName} customDirs=[${customPieceDirs.join(', ') || 'none'}] piecesDir=pieces`);
|
||||||
|
const piece = loadPiece(job.pieceName, 'pieces', customPieceDirs);
|
||||||
if (
|
if (
|
||||||
piece.model &&
|
piece.model &&
|
||||||
this.availableModels.size > 0 &&
|
this.availableModels.size > 0 &&
|
||||||
@ -1283,7 +1292,7 @@ export class Worker {
|
|||||||
abortController: jobAbortController,
|
abortController: jobAbortController,
|
||||||
safetyConfig: this.config.safety,
|
safetyConfig: this.config.safety,
|
||||||
searchFilter: this.config.searchFilter,
|
searchFilter: this.config.searchFilter,
|
||||||
customPiecesDir: this.config.customPiecesDir,
|
customPiecesDir: customPieceDirs,
|
||||||
contextManager,
|
contextManager,
|
||||||
vlmEnabled: workerDef.vlm === true,
|
vlmEnabled: workerDef.vlm === true,
|
||||||
jobId, // Phase 5: subtask handoff parent identity
|
jobId, // Phase 5: subtask handoff parent identity
|
||||||
|
|||||||
@ -399,7 +399,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
|
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
|
||||||
{page === 'pieces' && isAdmin && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} /></div>}
|
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
|
||||||
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
||||||
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
||||||
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
||||||
|
|||||||
@ -358,8 +358,10 @@ export async function reloadConfig(): Promise<void> {
|
|||||||
|
|
||||||
// --- Pieces ---
|
// --- Pieces ---
|
||||||
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
|
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
|
||||||
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; drift?: DriftStatus; requiredMcp?: string[] }
|
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] }
|
||||||
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
|
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
|
||||||
|
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
|
||||||
|
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }
|
||||||
|
|
||||||
export async function fetchPieces(): Promise<PieceSummary[]> {
|
export async function fetchPieces(): Promise<PieceSummary[]> {
|
||||||
const res = await fetch(`${BASE}/pieces`);
|
const res = await fetch(`${BASE}/pieces`);
|
||||||
@ -368,15 +370,17 @@ export async function fetchPieces(): Promise<PieceSummary[]> {
|
|||||||
return data.pieces;
|
return data.pieces;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPiece(name: string): Promise<PieceDef> {
|
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<PieceFetchResult> {
|
||||||
const res = await fetch(`${BASE}/pieces/${name}`);
|
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
|
||||||
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
|
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
|
||||||
return data.piece;
|
return { piece: data.piece, source: data.source, ownerId: data.ownerId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePiece(name: string, piece: PieceDef): Promise<void> {
|
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
|
||||||
const res = await fetch(`${BASE}/pieces/${name}`, {
|
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(piece),
|
body: JSON.stringify(piece),
|
||||||
@ -384,17 +388,22 @@ export async function updatePiece(name: string, piece: PieceDef): Promise<void>
|
|||||||
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
|
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPiece(piece: PieceDef): Promise<void> {
|
export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }
|
||||||
|
|
||||||
|
export async function createPiece(piece: PieceDef): Promise<PieceCreateResult> {
|
||||||
const res = await fetch(`${BASE}/pieces`, {
|
const res = await fetch(`${BASE}/pieces`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(piece),
|
body: JSON.stringify(piece),
|
||||||
});
|
});
|
||||||
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
|
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
|
||||||
|
const d = await res.json();
|
||||||
|
return { source: d.source ?? 'user-custom' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePiece(name: string): Promise<void> {
|
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
|
||||||
const res = await fetch(`${BASE}/pieces/${name}`, { method: 'DELETE' });
|
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
|
||||||
|
const res = await fetch(url, { method: 'DELETE' });
|
||||||
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
|
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfil
|
|||||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||||
import { ScheduleFields } from './ScheduleFields';
|
import { ScheduleFields } from './ScheduleFields';
|
||||||
import { usePieceList } from '../../hooks/usePieces';
|
import { usePieceList } from '../../hooks/usePieces';
|
||||||
|
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||||
import { useAuthState } from '../../App';
|
import { useAuthState } from '../../App';
|
||||||
|
|
||||||
interface CreateTaskDialogProps {
|
interface CreateTaskDialogProps {
|
||||||
@ -80,7 +81,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
scheduledAt: '',
|
scheduledAt: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedPiece = (pieces ?? []).find(p => p.name === form.piece);
|
const resolvedPieces = resolvePieceOptions(pieces ?? []);
|
||||||
|
const selectedPiece = resolvedPieces.find(p => p.name === form.piece);
|
||||||
const missingMcp = selectedPiece?.requiredMcp
|
const missingMcp = selectedPiece?.requiredMcp
|
||||||
? selectedPiece.requiredMcp.filter(
|
? selectedPiece.requiredMcp.filter(
|
||||||
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
|
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
|
||||||
@ -241,7 +243,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
|||||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="auto">自動選択</option>
|
<option value="auto">自動選択</option>
|
||||||
{(pieces ?? []).map(p => (
|
{resolvedPieces.map(p => (
|
||||||
<option key={p.name} value={p.name}>{p.name}</option>
|
<option key={p.name} value={p.name}>{p.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
|
import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
|
||||||
import { usePieceList } from '../../hooks/usePieces';
|
import { usePieceList } from '../../hooks/usePieces';
|
||||||
|
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||||
import { MarkdownText } from '../../lib/markdown-text';
|
import { MarkdownText } from '../../lib/markdown-text';
|
||||||
|
|
||||||
interface PrevJobInfo {
|
interface PrevJobInfo {
|
||||||
@ -123,7 +124,7 @@ export function ContinueWithPieceDialog({
|
|||||||
disabled={piecesQuery.isLoading}
|
disabled={piecesQuery.isLoading}
|
||||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||||
>
|
>
|
||||||
{(piecesQuery.data ?? []).map(p => (
|
{resolvePieceOptions(piecesQuery.data ?? []).map(p => (
|
||||||
<option key={p.name} value={p.name}>
|
<option key={p.name} value={p.name}>
|
||||||
{p.name}
|
{p.name}
|
||||||
{p.name === prevJob.pieceName ? ' (現在)' : ''}
|
{p.name === prevJob.pieceName ? ' (現在)' : ''}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ interface TopBarProps {
|
|||||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||||
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
|
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
|
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
|
{ id: 'pieces', label: 'Pieces', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
|
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
|
||||||
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
|
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
|
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
|
||||||
|
|||||||
@ -7,9 +7,10 @@ export interface MovementAccordionProps {
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
onMove: (index: number, direction: 'up' | 'down') => void;
|
onMove: (index: number, direction: 'up' | 'down') => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove }: MovementAccordionProps) {
|
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove, disabled = false }: MovementAccordionProps) {
|
||||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||||
const movementNames = movements.map((m) => m.name ?? '');
|
const movementNames = movements.map((m) => m.name ?? '');
|
||||||
|
|
||||||
@ -49,7 +50,8 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
|||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls — hidden in read-only mode */}
|
||||||
|
{!disabled && (
|
||||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -83,6 +85,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded form */}
|
{/* Expanded form */}
|
||||||
@ -92,6 +95,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
|||||||
movement={movement}
|
movement={movement}
|
||||||
movementNames={movementNames}
|
movementNames={movementNames}
|
||||||
onChange={(field, value) => onChange(i, field, value)}
|
onChange={(field, value) => onChange(i, field, value)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -100,6 +104,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
@ -107,6 +112,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
|||||||
>
|
>
|
||||||
+ Add Movement
|
+ Add Movement
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,10 +8,12 @@ export interface MovementFormProps {
|
|||||||
movement: any;
|
movement: any;
|
||||||
movementNames: string[];
|
movementNames: string[];
|
||||||
onChange: (field: string, value: any) => void;
|
onChange: (field: string, value: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MovementForm({ movement, movementNames, onChange }: MovementFormProps) {
|
export function MovementForm({ movement, movementNames, onChange, disabled = false }: MovementFormProps) {
|
||||||
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
|
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
|
||||||
|
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -22,7 +24,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
type="text"
|
type="text"
|
||||||
value={movement.name ?? ''}
|
value={movement.name ?? ''}
|
||||||
onChange={(e) => onChange('name', e.target.value)}
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -33,7 +36,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
type="text"
|
type="text"
|
||||||
value={movement.persona ?? ''}
|
value={movement.persona ?? ''}
|
||||||
onChange={(e) => onChange('persona', e.target.value)}
|
onChange={(e) => onChange('persona', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -43,7 +47,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
<select
|
<select
|
||||||
value={movement.default_next ?? 'COMPLETE'}
|
value={movement.default_next ?? 'COMPLETE'}
|
||||||
onChange={(e) => onChange('default_next', e.target.value)}
|
onChange={(e) => onChange('default_next', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
|
||||||
>
|
>
|
||||||
{nextOptions.map((opt) => (
|
{nextOptions.map((opt) => (
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
@ -58,7 +63,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
id={`edit-${movement.name}`}
|
id={`edit-${movement.name}`}
|
||||||
checked={movement.edit ?? false}
|
checked={movement.edit ?? false}
|
||||||
onChange={(e) => onChange('edit', e.target.checked)}
|
onChange={(e) => onChange('edit', e.target.checked)}
|
||||||
className="rounded border-slate-300"
|
disabled={disabled}
|
||||||
|
className="rounded border-slate-300 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
|
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
|
||||||
<HelpText>有効にすると Write / Edit ツールが LLM に提示されます</HelpText>
|
<HelpText>有効にすると Write / Edit ツールが LLM に提示されます</HelpText>
|
||||||
@ -71,7 +77,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
value={movement.instruction ?? ''}
|
value={movement.instruction ?? ''}
|
||||||
onChange={(e) => onChange('instruction', e.target.value)}
|
onChange={(e) => onChange('instruction', e.target.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono ${disabledClass}`}
|
||||||
/>
|
/>
|
||||||
<HelpText>LLM に渡される指示文。Markdown 記法が使えます</HelpText>
|
<HelpText>LLM に渡される指示文。Markdown 記法が使えます</HelpText>
|
||||||
</div>
|
</div>
|
||||||
@ -80,6 +87,7 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
<ToolTagInput
|
<ToolTagInput
|
||||||
value={movement.allowed_tools ?? []}
|
value={movement.allowed_tools ?? []}
|
||||||
onChange={(tools) => onChange('allowed_tools', tools)}
|
onChange={(tools) => onChange('allowed_tools', tools)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* rules */}
|
{/* rules */}
|
||||||
@ -87,6 +95,7 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
|||||||
rules={movement.rules ?? []}
|
rules={movement.rules ?? []}
|
||||||
movementNames={movementNames.filter((n) => n !== movement.name)}
|
movementNames={movementNames.filter((n) => n !== movement.name)}
|
||||||
onChange={(rules) => onChange('rules', rules)}
|
onChange={(rules) => onChange('rules', rules)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,10 +9,22 @@ import { MovementAccordion } from './MovementAccordion';
|
|||||||
|
|
||||||
export interface PieceEditorProps {
|
export interface PieceEditorProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Whether the current user has admin role. Controls edit access for built-in/global-custom pieces. */
|
||||||
|
isAdmin?: boolean;
|
||||||
|
/**
|
||||||
|
* Source hint from URL / list selection — used for the targeted GET request.
|
||||||
|
* The read-only gate uses the SOURCE the API actually resolved (authoritative),
|
||||||
|
* so a deep link without pieceSource still renders correctly for non-admins.
|
||||||
|
*/
|
||||||
|
source?: 'builtin' | 'user-custom' | 'global-custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PieceEditor({ name }: PieceEditorProps) {
|
export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps) {
|
||||||
const { data: piece, isLoading, error } = usePiece(name);
|
const { data: fetchResult, isLoading, error } = usePiece(name, source);
|
||||||
|
// Use the server-resolved source as the authoritative value; fall back to the
|
||||||
|
// prop only while the fetch hasn't completed yet (avoids flicker on known paths).
|
||||||
|
const piece = fetchResult?.piece ?? null;
|
||||||
|
const effectiveSource = fetchResult?.source ?? source;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setUrlState } = useUrlState();
|
const { setUrlState } = useUrlState();
|
||||||
|
|
||||||
@ -33,7 +45,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
setEditMode('visual');
|
setEditMode('visual');
|
||||||
setYamlError(null);
|
setYamlError(null);
|
||||||
}
|
}
|
||||||
}, [piece]);
|
}, [piece]); // piece is derived from fetchResult above
|
||||||
|
|
||||||
const showToast = (msg: string, duration = 2000) => {
|
const showToast = (msg: string, duration = 2000) => {
|
||||||
setToast(msg);
|
setToast(msg);
|
||||||
@ -159,7 +171,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updatePiece(name, saveData);
|
await updatePiece(name, saveData, effectiveSource);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
|
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
@ -177,7 +189,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
|
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
|
||||||
try {
|
try {
|
||||||
await deletePiece(name);
|
await deletePiece(name, effectiveSource);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||||
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
|
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -189,6 +201,12 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
if (error) return <div className="text-sm text-red-500">Piece の読み込みに失敗しました</div>;
|
if (error) return <div className="text-sm text-red-500">Piece の読み込みに失敗しました</div>;
|
||||||
if (!draft) return null;
|
if (!draft) return null;
|
||||||
|
|
||||||
|
// Non-admins cannot edit built-in or global-custom pieces — read-only view.
|
||||||
|
// user-custom stays editable for its owner. Admins can edit all sources.
|
||||||
|
// Use the SERVER-RESOLVED source (effectiveSource) as the authoritative value so
|
||||||
|
// that deep links without a ?pieceSource param still render read-only for non-admins.
|
||||||
|
const readonly = !isAdmin && (effectiveSource === 'builtin' || effectiveSource === 'global-custom');
|
||||||
|
|
||||||
const movementNames = (draft.movements ?? []).map((m: any) => m.name ?? '');
|
const movementNames = (draft.movements ?? []).map((m: any) => m.name ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -201,6 +219,8 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2">{String(draft.description).split('\n')[0]}</p>
|
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2">{String(draft.description).split('\n')[0]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Delete is hidden for ALL built-in pieces (non-deletable by everyone, including admins). */}
|
||||||
|
{!readonly && effectiveSource !== 'builtin' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@ -208,6 +228,12 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{readonly && (
|
||||||
|
<span className="px-2 py-1 text-xs text-slate-400 bg-slate-100 rounded border border-slate-200">
|
||||||
|
読み取り専用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
@ -242,6 +268,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
piece={draft}
|
piece={draft}
|
||||||
onChange={handleMetaChange}
|
onChange={handleMetaChange}
|
||||||
movementNames={movementNames}
|
movementNames={movementNames}
|
||||||
|
disabled={readonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -253,6 +280,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
onAdd={handleAddMovement}
|
onAdd={handleAddMovement}
|
||||||
onRemove={handleRemoveMovement}
|
onRemove={handleRemoveMovement}
|
||||||
onMove={handleMoveMovement}
|
onMove={handleMoveMovement}
|
||||||
|
disabled={readonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -268,7 +296,9 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
value={yamlText}
|
value={yamlText}
|
||||||
onChange={(e) => handleYamlChange(e.target.value)}
|
onChange={(e) => handleYamlChange(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono bg-slate-50 leading-relaxed resize-y"
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
className={`w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono leading-relaxed resize-y ${readonly ? 'bg-slate-100 text-slate-500 cursor-not-allowed' : 'bg-slate-50'}`}
|
||||||
style={{ minHeight: '500px', tabSize: 2 }}
|
style={{ minHeight: '500px', tabSize: 2 }}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
@ -278,6 +308,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
{!readonly && (
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
|
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
|
||||||
{toast && (
|
{toast && (
|
||||||
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
|
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
|
||||||
@ -299,6 +330,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
|||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,12 @@ export interface PieceMetaFormProps {
|
|||||||
piece: any;
|
piece: any;
|
||||||
onChange: (field: string, value: any) => void;
|
onChange: (field: string, value: any) => void;
|
||||||
movementNames: string[];
|
movementNames: string[];
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormProps) {
|
export function PieceMetaForm({ piece, onChange, movementNames, disabled = false }: PieceMetaFormProps) {
|
||||||
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
|
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
|
||||||
|
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -30,7 +32,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
|||||||
type="text"
|
type="text"
|
||||||
value={piece.description ?? ''}
|
value={piece.description ?? ''}
|
||||||
onChange={(e) => onChange('description', e.target.value)}
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -42,7 +45,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
|||||||
value={piece.max_movements ?? 10}
|
value={piece.max_movements ?? 10}
|
||||||
onChange={(e) => onChange('max_movements', parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => onChange('max_movements', parseInt(e.target.value, 10) || 0)}
|
||||||
min={1}
|
min={1}
|
||||||
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
disabled={disabled}
|
||||||
|
className={`w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||||
/>
|
/>
|
||||||
<HelpText>1 ジョブで実行できる movement の最大回数。ループ防止のため</HelpText>
|
<HelpText>1 ジョブで実行できる movement の最大回数。ループ防止のため</HelpText>
|
||||||
</div>
|
</div>
|
||||||
@ -53,7 +57,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
|||||||
<select
|
<select
|
||||||
value={piece.initial_movement ?? ''}
|
value={piece.initial_movement ?? ''}
|
||||||
onChange={(e) => onChange('initial_movement', e.target.value)}
|
onChange={(e) => onChange('initial_movement', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
|
||||||
>
|
>
|
||||||
{movementNames.length === 0 && <option value="">--</option>}
|
{movementNames.length === 0 && <option value="">--</option>}
|
||||||
{movementNames.map((name) => (
|
{movementNames.map((name) => (
|
||||||
@ -77,7 +82,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
|||||||
onChange('triggers', { ...piece.triggers, keywords });
|
onChange('triggers', { ...piece.triggers, keywords });
|
||||||
}}
|
}}
|
||||||
placeholder="keyword1, keyword2, ..."
|
placeholder="keyword1, keyword2, ..."
|
||||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||||
/>
|
/>
|
||||||
<HelpText>タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます</HelpText>
|
<HelpText>タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます</HelpText>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,10 +6,12 @@ export interface RulesTableProps {
|
|||||||
rules: Array<{ condition: string; next: string }>;
|
rules: Array<{ condition: string; next: string }>;
|
||||||
movementNames: string[];
|
movementNames: string[];
|
||||||
onChange: (rules: Array<{ condition: string; next: string }>) => void;
|
onChange: (rules: Array<{ condition: string; next: string }>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RulesTable({ rules, movementNames, onChange }: RulesTableProps) {
|
export function RulesTable({ rules, movementNames, onChange, disabled = false }: RulesTableProps) {
|
||||||
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
|
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
|
||||||
|
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||||
|
|
||||||
const updateRule = (index: number, field: 'condition' | 'next', value: string) => {
|
const updateRule = (index: number, field: 'condition' | 'next', value: string) => {
|
||||||
const updated = rules.map((r, i) => (i === index ? { ...r, [field]: value } : r));
|
const updated = rules.map((r, i) => (i === index ? { ...r, [field]: value } : r));
|
||||||
@ -33,7 +35,7 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
|||||||
<tr className="text-xs text-slate-500">
|
<tr className="text-xs text-slate-500">
|
||||||
<th className="text-left font-medium pb-1 pr-2">condition</th>
|
<th className="text-left font-medium pb-1 pr-2">condition</th>
|
||||||
<th className="text-left font-medium pb-1 pr-2 w-44">next</th>
|
<th className="text-left font-medium pb-1 pr-2 w-44">next</th>
|
||||||
<th className="w-8" />
|
{!disabled && <th className="w-8" />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -44,7 +46,8 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
|||||||
type="text"
|
type="text"
|
||||||
value={rule.condition}
|
value={rule.condition}
|
||||||
onChange={(e) => updateRule(i, 'condition', e.target.value)}
|
onChange={(e) => updateRule(i, 'condition', e.target.value)}
|
||||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||||
placeholder="条件..."
|
placeholder="条件..."
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@ -52,13 +55,15 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
|||||||
<select
|
<select
|
||||||
value={rule.next}
|
value={rule.next}
|
||||||
onChange={(e) => updateRule(i, 'next', e.target.value)}
|
onChange={(e) => updateRule(i, 'next', e.target.value)}
|
||||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
|
||||||
>
|
>
|
||||||
{nextOptions.map((opt) => (
|
{nextOptions.map((opt) => (
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
{!disabled && (
|
||||||
<td className="pb-1">
|
<td className="pb-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -68,11 +73,13 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addRule}
|
onClick={addRule}
|
||||||
@ -80,6 +87,7 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
|||||||
>
|
>
|
||||||
+ Add Rule
|
+ Add Rule
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<HelpText>LLM が transition ツールで遷移先を選ぶ際の条件です</HelpText>
|
<HelpText>LLM が transition ツールで遷移先を選ぶ際の条件です</HelpText>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { HelpText } from './HelpText';
|
|||||||
export interface ToolTagInputProps {
|
export interface ToolTagInputProps {
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (tools: string[]) => void;
|
onChange: (tools: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,7 +25,7 @@ export interface ToolTagInputProps {
|
|||||||
* - Selecting an unavailable catalog tool is still allowed (the user might be
|
* - Selecting an unavailable catalog tool is still allowed (the user might be
|
||||||
* preparing for a server that's about to come back online).
|
* preparing for a server that's about to come back online).
|
||||||
*/
|
*/
|
||||||
export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
|
export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInputProps) {
|
||||||
const { data: catalog } = useToolList();
|
const { data: catalog } = useToolList();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
@ -127,9 +128,11 @@ export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
|
|||||||
name={tool}
|
name={tool}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onRemove={() => removeTool(tool)}
|
onRemove={() => removeTool(tool)}
|
||||||
|
readOnly={disabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{!disabled && (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@ -143,8 +146,9 @@ export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
|
|||||||
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
|
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
|
||||||
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showDropdown && groupedSuggestions.length > 0 && (
|
{!disabled && showDropdown && groupedSuggestions.length > 0 && (
|
||||||
<div className="absolute z-10 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-slate-200 rounded-lg shadow-lg">
|
<div className="absolute z-10 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-slate-200 rounded-lg shadow-lg">
|
||||||
{groupedSuggestions.map((g) => (
|
{groupedSuggestions.map((g) => (
|
||||||
<div key={g.key}>
|
<div key={g.key}>
|
||||||
@ -202,10 +206,12 @@ function SelectedToolChip({
|
|||||||
name,
|
name,
|
||||||
entry,
|
entry,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
entry: ToolCatalogEntry | undefined;
|
entry: ToolCatalogEntry | undefined;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isUnknown = !entry;
|
const isUnknown = !entry;
|
||||||
const isUnavailable = entry ? !entry.available : false;
|
const isUnavailable = entry ? !entry.available : false;
|
||||||
@ -231,6 +237,7 @@ function SelectedToolChip({
|
|||||||
{entry && <ScopeBadge scope={entry.scope} dim />}
|
{entry && <ScopeBadge scope={entry.scope} dim />}
|
||||||
{isUnknown && <Badge color="amber">unknown</Badge>}
|
{isUnknown && <Badge color="amber">unknown</Badge>}
|
||||||
{!isUnknown && isUnavailable && <Badge color="amber">{entry?.reason ?? 'offline'}</Badge>}
|
{!isUnknown && isUnavailable && <Badge color="amber">{entry?.reason ?? 'offline'}</Badge>}
|
||||||
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
@ -239,6 +246,7 @@ function SelectedToolChip({
|
|||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,19 +49,37 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
|
|||||||
|
|
||||||
> このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。
|
> このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。
|
||||||
|
|
||||||
## カスタム Piece を作る(admin / パワーユーザー)
|
## Default Pieces と Custom Pieces
|
||||||
|
|
||||||
|
Pieces ページでは「**Default Pieces**」と「**Custom Pieces**」の 2 つのセクションが表示されます。
|
||||||
|
|
||||||
|
**Default Pieces(組み込み)**
|
||||||
|
|
||||||
|
- システムに同梱され、git で管理されている Piece です
|
||||||
|
- **削除は非 admin ユーザーには禁止**されています(削除は admin のみ)
|
||||||
|
- **編集は admin のみ** 可能です(非 admin ユーザーは読み取り専用)
|
||||||
|
- 非 admin ユーザーは `⎘` ボタンで **「Custom に複製」** でき、別名の Custom Piece として自分用にカスタマイズできます
|
||||||
|
|
||||||
|
**Custom Pieces(ユーザー作成)**
|
||||||
|
|
||||||
|
- ユーザーが作成した Piece で、自分のユーザーフォルダ配下に保存されます
|
||||||
|
- 自分で作った Custom Piece は編集・削除できます
|
||||||
|
- Custom Piece は **Default Piece と同名にできません**(別名を付けてください)
|
||||||
|
- タスク実行時、Custom Piece はそのオーナーのユーザーフォルダから読み込まれ、正しく実行されます
|
||||||
|
|
||||||
|
## カスタム Piece を作る
|
||||||
|
|
||||||
### 方法 1: piece-builder に依頼する
|
### 方法 1: piece-builder に依頼する
|
||||||
|
|
||||||
タスクを作成し、「○○用の Piece を作って」と書きます。`piece-builder` が選ばれ、要件をヒアリングしながら movement 構成・ツール選定・遷移ルールを設計して Piece を保存します。既存 Piece の改良で済む場合はそれを優先します。
|
タスクを作成し、「○○用の Piece を作って」と書きます。`piece-builder` が選ばれ、要件をヒアリングしながら movement 構成・ツール選定・遷移ルールを設計して Piece を保存します。既存 Piece の改良で済む場合はそれを優先します。
|
||||||
|
|
||||||
### 方法 2: Pieces ページで手動編集する
|
### 方法 2: Pieces ページで手動作成する
|
||||||
|
|
||||||
admin は TopBar → Pieces から Piece の一覧・閲覧・新規作成・編集ができます。YAML を直接編集して保存すると `pieces/{name}.yaml` に反映されます。
|
TopBar → Pieces → Custom Pieces セクションの `+` から新規作成できます。YAML を直接編集して保存できます。**Default Piece と同名の名前は使えません**(別名を付けてください)。
|
||||||
|
|
||||||
### 方法 3: 自分専用に fork する
|
### 方法 3: Default Piece を複製してカスタマイズする
|
||||||
|
|
||||||
組み込み Piece を少しだけ変えたい場合、ユーザーフォルダ配下に同名の Piece を置くと、自分のタスクではそちらが優先されます。組み込み定義はそのまま残ります。
|
Default Piece の行にある `⎘` ボタンをクリックすると「複製名」の入力ダイアログが開きます。別名を入力して複製すると、Custom Pieces セクションに追加されます。元の Default Piece はそのまま残ります。複製後の Custom Piece は自分で自由に編集できます。
|
||||||
|
|
||||||
## Piece を編集するときの勘所
|
## Piece を編集するときの勘所
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,15 @@ export function usePieceList() {
|
|||||||
return useQuery({ queryKey: ['pieces'], queryFn: fetchPieces, staleTime: STALE_TIME.SEMI_STATIC });
|
return useQuery({ queryKey: ['pieces'], queryFn: fetchPieces, staleTime: STALE_TIME.SEMI_STATIC });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePiece(name: string | undefined) {
|
/**
|
||||||
|
* Fetches a single piece by name (and optional source).
|
||||||
|
* Returns the full PieceFetchResult so callers can use the server-resolved source
|
||||||
|
* for authorization decisions (e.g. read-only gate in PieceEditor).
|
||||||
|
*/
|
||||||
|
export function usePiece(name: string | undefined, source?: 'builtin' | 'user-custom' | 'global-custom') {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['piece', name],
|
queryKey: ['piece', name, source],
|
||||||
queryFn: () => fetchPiece(name!),
|
queryFn: () => fetchPiece(name!, source),
|
||||||
enabled: !!name,
|
enabled: !!name,
|
||||||
staleTime: STALE_TIME.SEMI_STATIC,
|
staleTime: STALE_TIME.SEMI_STATIC,
|
||||||
});
|
});
|
||||||
|
|||||||
125
ui/src/lib/splitPieces.test.ts
Normal file
125
ui/src/lib/splitPieces.test.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { splitPieces, resolvePieceOptions } from './splitPieces';
|
||||||
|
import type { PieceSummary } from '../api';
|
||||||
|
|
||||||
|
function makePiece(name: string, source?: PieceSummary['source']): PieceSummary {
|
||||||
|
return { name, description: 'test', source };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('splitPieces', () => {
|
||||||
|
it('puts builtin pieces in defaults', () => {
|
||||||
|
const pieces = [makePiece('chat', 'builtin'), makePiece('general', 'builtin')];
|
||||||
|
const { defaults, customs } = splitPieces(pieces);
|
||||||
|
expect(defaults).toHaveLength(2);
|
||||||
|
expect(customs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts user-custom pieces in customs', () => {
|
||||||
|
const pieces = [makePiece('my-piece', 'user-custom')];
|
||||||
|
const { defaults, customs } = splitPieces(pieces);
|
||||||
|
expect(defaults).toHaveLength(0);
|
||||||
|
expect(customs).toHaveLength(1);
|
||||||
|
expect(customs[0].name).toBe('my-piece');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts global-custom pieces in customs', () => {
|
||||||
|
const pieces = [makePiece('global-piece', 'global-custom')];
|
||||||
|
const { defaults, customs } = splitPieces(pieces);
|
||||||
|
expect(defaults).toHaveLength(0);
|
||||||
|
expect(customs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits mixed list correctly', () => {
|
||||||
|
const pieces = [
|
||||||
|
makePiece('chat', 'builtin'),
|
||||||
|
makePiece('general', 'builtin'),
|
||||||
|
makePiece('my-chat', 'user-custom'),
|
||||||
|
makePiece('chat', 'user-custom'), // same name as builtin, but still custom
|
||||||
|
];
|
||||||
|
const { defaults, customs } = splitPieces(pieces);
|
||||||
|
expect(defaults).toHaveLength(2);
|
||||||
|
expect(customs).toHaveLength(2);
|
||||||
|
expect(defaults.map(p => p.name)).toEqual(['chat', 'general']);
|
||||||
|
expect(customs.map(p => p.name)).toEqual(['my-chat', 'chat']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles pieces with undefined source as custom', () => {
|
||||||
|
const pieces = [makePiece('legacy')]; // no source
|
||||||
|
const { defaults, customs } = splitPieces(pieces);
|
||||||
|
expect(defaults).toHaveLength(0);
|
||||||
|
expect(customs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty arrays for empty input', () => {
|
||||||
|
const { defaults, customs } = splitPieces([]);
|
||||||
|
expect(defaults).toHaveLength(0);
|
||||||
|
expect(customs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePieceOptions', () => {
|
||||||
|
it('returns unique names with no duplicates when all sources differ', () => {
|
||||||
|
const pieces = [makePiece('chat', 'builtin'), makePiece('my-tool', 'user-custom')];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map(p => p.name).sort()).toEqual(['chat', 'my-tool']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user-custom wins over builtin for the same name', () => {
|
||||||
|
const pieces = [makePiece('chat', 'builtin'), makePiece('chat', 'user-custom')];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].source).toBe('user-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user-custom wins over global-custom for the same name', () => {
|
||||||
|
const pieces = [makePiece('chat', 'global-custom'), makePiece('chat', 'user-custom')];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].source).toBe('user-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global-custom wins over builtin for the same name', () => {
|
||||||
|
const pieces = [makePiece('chat', 'builtin'), makePiece('chat', 'global-custom')];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].source).toBe('global-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user-custom wins over both global-custom and builtin', () => {
|
||||||
|
const pieces = [
|
||||||
|
makePiece('chat', 'builtin'),
|
||||||
|
makePiece('chat', 'global-custom'),
|
||||||
|
makePiece('chat', 'user-custom'),
|
||||||
|
];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].source).toBe('user-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates only same-name entries, keeps different names', () => {
|
||||||
|
const pieces = [
|
||||||
|
makePiece('chat', 'builtin'),
|
||||||
|
makePiece('general', 'builtin'),
|
||||||
|
makePiece('chat', 'user-custom'),
|
||||||
|
makePiece('my-tool', 'user-custom'),
|
||||||
|
];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
const byName = Object.fromEntries(result.map(p => [p.name, p]));
|
||||||
|
expect(byName['chat'].source).toBe('user-custom');
|
||||||
|
expect(byName['general'].source).toBe('builtin');
|
||||||
|
expect(byName['my-tool'].source).toBe('user-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(resolvePieceOptions([])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undefined source is treated as builtin priority (lowest)', () => {
|
||||||
|
const pieces = [makePiece('chat', 'global-custom'), makePiece('chat', undefined)];
|
||||||
|
const result = resolvePieceOptions(pieces);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].source).toBe('global-custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
57
ui/src/lib/splitPieces.ts
Normal file
57
ui/src/lib/splitPieces.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import type { PieceSummary } from '../api';
|
||||||
|
|
||||||
|
export interface SplitPieces {
|
||||||
|
defaults: PieceSummary[];
|
||||||
|
customs: PieceSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a flat list of PieceSummary into Default (built-in) and Custom sections.
|
||||||
|
* Pieces with source === 'builtin' go to defaults; all others go to customs.
|
||||||
|
*/
|
||||||
|
export function splitPieces(pieces: PieceSummary[]): SplitPieces {
|
||||||
|
const defaults: PieceSummary[] = [];
|
||||||
|
const customs: PieceSummary[] = [];
|
||||||
|
for (const p of pieces) {
|
||||||
|
if (p.source === 'builtin') {
|
||||||
|
defaults.push(p);
|
||||||
|
} else {
|
||||||
|
customs.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { defaults, customs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-duplicate a flat list of PieceSummary by name, keeping the highest-priority
|
||||||
|
* source for each name — mirroring the executor's resolution order:
|
||||||
|
* user-custom > global-custom > builtin
|
||||||
|
*
|
||||||
|
* Use this wherever a piece is SELECTED TO RUN (task creation, scheduled tasks,
|
||||||
|
* continue-with-piece dialogs). The result matches what the executor will actually
|
||||||
|
* run, so the user sees exactly one "chat" entry rather than two.
|
||||||
|
*
|
||||||
|
* Do NOT use this in the management/PiecesPage view — that view intentionally
|
||||||
|
* shows both the builtin and any same-named custom side-by-side.
|
||||||
|
*/
|
||||||
|
export function resolvePieceOptions(pieces: PieceSummary[]): PieceSummary[] {
|
||||||
|
const PRIORITY: Record<string, number> = {
|
||||||
|
'user-custom': 0,
|
||||||
|
'global-custom': 1,
|
||||||
|
builtin: 2,
|
||||||
|
};
|
||||||
|
const seen = new Map<string, PieceSummary>();
|
||||||
|
for (const p of pieces) {
|
||||||
|
const existing = seen.get(p.name);
|
||||||
|
const pPrio = PRIORITY[p.source ?? 'builtin'] ?? 2;
|
||||||
|
if (!existing) {
|
||||||
|
seen.set(p.name, p);
|
||||||
|
} else {
|
||||||
|
const existingPrio = PRIORITY[existing.source ?? 'builtin'] ?? 2;
|
||||||
|
if (pPrio < existingPrio) {
|
||||||
|
seen.set(p.name, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen.values());
|
||||||
|
}
|
||||||
@ -78,6 +78,8 @@ export interface UiUrlState {
|
|||||||
taskId: number | null;
|
taskId: number | null;
|
||||||
section?: SettingsSection;
|
section?: SettingsSection;
|
||||||
piece?: string;
|
piece?: string;
|
||||||
|
/** Source of the currently-selected piece ('builtin' | 'user-custom' | 'global-custom'). */
|
||||||
|
pieceSource?: 'builtin' | 'user-custom' | 'global-custom';
|
||||||
/** Active SideInfoPanel widget slug. Default: 'worker-status'. */
|
/** Active SideInfoPanel widget slug. Default: 'worker-status'. */
|
||||||
dashboardWidget?: string;
|
dashboardWidget?: string;
|
||||||
/** Selected help section id. Only meaningful when page === 'help'. */
|
/** Selected help section id. Only meaningful when page === 'help'. */
|
||||||
@ -107,6 +109,11 @@ export function readUiUrlState(): UiUrlState {
|
|||||||
const taskId = Number(params.get('task') ?? '');
|
const taskId = Number(params.get('task') ?? '');
|
||||||
const section = params.get('section');
|
const section = params.get('section');
|
||||||
const piece = params.get('piece');
|
const piece = params.get('piece');
|
||||||
|
const pieceSourceRaw = params.get('pieceSource');
|
||||||
|
const PIECE_SOURCES = ['builtin', 'user-custom', 'global-custom'] as const;
|
||||||
|
const pieceSource = PIECE_SOURCES.includes(pieceSourceRaw as any)
|
||||||
|
? (pieceSourceRaw as UiUrlState['pieceSource'])
|
||||||
|
: undefined;
|
||||||
const dashboardWidget = params.get('dashboardWidget');
|
const dashboardWidget = params.get('dashboardWidget');
|
||||||
const help = params.get('help');
|
const help = params.get('help');
|
||||||
|
|
||||||
@ -127,6 +134,7 @@ export function readUiUrlState(): UiUrlState {
|
|||||||
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
||||||
section: section && SETTINGS_SECTIONS.includes(section as SettingsSection) ? section as SettingsSection : undefined,
|
section: section && SETTINGS_SECTIONS.includes(section as SettingsSection) ? section as SettingsSection : undefined,
|
||||||
piece: piece || undefined,
|
piece: piece || undefined,
|
||||||
|
...(pieceSource ? { pieceSource } : {}),
|
||||||
...(dashboardWidget ? { dashboardWidget } : {}),
|
...(dashboardWidget ? { dashboardWidget } : {}),
|
||||||
...(help ? { help } : {}),
|
...(help ? { help } : {}),
|
||||||
};
|
};
|
||||||
@ -144,6 +152,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
|
|||||||
if (state.taskId) params.set('task', String(state.taskId));
|
if (state.taskId) params.set('task', String(state.taskId));
|
||||||
if (state.section) params.set('section', state.section);
|
if (state.section) params.set('section', state.section);
|
||||||
if (state.piece) params.set('piece', state.piece);
|
if (state.piece) params.set('piece', state.piece);
|
||||||
|
if (state.pieceSource) params.set('pieceSource', state.pieceSource);
|
||||||
if (state.help) params.set('help', state.help);
|
if (state.help) params.set('help', state.help);
|
||||||
if (state.dashboardWidget && state.dashboardWidget !== 'worker-status') {
|
if (state.dashboardWidget && state.dashboardWidget !== 'worker-status') {
|
||||||
params.set('dashboardWidget', state.dashboardWidget);
|
params.set('dashboardWidget', state.dashboardWidget);
|
||||||
|
|||||||
@ -2,8 +2,15 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
import { usePieceList } from '../hooks/usePieces';
|
import { usePieceList } from '../hooks/usePieces';
|
||||||
import { createPiece, fetchPiece, PieceDef, DriftStatus } from '../api';
|
import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api';
|
||||||
import { PieceEditor } from '../components/settings/PieceEditor';
|
import { PieceEditor } from '../components/settings/PieceEditor';
|
||||||
|
import { splitPieces } from '../lib/splitPieces';
|
||||||
|
|
||||||
|
type PieceSource = 'builtin' | 'user-custom' | 'global-custom';
|
||||||
|
|
||||||
|
/** Composite selection key so same-named builtin and custom rows are independently selectable. */
|
||||||
|
type SelectionKey = string; // `${name}::${source}`
|
||||||
|
function makeKey(name: string, source: PieceSource): SelectionKey { return `${name}::${source}`; }
|
||||||
|
|
||||||
function shortSha(sha: string | null): string {
|
function shortSha(sha: string | null): string {
|
||||||
return sha ? sha.slice(0, 7) : '???????';
|
return sha ? sha.slice(0, 7) : '???????';
|
||||||
@ -64,13 +71,57 @@ function DriftBadge({ drift }: { drift: DriftStatus }) {
|
|||||||
|
|
||||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||||
|
|
||||||
|
interface PieceRowProps {
|
||||||
|
p: PieceSummary;
|
||||||
|
activeKey?: SelectionKey;
|
||||||
|
isBuiltin: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onSelectPiece: (name: string, source: PieceSource) => void;
|
||||||
|
startDuplicate: (name: string, source: PieceSource) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PieceRow({ p, activeKey, isBuiltin, isAdmin, onSelectPiece, startDuplicate }: PieceRowProps) {
|
||||||
|
// For Default pieces:
|
||||||
|
// - admins: show Duplicate (hover)
|
||||||
|
// - non-admins: show Duplicate always (it's their only action)
|
||||||
|
// For Custom pieces: existing behavior (Duplicate on hover)
|
||||||
|
const duplicateAlwaysVisible = isBuiltin && !isAdmin;
|
||||||
|
const src = (p.source ?? (isBuiltin ? 'builtin' : 'user-custom')) as PieceSource;
|
||||||
|
const thisKey = makeKey(p.name, src);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={thisKey} className="group flex items-center mb-0.5 gap-1 pr-1">
|
||||||
|
<button onClick={() => onSelectPiece(p.name, src)}
|
||||||
|
className={`flex-1 text-left px-2 py-1 rounded text-xs transition-colors min-w-0 truncate ${
|
||||||
|
activeKey === thisKey
|
||||||
|
? 'bg-accent-soft text-accent font-semibold'
|
||||||
|
: 'text-slate-700 hover:bg-surface'
|
||||||
|
}`}>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
{p.drift?.drifted && <DriftBadge drift={p.drift} />}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); startDuplicate(p.name, src); }}
|
||||||
|
className={`text-slate-400 hover:text-slate-700 text-xs px-1.5 transition-opacity flex-shrink-0 ${
|
||||||
|
duplicateAlwaysVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
title="複製"
|
||||||
|
>
|
||||||
|
⎘
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PiecesSidebar({
|
function PiecesSidebar({
|
||||||
activePiece,
|
activeKey,
|
||||||
onSelectPiece,
|
onSelectPiece,
|
||||||
|
isAdmin,
|
||||||
showToast,
|
showToast,
|
||||||
}: {
|
}: {
|
||||||
activePiece?: string;
|
activeKey?: SelectionKey;
|
||||||
onSelectPiece: (name: string) => void;
|
onSelectPiece: (name: string, source: PieceSource) => void;
|
||||||
|
isAdmin: boolean;
|
||||||
showToast?: ShowToast;
|
showToast?: ShowToast;
|
||||||
}) {
|
}) {
|
||||||
const { data: pieces } = usePieceList();
|
const { data: pieces } = usePieceList();
|
||||||
@ -79,8 +130,9 @@ function PiecesSidebar({
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
// Inline duplicate dialog state — replaces window.prompt
|
// Inline duplicate dialog state — replaces window.prompt.
|
||||||
const [duplicateSource, setDuplicateSource] = useState<string | null>(null);
|
// Track both the name and source of the piece being duplicated.
|
||||||
|
const [duplicateTarget, setDuplicateTarget] = useState<{ name: string; source: PieceSource } | null>(null);
|
||||||
const [duplicateName, setDuplicateName] = useState('');
|
const [duplicateName, setDuplicateName] = useState('');
|
||||||
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
||||||
const [duplicating, setDuplicating] = useState(false);
|
const [duplicating, setDuplicating] = useState(false);
|
||||||
@ -111,11 +163,11 @@ function PiecesSidebar({
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
await createPiece(defaultPiece);
|
const { source } = await createPiece(defaultPiece);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
onSelectPiece(name);
|
onSelectPiece(name, source);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyError('Piece の作成に失敗', e);
|
notifyError('Piece の作成に失敗', e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -123,20 +175,20 @@ function PiecesSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDuplicate = (sourceName: string) => {
|
const startDuplicate = (name: string, source: PieceSource) => {
|
||||||
setDuplicateSource(sourceName);
|
setDuplicateTarget({ name, source });
|
||||||
setDuplicateName(`${sourceName}-copy`);
|
setDuplicateName(`${name}-copy`);
|
||||||
setDuplicateError(null);
|
setDuplicateError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelDuplicate = () => {
|
const cancelDuplicate = () => {
|
||||||
setDuplicateSource(null);
|
setDuplicateTarget(null);
|
||||||
setDuplicateName('');
|
setDuplicateName('');
|
||||||
setDuplicateError(null);
|
setDuplicateError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitDuplicate = async () => {
|
const submitDuplicate = async () => {
|
||||||
if (!duplicateSource || duplicating) return;
|
if (!duplicateTarget || duplicating) return;
|
||||||
const name = duplicateName.trim();
|
const name = duplicateName.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
setDuplicateError('複製名を入力してください');
|
setDuplicateError('複製名を入力してください');
|
||||||
@ -149,11 +201,12 @@ function PiecesSidebar({
|
|||||||
try {
|
try {
|
||||||
setDuplicating(true);
|
setDuplicating(true);
|
||||||
setDuplicateError(null);
|
setDuplicateError(null);
|
||||||
const source = await fetchPiece(duplicateSource);
|
// Pass the source so we fetch from the SPECIFIC source (Fix 2).
|
||||||
await createPiece({ ...source, name });
|
const { piece: pieceData } = await fetchPiece(duplicateTarget.name, duplicateTarget.source);
|
||||||
|
const { source } = await createPiece({ ...pieceData, name });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||||
cancelDuplicate();
|
cancelDuplicate();
|
||||||
onSelectPiece(name);
|
onSelectPiece(name, source);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'Failed to duplicate piece';
|
const msg = e instanceof Error ? e.message : 'Failed to duplicate piece';
|
||||||
setDuplicateError(msg);
|
setDuplicateError(msg);
|
||||||
@ -162,10 +215,32 @@ function PiecesSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { defaults, customs } = splitPieces(pieces ?? []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
|
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
|
||||||
|
{/* Default Pieces section */}
|
||||||
<div className="flex items-center justify-between mb-2 px-2">
|
<div className="flex items-center justify-between mb-2 px-2">
|
||||||
<span className="section-label">Pieces</span>
|
<span className="section-label">Default Pieces</span>
|
||||||
|
</div>
|
||||||
|
{defaults.length === 0 && (
|
||||||
|
<div className="px-2 text-xs text-slate-400 mb-2">(none)</div>
|
||||||
|
)}
|
||||||
|
{defaults.map(p => (
|
||||||
|
<PieceRow
|
||||||
|
key={`builtin-${p.name}`}
|
||||||
|
p={p}
|
||||||
|
activeKey={activeKey}
|
||||||
|
isBuiltin={true}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onSelectPiece={onSelectPiece}
|
||||||
|
startDuplicate={startDuplicate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Custom Pieces section */}
|
||||||
|
<div className="flex items-center justify-between mt-3 mb-2 px-2">
|
||||||
|
<span className="section-label">Custom Pieces</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
|
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
|
||||||
@ -190,27 +265,22 @@ function PiecesSidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(pieces ?? []).map(p => (
|
{customs.length === 0 && !isCreating && (
|
||||||
<div key={p.name} className="group flex items-center mb-0.5 gap-1 pr-1">
|
<div className="px-2 text-xs text-slate-400">(none)</div>
|
||||||
<button onClick={() => onSelectPiece(p.name)}
|
)}
|
||||||
className={`flex-1 text-left px-2 py-1 rounded text-xs transition-colors min-w-0 truncate ${
|
{customs.map(p => (
|
||||||
activePiece === p.name
|
<PieceRow
|
||||||
? 'bg-accent-soft text-accent font-semibold'
|
key={`custom-${p.name}`}
|
||||||
: 'text-slate-700 hover:bg-surface'
|
p={p}
|
||||||
}`}>
|
activeKey={activeKey}
|
||||||
{p.name}
|
isBuiltin={false}
|
||||||
</button>
|
isAdmin={isAdmin}
|
||||||
{p.drift?.drifted && <DriftBadge drift={p.drift} />}
|
onSelectPiece={onSelectPiece}
|
||||||
<button
|
startDuplicate={startDuplicate}
|
||||||
onClick={(e) => { e.stopPropagation(); startDuplicate(p.name); }}
|
/>
|
||||||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 text-xs px-1.5 transition-opacity flex-shrink-0"
|
|
||||||
title="複製"
|
|
||||||
>
|
|
||||||
⎘
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{duplicateSource && (
|
|
||||||
|
{duplicateTarget && (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@ -223,7 +293,7 @@ function PiecesSidebar({
|
|||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div id="dup-piece-label" className="text-[13px] font-semibold text-slate-800 mb-2">
|
<div id="dup-piece-label" className="text-[13px] font-semibold text-slate-800 mb-2">
|
||||||
"{duplicateSource}" を複製
|
"{duplicateTarget.name}" を複製
|
||||||
</div>
|
</div>
|
||||||
<label className="block text-2xs font-medium text-slate-500 mb-1">複製名</label>
|
<label className="block text-2xs font-medium text-slate-500 mb-1">複製名</label>
|
||||||
<input
|
<input
|
||||||
@ -267,11 +337,14 @@ function PiecesSidebar({
|
|||||||
|
|
||||||
interface PiecesPageProps {
|
interface PiecesPageProps {
|
||||||
showToast?: ShowToast;
|
showToast?: ShowToast;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PiecesPage({ showToast }: PiecesPageProps = {}) {
|
export function PiecesPage({ showToast, isAdmin = true }: PiecesPageProps = {}) {
|
||||||
const { urlState, setUrlState } = useUrlState();
|
const { urlState, setUrlState } = useUrlState();
|
||||||
const piece = urlState.piece;
|
const piece = urlState.piece;
|
||||||
|
// pieceSource is persisted in the URL so reload correctly restores both name+source.
|
||||||
|
const selectedSource: PieceSource | undefined = urlState.pieceSource;
|
||||||
|
|
||||||
// モバイルでは list / detail のどちらかを全幅で表示。
|
// モバイルでは list / detail のどちらかを全幅で表示。
|
||||||
// URL に piece が指定されていれば detail から、そうでなければ list から。
|
// URL に piece が指定されていれば detail から、そうでなければ list から。
|
||||||
@ -282,17 +355,26 @@ export function PiecesPage({ showToast }: PiecesPageProps = {}) {
|
|||||||
if (piece) setMobileView('detail');
|
if (piece) setMobileView('detail');
|
||||||
}, [piece]);
|
}, [piece]);
|
||||||
|
|
||||||
const handleSelectPiece = (name: string) => {
|
const handleSelectPiece = (name: string, source: PieceSource) => {
|
||||||
setUrlState(prev => ({ ...prev, piece: name }));
|
setUrlState(prev => ({ ...prev, piece: name, pieceSource: source }));
|
||||||
setMobileView('detail');
|
setMobileView('detail');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Composite key for sidebar highlight.
|
||||||
|
const activeKey: SelectionKey | undefined =
|
||||||
|
piece && selectedSource ? makeKey(piece, selectedSource) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<div
|
<div
|
||||||
className={`${mobileView === 'list' ? 'block' : 'hidden'} md:block w-full md:w-52 flex-shrink-0`}
|
className={`${mobileView === 'list' ? 'block' : 'hidden'} md:block w-full md:w-52 flex-shrink-0`}
|
||||||
>
|
>
|
||||||
<PiecesSidebar activePiece={piece} onSelectPiece={handleSelectPiece} showToast={showToast} />
|
<PiecesSidebar
|
||||||
|
activeKey={activeKey}
|
||||||
|
onSelectPiece={handleSelectPiece}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${mobileView === 'detail' ? 'flex' : 'hidden'} md:flex flex-1 flex-col overflow-y-auto`}
|
className={`${mobileView === 'detail' ? 'flex' : 'hidden'} md:flex flex-1 flex-col overflow-y-auto`}
|
||||||
@ -309,7 +391,7 @@ export function PiecesPage({ showToast }: PiecesPageProps = {}) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1 p-6">
|
||||||
{piece ? (
|
{piece ? (
|
||||||
<PieceEditor name={piece} />
|
<PieceEditor name={piece} isAdmin={isAdmin} source={selectedSource} />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-slate-400">左から Piece を選択してください。</div>
|
<div className="text-sm text-slate-400">左から Piece を選択してください。</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { POLLING } from '../lib/constants.js';
|
|||||||
import { EmptyState } from '../components/shared/EmptyState';
|
import { EmptyState } from '../components/shared/EmptyState';
|
||||||
import { StatChip } from '../components/shared/StatChip';
|
import { StatChip } from '../components/shared/StatChip';
|
||||||
import { usePieceList } from '../hooks/usePieces';
|
import { usePieceList } from '../hooks/usePieces';
|
||||||
|
import { resolvePieceOptions } from '../lib/splitPieces';
|
||||||
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
|
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
|
||||||
import { useAuthState } from '../App';
|
import { useAuthState } from '../App';
|
||||||
|
|
||||||
@ -846,7 +847,7 @@ function ScheduleEditor({ mode, initialTask, onCancel, onSaved }: ScheduleEditor
|
|||||||
|
|
||||||
const pieceOptions = useMemo(() => {
|
const pieceOptions = useMemo(() => {
|
||||||
const opts = [{ value: 'auto', label: 'auto', description: 'LLM が自動選択' }];
|
const opts = [{ value: 'auto', label: 'auto', description: 'LLM が自動選択' }];
|
||||||
for (const p of pieces) {
|
for (const p of resolvePieceOptions(pieces)) {
|
||||||
if (p.name === 'auto') continue;
|
if (p.name === 'auto') continue;
|
||||||
opts.push({ value: p.name, label: p.name, description: p.description ?? '' });
|
opts.push({ value: p.name, label: p.name, description: p.description ?? '' });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user