diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9375f64dc..ff38cc601b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,9 @@ jobs: workspaces: "./src-tauri -> target" shared-key: "ci-macos" + - name: Stage org2-pm sidecar + run: node scripts/tauri/prepare-sidecars.cjs --profile debug + # Clippy runs the full compiler front-end, so it subsumes `cargo check`. # Check every target and reject new warnings now that the workspace # baseline is clean. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 81d09c997d..7c8dfcd32f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -155,6 +155,9 @@ jobs: run: python3 scripts/tools/download_sidecars.py # ── Build Tauri app (sign + notarize + updater artifacts) ─ + - name: Stage org2-pm sidecar + run: node scripts/tauri/prepare-sidecars.cjs --profile release --target aarch64-apple-darwin + - name: Build ORGII env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -306,6 +309,9 @@ jobs: shared-key: "release-windows-x64" # ── Build Tauri app ────────────────────────────────────────── + - name: Stage org2-pm sidecar + run: node scripts/tauri/prepare-sidecars.cjs --profile release --target x86_64-pc-windows-msvc + - name: Build ORGII env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -517,6 +523,9 @@ jobs: shared-key: "release-linux-x64" # ── Build Tauri app ────────────────────────────────────────── + - name: Stage org2-pm sidecar + run: node scripts/tauri/prepare-sidecars.cjs --profile release --target x86_64-unknown-linux-gnu + - name: Build ORGII env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index 9afbcb5165..b4aafa4711 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,7 @@ subagent_history.json *_subagent_history.json # Downloaded / locally built sidecar binaries +src-tauri/binaries/ src-tauri/bin/agent-browser-* src-tauri/bin/peekaboo* src-tauri/bin/qdrant-* diff --git a/docs/orgtrack-pm-protocol/decisions.md b/docs/orgtrack-pm-protocol/decisions.md index 8d461e6dd0..a6bc33b8d4 100644 --- a/docs/orgtrack-pm-protocol/decisions.md +++ b/docs/orgtrack-pm-protocol/decisions.md @@ -142,3 +142,40 @@ seq INTEGER NOT NULL)`; - 云端 `orgii_acquire_work_item_lock` 保持为 human 协作**编辑锁**:编辑锁 ≠ 工作 claim,两者并存但互不代理;Phase 2a 落地时在 service 层写清依赖关系 (持有编辑锁不阻止 claim,claim 不授予编辑权)。 + +## 修订记录 + +### 2026-08-07 — S0 conformance 收敛(PR #737) + +- `execution-context.schema.json`:`scopeId` 与 `actor` 增加 null 分支——未初始化 workspace 下的裸 `org2 context` 输出 null 是合法状态,schema 此前与实现矛盾(实现自 Phase 3 起即输出 null)。 +- 错误 fixture 文案与实现对齐(byte-golden 前置):`actor-required`(补 `` 格式提示)、`idempotency-conflict`(去尾词 "body")、`invalid-transition`(统一为信封映射的通用句式)、`project-mode-required`("WorkItem mutation" → "WorkItem/Routine mutation")。 +- claim 竞争的第二分支(active linked session)修正为 `ALREADY_CLAIMED`(此前误落 `STORE_UNAVAILABLE`,违反 §3 的 claim 竞争优先规则)。 +- `ALREADY_EXISTS` 获得生产者:`work.create` 全局存在性守卫(跨 scope 短 ID 碰撞从静默覆盖改为结构化拒绝)。 + +### 2026-08-07 — S1 载荷形态裁决(PR #737) + +- `work.*` 成功载荷本轮**维持存储形态**(`frontmatter` + `portableState` + `revision`)。阻塞:`work-item.schema.json` 的 `id` 要求 `^work_[A-Za-z0-9]+$` 持久 id,而存储沿用 `id = short_id`(如 `AAA-0001`);在 id 方案迁移落地前切换会产出无法通过自身 schema 的载荷。schema 与 6 个 success fixture 明确标记为 **id 迁移后的目标形态**,`commands.rs` 头部 residual 注记为唯一真相点的状态就此解除——真相点移至本条。 +- 分页(`--cursor`/`meta.nextCursor`)、portable `--status` 词表、`work assign/release`、`org2 project` 命令族(无 delete)已实装并入 conformance 覆盖。 + +### 2026-08-07 — PM Case CLI 载体裁决(用户拍板) + +修订 §4/§9 的"本轮无 PM CLI"条目:**PM Case 的 agent 载体从出生即 CLI**。`org2 pm` 命令族携独立版本 `org2/pm/v1` contract,与 PM Case 实现同期落地(不提前、不做类型化工具例外)。 + +理由:agent 平面已统一走 `org2-pm`(见 agent-cli-unification-design.md §5.6);PM 工具若做唯一类型化例外会破坏"agent 可见面 = CLI 面"等式,且 conformance 是必修课,早接比晚接便宜。 + +约束: + +- `org2 pm` 落地时必须同步 schemas、fixtures、权限矩阵、入口一致性测试,且**不得静默改变 `orgtrack/v1`**(独立版本命名空间)。 +- 不向 agent 暴露的动词永不进 CLI 面:`commit-confirmed-graph`、`promote-standalone-to-project` 只存在于用户确认 handler 内部。 +- 本条不改变本轮范围——本轮仍不实现 PM Case,故 `org2 pm` 仍未落地;改变的只是"未来它以何种形态出现"从未决转为已定。 + +### 2026-08-07 — S4 类型化工具退役执行(PR #737) + +`manage_work_item` / `manage_project` 从工具面删除(impl、注册、编排 fresh-registry 分支、builtin 表项、能力种子)。agent 平面唯一入口自此为 `org2-pm`;product_mode 的 deny-delta 工具层(`product_mode_layer`/`with_modes`)随之退役——门控收敛到 CLI 的 application boundary(`ORGII_MODE` + `require_project_mode`)。 + +配套裁决: + +- 工具名进入 `SUBAGENT_RETIRED_TOOL_ALIASES` 防御旧 checkpoint;常量保留用于历史转录渲染(前端渲染路径不删,先例同 `manage_story`)。 +- standalone(无项目)工作项获得 CLI 面:`work show/update/create --standalone`,org 上下文经 `ORGII_ORG` env 与 session marker `org` 字段注入;`--schedule-cron`/`--schedule-at` 补齐周期任务能力。 +- 子代理 CLI 身份解析到**顶层祖先 session**(workspace marker 绑定所有者,env 必须与之一致,否则共享 workspace 的 worker 全部被拒为冒充);子代理 session record 继承父 `product_mode`。 +- `debug_session_execute_tool` 收窄为 `read_file`;两条经 debug hook 驱动类型化工具的 e2e 场景删除(agent 平面覆盖由 `orgtrack-pm-cli` 跨进程 e2e 承接)。 diff --git a/docs/orgtrack-pm-protocol/dual-instance-s4-2026-08-07.md b/docs/orgtrack-pm-protocol/dual-instance-s4-2026-08-07.md new file mode 100644 index 0000000000..350e5da0c9 --- /dev/null +++ b/docs/orgtrack-pm-protocol/dual-instance-s4-2026-08-07.md @@ -0,0 +1,46 @@ +# 双机实测 — S4 工具退役(PR #737,2026-08-07) + +双机协议(`.orgii/skills/dual-instance-verification/SKILL.md`)对 S4(typed 工具删除 → org2-pm CLI 唯一 agent 入口)的实测记录。发送端 instance-1 = 本分支 debug 构建(webdriver feature,真实 `~/.orgii`),接收端 instance-2 = 旧构建(ORGII-instance2,未动)。 + +## Cell 结果 + +| Cell | 内容 | 结果 | +| ---- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A | filler-via-CLI:真实 Create-with-AI 渲染 UI + deepseek-v4-flash 真 LLM 轮次(wdio,隔离 live home) | **GREEN**(13.3s)。transcript:`work show --standalone` → `work update --title … --body …` → `work show` 复核,exit code 全 0;store 标题/正文逐字一致;audit `work.create → work.patch(app) → work.patch:agent:os`;会话行 `org=bfa7b134 / build / project` | +| B | 双机 collab 同步:CLI 跨进程写共享 org "CU New Target 0720" | **GREEN**。`WI-0013` CLI create+update → workitems 落库 → outbox 行 → watermark(2s poller) → `orgii-data-changed` → forceProjects → 云 `cloud_work_items` 行(rev2 标题,两次写合并推送)→ instance-2 拉取到本地。JWT 过期一度阻塞(GoTrue 多 client 竞态,自愈);两端窗口失焦时按 minimize-aware 设计不推/不拉,置前后立即走通 | +| C | 子代理 root-walk:真实 `builtin:general` delegation 跑 `org2-pm context` | **GREEN**。envelope `sessionRef = 顶层父会话`,exit 0。C-lite 三姿势(身份匹配 / 子会话 env 冒充 / human 冒充)分别 OK / PERMISSION_DENIED / PERMISSION_DENIED | +| D | 舰队账本(cloud_sessions 全量 1365→1368 行) | boot-1 出现 bfa7b134 org 全量 claudecodeapp epoch+1 波 + 2 行 `deleted_at→None` 复活;**boot-2 确定性 cell:0 增删、0 epoch 变**,仅本会话转录与一个活跃 codex 会话单调增长 → 定性为升级重锚定(旧 0829a9b 构建 → develop+737 序列化差异)恰好一次收敛 | +| E | 破坏性动词审计(两实例日志,boot 窗口) | 干净:仅 disabled-tool 跳过日志与临时 scratchpad housekeeping 两条良性命中 | + +## 实测挖出并已修(全部进 #737) + +1. **Ask/Plan 拒 shell → filler 填不了**:agent 连搜 15 次 `run_shell` 后放弃("能动 WorkItem⟺有 shell" 未决假设第一次爆雷)。修:filler 与 Create-Project-with-AI 启动 pin `agentExecMode:"build"`(`97f67fa3e`) +2. **filler 会话不带 org → ORGII_ORG 不注入 → org-standalone 草稿 CLI 找不到**。修:launch 顶层带 `orgId`(同上) +3. **standalone 分配器按 org 计数 vs id 全局主键**:真实 home 上 `WI-0001` 跨 org 碰撞被 S0 create 守卫拦下(旧代码是静默覆盖他 org 条目)。修:分配器跳过全局占用 id(`202185e6a`) +4. **standalone patch 审计 actor 为空**。修:actor 线穿 `update_standalone_work_item_atomic_by`(`09a8e8b9a`) +5. e2e spec 三处修复(Harry launchpad 改版死 selector / org-aware 扫描 + 抖动稳定 / `readProjectOrgs` helper 注册)(`97f67fa3e` + `835d6cf19`) + +## 追加发现(2026-08-07 晚,用户真机·bundled 构建) + +**P0 — bundle 不带 org2-pm**:`externalBin: []`,.app 只装主二进制;PATH prepend 指向 exe 目录但 CLI 不存在 → 打包构建里 agent 对工作系统零入口(S4 后无 typed 工具兜底)。实测全绿是因为 debug 裸跑恰好同目录。修复:org2-pm 进 `externalBin` sidecar(版本锁定,与"首启下载"类 sidecar 政策区分开),prepare-sidecars.cjs 接入 tauri:dev/tauri:build/fast-build/release CI 三平台;fast-build 复验 bundle 内 `Contents/MacOS/org2-pm context` 出合法 envelope。 + +## OPEN(非本 PR,已记录/挂单) + +- **session memory side query 钉死 claude-haiku-4.5**:账号无此 model 时提取失败并弹 toast → 已挂单(fallback 到账号可用 model) +- **`gemini_cli` 缺席 `CliAgentTypeSchema`**:sidebar 翻页含 6 月 gemini_cli 会话行时 RPC output validation 失败 → 已挂后台任务修 +- **云端删除后本地无 tombstone → 升级重推复活**:boot-1 将当日 16:49Z 云端删除的 2 行 `deleted_at` 复活(机制=本地行仍在、full re-push 重断言;已知"无 tombstone 不可证伪缺席"类;非 737 触发,737 窗口内仅因升级重锚定显形) +- **Projects-channel JWT 过期恢复慢**:多 GoTrueClient 告警 + 过期后至多约一个刷新周期不推;自愈但值得并入 token 单一持有者清理 +- UNCOVERED:S3 envelope 卡片的渲染态视觉断言(store/transcript 已证,视觉未截图);`--schedule-cron` 真机排程触发未跑 + +## Sub-item 与 Discussion 实测(2026-08-07 深夜,真机·真 Opus) + +围绕 sub-item 与 Discussion 机制(`--parent`/`--stage`、"Post exactly ONE comment per run"、turn-end fallback、mention 副作用纪律)落地并实测三轮: + +- **机械面**:`work create --parent` / `work note --standalone` / `work list --standalone` 补齐(cli_e2e 覆盖 parent 链接与 note 落 comments) +- **纪律面**:CLI brief 恢复拆分判据("more than one independently completable step")+ 恰好一条回执(outcome 不 process)+ blocked 转移;linked-work-item context 增 ⚠️ 交付强制块("Chat replies are conversation, not delivery") +- **实测轮次**:WI-0016(无强制块)=agent 全量产出丢聊天、零 CLI 调用 → 强制块后 WI-0017=body 填 381 字 + 恰好一条 `[progress]` 回执(Discussion UI 渲染验证);WI-0018=**自主拆 3 子项**(WI-0019/20/21 各 600-900 字内容,audit 8 条全带 agent:os)+ 父项闭环总览 + 恰好一条回执 +- **Gap(挂单)**:详情页无 Sub-items 区块,parent 关系仅在库中,agent 只能在父 body 手写引用 + +## 探针清理 + +`WI-0013` 已在云端打 tombstone(v2, deleted_at 2026-08-07T20:50:30Z),两实例经拉取收敛删除(见任务日志)。隔离 e2e home 与 wdio shard homes 为一次性沙箱已删。真机演示数据 `WI-0015`(rail 探针)与 `WI-0017`~`WI-0021`(纪律探针,bfa7b134 org)留作用户查看,自行处置。 diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json b/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json index 4e83711211..f3dc9b56f3 100644 --- a/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json +++ b/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json @@ -3,7 +3,7 @@ "ok": false, "error": { "code": "ACTOR_REQUIRED", - "message": "No actor resolved: pass --actor or set ORGII_ACTOR (actors are never inferred from OS username or git owner)", + "message": "No actor resolved: pass --actor or set ORGII_ACTOR (actors are never inferred from OS username or git owner)", "retryable": false, "details": { "missing": ["actor"] diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json b/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json index e98990126d..de86be0a92 100644 --- a/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json +++ b/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json @@ -3,7 +3,7 @@ "ok": false, "error": { "code": "IDEMPOTENCY_CONFLICT", - "message": "Idempotency key 'session_abc:complete' was already used with a different canonical request body", + "message": "Idempotency key 'session_abc:complete' was already used with a different canonical request", "retryable": false, "details": { "idempotencyKey": "session_abc:complete", diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json b/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json index 9e7a9541f2..de97399ab8 100644 --- a/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json +++ b/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json @@ -3,7 +3,7 @@ "ok": false, "error": { "code": "INVALID_TRANSITION", - "message": "completed -> in_progress is not allowed; in_progress is only entered via work.claim or blocked -> in_progress", + "message": "completed -> in_progress is not an allowed transition", "retryable": false, "details": { "from": "completed", diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json b/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json index 2a8b7b5467..b2bcd65e15 100644 --- a/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json +++ b/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json @@ -3,7 +3,7 @@ "ok": false, "error": { "code": "PROJECT_MODE_REQUIRED", - "message": "work.claim is a WorkItem mutation; current mode is 'build'. Switch the session to Project mode or pass --mode project", + "message": "work.claim is a WorkItem/Routine mutation; current mode is 'build'. Switch the session to Project mode or pass --mode project", "retryable": false, "details": { "operation": "work.claim", diff --git a/docs/orgtrack-pm-protocol/parity-matrix.md b/docs/orgtrack-pm-protocol/parity-matrix.md index b642632b42..ee12f2f269 100644 --- a/docs/orgtrack-pm-protocol/parity-matrix.md +++ b/docs/orgtrack-pm-protocol/parity-matrix.md @@ -3,14 +3,14 @@ 每个入口必须经过同一 Context resolver 与同一 application command 层。空格 = 未落地;落地 phase 在括号内标注后打 ✓;任何长期空格需要书面理由。 -| entry point | context resolver | actor 解析 | auth/capability 交集 | idempotency | OCC (expected-revision) | audit event | outbox | pm_change_seq bump | -| ----------------- | ---------------- | ---------- | -------------------- | ----------- | ----------------------- | ----------- | ------ | ------------------ | -| CLI (`org2-pm`) | (P3) | (P3) | (P3) | (P3) | (P3) | (P3) | (P3) | (P3) | -| Tauri commands | (P2a) | (P2a) | (P2a) | (P2a) | (P2a) | (P2a) | (P2a) | (P2a) | -| Routine scheduler | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | -| Provider adapter | (P6) | (P6) | (P6) | (P6) | (P6) | (P6) | (P6) | (P6) | -| hooks | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | -| tests/E2E helpers | (P3) | (P3) | (P3) | (P3) | (P3) | (P3) | (P3) | (P3) | +| entry point | context resolver | actor 解析 | auth/capability 交集 | idempotency | OCC (expected-revision) | audit event | outbox | pm_change_seq bump | +| ----------------- | ---------------- | ---------- | -------------------- | ------------------------------------- | ----------------------- | ---------------------------------------------------- | ------ | ------------------ | +| CLI (`org2-pm`) | (P3) ✓ | (P3) ✓ | (P3) ✓ | (P3) ✓ create/claim/transition/invoke | (P3) ✓ claim/transition | (P3) ✓ | (P3) | (P3) ✓ | +| Tauri commands | (P2a) | (P2a) | (P2a) | (P2a) | (P2a) | (P2a) ✓ create/patch/transition/delete/restore/write | (P2a) | (P2a) ✓ | +| Routine scheduler | (P5) | (P5) | (P5) | (P5) ✓ plan-time invoke key | (P5) | (P5) ✓ invoke/suppressed_fire | (P5) | (P5) ✓ | +| Provider adapter | (P6) | (P6) | (P6) | (P6) | (P6) | (P6) | (P6) | (P6) | +| hooks | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | (P5) | +| tests/E2E helpers | (P3) ✓ | (P3) ✓ | (P3) ✓ | (P3) ✓ | (P3) ✓ | (P3) ✓ | (P3) | (P3) ✓ | 规则: diff --git a/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json b/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json index 4df89f85f3..227b274dd0 100644 --- a/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json +++ b/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json @@ -13,20 +13,54 @@ "capabilities" ], "properties": { - "apiVersion": { "$ref": "common.schema.json#/definitions/apiVersion" }, - "mode": { "$ref": "common.schema.json#/definitions/productMode" }, + "apiVersion": { + "$ref": "common.schema.json#/definitions/apiVersion" + }, + "mode": { + "$ref": "common.schema.json#/definitions/productMode" + }, "runtimeExecutionMode": { "type": "string", "description": "Existing agent/provider wire values (build|ask|plan|debug|review|wingman|provider extension). Distinct from the Rust ExecutionMode{Direct,WorkStation} type — see decisions.md §2." }, - "scopeId": { "type": "string", "minLength": 1 }, - "orgId": { "type": "string", "minLength": 1 }, - "workspace": { "type": "string" }, - "actor": { "$ref": "common.schema.json#/definitions/actorRef" }, + "scopeId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Null when no scope is resolved (bare `org2 context` outside an initialized workspace)." + }, + "orgId": { + "type": "string", + "minLength": 1 + }, + "workspace": { + "type": "string" + }, + "actor": { + "anyOf": [ + { + "$ref": "common.schema.json#/definitions/actorRef" + }, + { + "type": "null" + } + ], + "description": "Null when no actor is resolved; mutations then require --actor or ORGII_ACTOR." + }, "sessionRef": { "oneOf": [ - { "$ref": "common.schema.json#/definitions/sessionRef" }, - { "type": "null" } + { + "$ref": "common.schema.json#/definitions/sessionRef" + }, + { + "type": "null" + } ] }, "runtimeProvider": { @@ -34,7 +68,10 @@ "additionalProperties": false, "required": ["id", "profiles"], "properties": { - "id": { "type": "string", "minLength": 1 }, + "id": { + "type": "string", + "minLength": 1 + }, "profiles": { "type": "array", "items": { @@ -45,11 +82,20 @@ } }, "activeWorkItemId": { - "oneOf": [{ "type": "string" }, { "type": "null" }] + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "capabilities": { "type": "array", - "items": { "$ref": "common.schema.json#/definitions/capabilityId" }, + "items": { + "$ref": "common.schema.json#/definitions/capabilityId" + }, "uniqueItems": true } } diff --git a/scripts/dev/tauri.js b/scripts/dev/tauri.js index 259795e239..6b58c9bdfa 100755 --- a/scripts/dev/tauri.js +++ b/scripts/dev/tauri.js @@ -563,6 +563,18 @@ function startFrontendDev() { } function startTauriDev(features) { + // `externalBin` requires the staged org2-pm sidecar before the tauri CLI + // starts (dev copies it next to the debug binary). + const sidecarResult = execFileSync( + process.execPath, + [ + path.join(rootDir, "scripts", "tauri", "prepare-sidecars.cjs"), + "--profile", + "debug", + ], + { cwd: rootDir, stdio: "inherit" } + ); + void sidecarResult; const args = createTauriArgs({ features, devUrl: createDevUrl(), diff --git a/scripts/tauri/build-fast-parallel.cjs b/scripts/tauri/build-fast-parallel.cjs index 081698ca0a..5ed8dbfbb1 100644 --- a/scripts/tauri/build-fast-parallel.cjs +++ b/scripts/tauri/build-fast-parallel.cjs @@ -264,6 +264,22 @@ async function main() { "\x1b[1m[build-fast-parallel] Phase 2: tauri build + bundle\x1b[0m" ); + // Stage the org2-pm sidecar (externalBin) before the tauri CLI runs; it + // shares the dev-build dep cache so this is nearly free after the first + // compile. + const sidecarResult = spawnSync( + process.execPath, + [ + path.join(__dirname, "prepare-sidecars.cjs"), + "--profile", + "dev-build", + ], + { cwd: rootDir, env, stdio: "inherit" } + ); + if (sidecarResult.status !== 0) { + process.exit(sidecarResult.status ?? 1); + } + const configOverride = JSON.stringify({ ...(instanceProfile ? { diff --git a/scripts/tauri/prepare-sidecars.cjs b/scripts/tauri/prepare-sidecars.cjs new file mode 100644 index 0000000000..0f10cdfe78 --- /dev/null +++ b/scripts/tauri/prepare-sidecars.cjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +/** + * Builds the `org2-pm` CLI and stages it where tauri's `externalBin` + * expects it: `src-tauri/binaries/org2-pm-[.exe]`. + * + * Unlike the downloaded sidecars (peekaboo, agent-browser, git — fetched + * into ~/.orgii/bin at first launch), org2-pm is built from this repo and + * version-locked to the app: agents resolve it via the PATH prepend that + * points at the app binary's own directory, so the bundle must carry the + * exact matching build. + * + * Usage: + * node scripts/tauri/prepare-sidecars.cjs [--profile ] [--target ] + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const rootDir = path.join(__dirname, "..", ".."); +const srcTauriDir = path.join(rootDir, "src-tauri"); +const rawArgs = process.argv.slice(2); + +function argValue(flag) { + const index = rawArgs.indexOf(flag); + return index >= 0 ? (rawArgs[index + 1] ?? null) : null; +} + +const profile = argValue("--profile") ?? "debug"; +const explicitTarget = argValue("--target"); + +function hostTriple() { + const result = spawnSync("rustc", ["-vV"], { encoding: "utf8" }); + if (result.status !== 0) { + console.error("[prepare-sidecars] rustc -vV failed"); + process.exit(result.status ?? 1); + } + const match = result.stdout.match(/host: (\S+)/); + if (!match) { + console.error("[prepare-sidecars] could not parse host triple from rustc -vV"); + process.exit(1); + } + return match[1]; +} + +function cargoTargetDir() { + const result = spawnSync( + "cargo", + ["metadata", "--format-version", "1", "--no-deps"], + { cwd: srcTauriDir, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] } + ); + if (result.status !== 0) { + console.error("[prepare-sidecars] cargo metadata failed"); + process.exit(result.status ?? 1); + } + return JSON.parse(result.stdout).target_directory; +} + +const triple = explicitTarget ?? hostTriple(); +const exeSuffix = triple.includes("windows") ? ".exe" : ""; + +const cargoArgs = ["build", "-p", "orgtrack-pm-cli", "--bin", "org2-pm"]; +if (profile === "release") { + cargoArgs.push("--release"); +} else if (profile !== "debug") { + cargoArgs.push("--profile", profile); +} +if (explicitTarget) { + cargoArgs.push("--target", explicitTarget); +} + +console.log(`[prepare-sidecars] cargo ${cargoArgs.join(" ")}`); +const build = spawnSync("cargo", cargoArgs, { + cwd: srcTauriDir, + stdio: "inherit", + env: process.env, +}); +if (build.status !== 0) { + console.error("[prepare-sidecars] org2-pm build failed"); + process.exit(build.status ?? 1); +} + +const profileDir = profile === "debug" ? "debug" : profile; +const builtPath = path.join( + cargoTargetDir(), + ...(explicitTarget ? [explicitTarget] : []), + profileDir, + `org2-pm${exeSuffix}` +); +if (!fs.existsSync(builtPath)) { + console.error(`[prepare-sidecars] built binary not found at ${builtPath}`); + process.exit(1); +} + +const stagingDir = path.join(srcTauriDir, "binaries"); +fs.mkdirSync(stagingDir, { recursive: true }); +const stagedPath = path.join(stagingDir, `org2-pm-${triple}${exeSuffix}`); +fs.copyFileSync(builtPath, stagedPath); +if (process.platform !== "win32") { + fs.chmodSync(stagedPath, 0o755); +} +console.log(`[prepare-sidecars] staged ${stagedPath}`); diff --git a/scripts/tauri/run-with-features.cjs b/scripts/tauri/run-with-features.cjs index 471aee6829..08638d1e86 100644 --- a/scripts/tauri/run-with-features.cjs +++ b/scripts/tauri/run-with-features.cjs @@ -30,6 +30,29 @@ if (featureString.length > 0) { args.push(...extraArgs); const rootDir = path.join(__dirname, "..", ".."); + +// `externalBin` requires the staged org2-pm sidecar to exist before the +// tauri CLI starts (dev copies it next to the debug binary; build bundles +// and signs it). +if (subcommand === "dev" || subcommand === "build") { + const targetIndex = extraArgs.indexOf("--target"); + const sidecarArgs = [ + path.join(__dirname, "prepare-sidecars.cjs"), + "--profile", + subcommand === "build" ? "release" : "debug", + ]; + if (targetIndex >= 0 && extraArgs[targetIndex + 1]) { + sidecarArgs.push("--target", extraArgs[targetIndex + 1]); + } + const sidecarResult = spawnSync(process.execPath, sidecarArgs, { + stdio: "inherit", + cwd: rootDir, + }); + if (sidecarResult.status !== 0) { + process.exit(sidecarResult.status ?? 1); + } +} + const result = spawnSync("tauri", args, { stdio: "inherit", shell: true, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd3028fd1f..8ce8d390a0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -164,7 +164,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -851,6 +853,12 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.3" @@ -923,6 +931,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -2086,6 +2100,15 @@ dependencies = [ "serde", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.9" @@ -2254,6 +2277,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-float2" version = "0.2.3" @@ -2388,6 +2422,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -2453,6 +2498,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs2" version = "0.4.3" @@ -3907,6 +3962,31 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a960f0c34d5423581d858ce94815cc11f0171b09939409097969ed269ede1b" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex 0.14.0", + "fraction", + "idna", + "itoa", + "num-cmp", + "once_cell", + "percent-encoding", + "referencing", + "regex-syntax", + "reqwest 0.12.28", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "key_vault" version = "0.1.0" @@ -4244,7 +4324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" dependencies = [ "bitflags 1.3.2", - "fluent-uri", + "fluent-uri 0.1.4", "serde", "serde_json", "serde_repr", @@ -4662,6 +4742,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4688,6 +4782,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4714,6 +4823,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -5235,6 +5355,7 @@ version = "0.1.0" dependencies = [ "chrono", "database", + "jsonschema", "project_management", "rusqlite", "serde", @@ -5334,6 +5455,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "pango" version = "0.18.3" @@ -6027,7 +6154,9 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "chrono-tz", "core_types", + "croner", "database", "git", "hex", @@ -6441,6 +6570,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8e15af8558cb157432dd3d88c1d1e982d0a5755cf80ce593b6499260aebc49" +dependencies = [ + "ahash", + "fluent-uri 0.3.2", + "once_cell", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.4" @@ -8741,7 +8883,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex", + "fancy-regex 0.13.0", "lazy_static", "regex", "rustc-hash 1.1.0", @@ -9505,6 +9647,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -9541,6 +9694,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs b/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs new file mode 100644 index 0000000000..a0cf244539 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/child_done_wake.rs @@ -0,0 +1,382 @@ +//! Child-done parent wake. +//! +//! When the last open sub-item of a parent Work Item settles (every +//! child reaches a terminal portable state), the parent's Discussion +//! gets a system progress note and the parent's latest linked session +//! is resumed with a barrier summary so its agent can close out or +//! advance the plan. Per-child completions do NOT wake the parent — +//! only the closure of the whole (implicit) stage does, so multi-child +//! plans produce one wake instead of a wake storm. + +use std::collections::HashSet; +use std::sync::Mutex; +use std::sync::OnceLock; + +use tracing::{info, warn}; + +use project_management::projects::events::WorkItemTerminalEvent; +use project_management::projects::io as pio; +use project_management::projects::types::{WorkItemData, WorkItemMutationActor}; +use project_management::work_service; +use project_management::work_service::state::{map_legacy_status, WorkItemState}; + +fn is_terminal(status: &str) -> bool { + matches!( + map_legacy_status(status), + Some(WorkItemState::Completed | WorkItemState::Failed | WorkItemState::Cancelled) + ) +} + +fn wake_dedupe() -> &'static Mutex> { + static SET: OnceLock>> = OnceLock::new(); + SET.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Register the terminal-transition observer. Called once at app setup. +pub fn register(app: tauri::AppHandle) { + project_management::projects::events::register_work_item_terminal_notifier(Box::new( + move |event| { + process_event(app.clone(), event); + }, + )); +} + +/// Dispatch one terminal transition into the wake pipeline. Shared by +/// the in-process notifier (app-local writes) and the cross-process +/// audit-stream bridge (CLI writes); barrier-level dedupe makes double +/// delivery of the same closure harmless. +pub fn process_event(app: tauri::AppHandle, event: WorkItemTerminalEvent) { + tauri::async_runtime::spawn(async move { + if let Err(error) = handle_child_terminal(app, event.clone()).await { + warn!( + child = %event.short_id, + error = %error, + "[child-done-wake] failed to process terminal transition" + ); + } + }); +} + +/// Cross-process bridge: fold audit-stream status transitions committed +/// by OTHER processes (the org2-pm CLI in agent shells) into the same +/// wake pipeline. Returns the number of terminal crossings dispatched. +pub fn process_audit_window(app: &tauri::AppHandle, after_seq: i64) -> usize { + let transitions = + match project_management::work_service::audit::read_status_transitions_since(after_seq) { + Ok(rows) => rows, + Err(error) => { + warn!(error = %error, "[child-done-wake] audit window read failed"); + return 0; + } + }; + let mut dispatched = 0; + for transition in transitions { + if is_terminal(&transition.status_from) || !is_terminal(&transition.status_to) { + continue; + } + let org_id = transition + .org_id + .clone() + .unwrap_or_else(|| "personal-org".to_string()); + let item = match pio::read_work_item_by_row_id(&org_id, &transition.entity_id) { + Ok(Some(item)) => item, + _ => continue, + }; + process_event( + app.clone(), + WorkItemTerminalEvent { + org_id, + project_slug: transition.project_slug.clone(), + short_id: item.frontmatter.short_id.clone(), + parent: item.frontmatter.parent.clone(), + status: transition.status_to.clone(), + }, + ); + dispatched += 1; + } + dispatched +} + +async fn handle_child_terminal( + app: tauri::AppHandle, + event: WorkItemTerminalEvent, +) -> Result<(), String> { + let Some(parent_short_id) = event.parent.clone() else { + return Ok(()); + }; + + let barrier = tokio::task::spawn_blocking({ + let event = event.clone(); + let parent_short_id = parent_short_id.clone(); + move || evaluate_barrier(&event, &parent_short_id) + }) + .await + .map_err(|err| format!("join error: {err}"))??; + + let Some(barrier) = barrier else { + return Ok(()); + }; + + let dedupe_key = format!( + "{}/{}/{}", + event.org_id, + event.project_slug.as_deref().unwrap_or("-"), + parent_short_id + ); + { + let mut seen = wake_dedupe().lock().map_err(|_| "dedupe poisoned")?; + if !seen.insert(format!("{dedupe_key}:{}", barrier.settled_key)) { + return Ok(()); + } + } + + let note = barrier.summary.clone(); + { + let event = event.clone(); + let parent_short_id = parent_short_id.clone(); + let note = note.clone(); + tokio::task::spawn_blocking(move || post_parent_note(&event, &parent_short_id, ¬e)) + .await + .map_err(|err| format!("join error: {err}"))??; + } + project_management::projects::events::notify_data_changed(); + + let Some(session_id) = barrier.parent_session_id else { + info!( + parent = %parent_short_id, + "[child-done-wake] barrier closed; note posted (no linked session to wake)" + ); + return Ok(()); + }; + + use tauri::Manager; + let Some(state) = app.try_state::() else { + return Err("AgentAppState unavailable".to_string()); + }; + let content = format!( + "[Sub-items complete] {note}\n\nReview the parent with `org2-pm work show {parent_short_id}` \ + and decide the next step — close it out, or create/advance follow-up items. \ + Deliver every outcome through org2-pm with exactly one Discussion receipt." + ); + let display_text = format!("🧩 Sub-item barrier closed on {parent_short_id}"); + crate::state::commands::session::message::send_message_impl( + &state, + session_id.clone(), + content, + Some(display_text), + crate::state::commands::session::identity::IdentityOverrides::default(), + None, + None, + None, + false, + false, + None, + None, + None, + None, + crate::foundation::session_bridge::TurnIntentBridgeSource::Queue, + ) + .await + .map_err(|err| format!("wake enqueue failed: {err}"))?; + info!( + parent = %parent_short_id, + session_id, + "[child-done-wake] barrier closed; parent session woken" + ); + Ok(()) +} + +struct BarrierClosure { + /// Stable key of the settled barrier (stage-scoped), so + /// re-transitions of an already-closed barrier don't wake twice. + settled_key: String, + /// Human summary shared by the parent's Discussion note and the + /// wake message. + summary: String, + parent_session_id: Option, +} + +fn completed_count(children: &[&WorkItemData]) -> usize { + children + .iter() + .filter(|child| { + matches!( + map_legacy_status(&child.frontmatter.status), + Some(WorkItemState::Completed) + ) + }) + .count() +} + +fn sorted_ids_key(children: &[&WorkItemData]) -> String { + let mut ids: Vec<&str> = children + .iter() + .map(|child| child.frontmatter.short_id.as_str()) + .collect(); + ids.sort_unstable(); + ids.join(",") +} + +fn evaluate_barrier( + event: &WorkItemTerminalEvent, + parent_short_id: &str, +) -> Result, String> { + let items: Vec = match event.project_slug.as_deref() { + Some(slug) => pio::read_all_work_items(slug)?, + None => pio::read_standalone_work_items(Some(event.org_id.as_str()))?, + }; + + let parent = items + .iter() + .find(|item| item.frontmatter.short_id == parent_short_id); + let Some(parent) = parent else { + return Ok(None); + }; + if is_terminal(&parent.frontmatter.status) { + return Ok(None); + } + + let children: Vec<&WorkItemData> = items + .iter() + .filter(|item| { + item.frontmatter.parent.as_deref() == Some(parent_short_id) + && item.frontmatter.deleted_at.is_none() + }) + .collect(); + if children.is_empty() { + return Ok(None); + } + + let parent_session_id = parent + .frontmatter + .linked_sessions + .iter() + .filter(|session| session.parent_session_id.is_none()) + .max_by(|a, b| a.started_at.cmp(&b.started_at)) + .map(|session| session.session_id.clone()); + + let staged: Vec<&WorkItemData> = children + .iter() + .copied() + .filter(|child| child.frontmatter.stage.is_some()) + .collect(); + + if staged.is_empty() { + // Implicit single stage: the barrier is the whole child set. + if !children + .iter() + .all(|child| is_terminal(&child.frontmatter.status)) + { + return Ok(None); + } + let completed = completed_count(&children); + let summary = format!( + "All {} sub-items of {} are settled ({} completed, {} cancelled/failed). Latest: {} → {}.", + children.len(), + parent_short_id, + completed, + children.len() - completed, + event.short_id, + event.status, + ); + return Ok(Some(BarrierClosure { + settled_key: sorted_ids_key(&children), + summary, + parent_session_id, + })); + } + + // Staged frontier semantics: only a STAGED child's + // completion can close a barrier; stage S closes when every staged + // sibling with stage <= S is terminal. Unstaged children are ignored. + let Some(triggering_stage) = children + .iter() + .find(|child| child.frontmatter.short_id == event.short_id) + .and_then(|child| child.frontmatter.stage) + else { + return Ok(None); + }; + let frontier: Vec<&WorkItemData> = staged + .iter() + .copied() + .filter(|child| child.frontmatter.stage.unwrap_or(u32::MAX) <= triggering_stage) + .collect(); + if !frontier + .iter() + .all(|child| is_terminal(&child.frontmatter.status)) + { + return Ok(None); + } + + let mut stage_numbers: Vec = staged + .iter() + .filter_map(|child| child.frontmatter.stage) + .collect(); + stage_numbers.sort_unstable(); + stage_numbers.dedup(); + let mut progress_parts: Vec = Vec::new(); + let mut next_stage: Option = None; + for stage in &stage_numbers { + let members: Vec<&WorkItemData> = staged + .iter() + .copied() + .filter(|child| child.frontmatter.stage == Some(*stage)) + .collect(); + let settled = members + .iter() + .filter(|child| is_terminal(&child.frontmatter.status)) + .count(); + let marker = if *stage > triggering_stage && next_stage.is_none() && settled < members.len() + { + next_stage = Some(*stage); + " (next)" + } else { + "" + }; + progress_parts.push(format!("Stage {}: {}/{}{}", stage, settled, members.len(), marker)); + } + let next_hint = match next_stage { + Some(stage) => format!( + " Advance the Stage {} sub-items next (org2-pm work claim/transition), or close out if nothing remains.", + stage + ), + None => " No later stage remains — review and close out the parent.".to_string(), + }; + let summary = format!( + "Stage {} of {} is complete ({}). Latest: {} → {}.{}", + triggering_stage, + parent_short_id, + progress_parts.join("; "), + event.short_id, + event.status, + next_hint, + ); + Ok(Some(BarrierClosure { + settled_key: format!("stage{}:{}", triggering_stage, sorted_ids_key(&frontier)), + summary, + parent_session_id, + })) +} + +fn post_parent_note( + event: &WorkItemTerminalEvent, + parent_short_id: &str, + note: &str, +) -> Result<(), String> { + let actor = WorkItemMutationActor { + id: "system".to_string(), + name: "System".to_string(), + }; + match event.project_slug.as_deref() { + Some(slug) => { + work_service::note_project_work_item(slug, parent_short_id, "progress", note, Some(&actor)) + } + None => work_service::note_standalone_work_item( + Some(event.org_id.as_str()), + parent_short_id, + "progress", + note, + Some(&actor), + ), + } +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/mod.rs index 80407a6d10..9aea1d49e2 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/mod.rs @@ -27,6 +27,7 @@ pub mod agent_org_run_events; pub mod agent_org_runs; pub mod agent_org_tasks; pub mod agent_org_watchdog; +pub mod child_done_wake; pub mod routine_scheduler; pub mod work_item_recovery; pub mod work_item_scheduler; diff --git a/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs b/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs index 26ce34c246..29956288ae 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/routine_scheduler.rs @@ -15,6 +15,7 @@ use chrono::{DateTime, Utc}; use tracing::{info, warn}; use project_management::projects::io; +use project_management::projects::routine_schedule::{due_times, next_occurrence}; use project_management::projects::types::{ RoutineCatchUpPolicy, RoutineDefinition, RoutineTrigger, }; @@ -66,8 +67,7 @@ async fn tick(app: &tauri::AppHandle, now: DateTime) -> Result<(), String> } /// Evaluate the portable `pm_routines` schedule activations (design -/// §10.4). Cron is evaluated in UTC for now — the declared timezone is -/// carried in the spec and honored once tz-aware evaluation lands. +/// §10.4). Cron is evaluated in the timezone declared by the portable spec. /// Catch-up: both portable policies (`none`, `fire_once`) reduce to /// "fire the latest missed tick once", matching the legacy collapse. async fn portable_tick(now: DateTime) -> Result<(), String> { @@ -84,6 +84,7 @@ async fn portable_tick(now: DateTime) -> Result<(), String> { .unwrap_or_else(|| now - chrono::Duration::seconds(POLL_INTERVAL_SECS as i64)); let trigger = RoutineTrigger::Cron { cron: candidate.cron.clone(), + timezone: candidate.timezone.clone(), }; let due = match due_times(&trigger, &window_start, &now) { Ok(due) => due, @@ -114,7 +115,9 @@ async fn portable_tick(now: DateTime) -> Result<(), String> { routines::audit_suppressed_fire(&name, "no_scope_binding", scheduled_millis)?; return Ok(()); }; - let run = routines::invoke(&name, &scope, &Default::default(), None)?; + let invoke_key = format!("{}:{}", name, scheduled_millis); + let run = + routines::invoke(&name, &scope, &Default::default(), None, Some(&invoke_key))?; info!( "[routine-scheduler] portable routine {} fired run {}", name, run.run_id @@ -134,6 +137,7 @@ async fn portable_tick(now: DateTime) -> Result<(), String> { let next = next_occurrence( &RoutineTrigger::Cron { cron: candidate.cron.clone(), + timezone: candidate.timezone.clone(), }, &now, ) @@ -241,48 +245,6 @@ fn watermark(routine: &RoutineDefinition, now: DateTime) -> DateTime { .unwrap_or_else(|| now - chrono::Duration::seconds(POLL_INTERVAL_SECS as i64)) } -/// All trigger times in `(window_start, now]`. -fn due_times( - trigger: &RoutineTrigger, - window_start: &DateTime, - now: &DateTime, -) -> Result>, String> { - match trigger { - RoutineTrigger::OneTime { at } => { - let at_time = parse_trigger_time(at)?; - if at_time > *window_start && at_time <= *now { - Ok(vec![at_time]) - } else if at_time <= *window_start { - // Missed while the app was closed — still due exactly once; - // catch-up policy decides whether it actually runs. - Ok(vec![at_time]) - } else { - Ok(Vec::new()) - } - } - RoutineTrigger::Cron { cron } => { - let parsed = croner::Cron::new(cron) - .parse() - .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; - let mut due = Vec::new(); - let mut cursor = *window_start; - // Bounded to avoid unbounded loops on pathological expressions - // after long downtime. - const MAX_DUE: usize = 1000; - while due.len() < MAX_DUE { - match parsed.find_next_occurrence(&cursor, false) { - Ok(next) if next <= *now => { - due.push(next); - cursor = next; - } - _ => break, - } - } - Ok(due) - } - } -} - /// Reduce the due list according to the catch-up policy. The latest due time /// always fires; earlier (missed) ones are policy-dependent. fn apply_catch_up_policy( @@ -313,34 +275,6 @@ fn apply_catch_up_policy( } } -fn next_occurrence( - trigger: &RoutineTrigger, - now: &DateTime, -) -> Result>, String> { - match trigger { - RoutineTrigger::OneTime { at } => { - let at_time = parse_trigger_time(at)?; - Ok((at_time > *now).then_some(at_time)) - } - RoutineTrigger::Cron { cron } => { - let parsed = croner::Cron::new(cron) - .parse() - .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; - Ok(parsed.find_next_occurrence(now, false).ok()) - } - } -} - -fn parse_trigger_time(raw: &str) -> Result, String> { - if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) { - return Ok(parsed.with_timezone(&Utc)); - } - if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") { - return Ok(parsed.and_utc()); - } - Err(format!("invalid one-time trigger timestamp: {raw}")) -} - #[cfg(test)] mod tests { use super::*; @@ -358,6 +292,7 @@ mod tests { fn cron_no_tick_in_window_returns_empty() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; let window_start = at(2026, 6, 10, 10, 0); let now = at(2026, 6, 10, 10, 5); @@ -368,6 +303,7 @@ mod tests { fn cron_single_tick_in_window() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; let window_start = at(2026, 6, 10, 8, 0); let now = at(2026, 6, 10, 10, 0); @@ -379,6 +315,7 @@ mod tests { fn cron_multiple_missed_ticks_accumulate() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; // Three days of downtime → three missed 09:00 ticks. let window_start = at(2026, 6, 7, 12, 0); @@ -398,6 +335,7 @@ mod tests { fn cron_invalid_expression_is_error() { let trigger = RoutineTrigger::Cron { cron: "not a cron".to_string(), + timezone: "UTC".to_string(), }; let now = Utc::now(); assert!(due_times(&trigger, &now, &now).is_err()); @@ -490,6 +428,7 @@ mod tests { fn next_occurrence_cron() { let trigger = RoutineTrigger::Cron { cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), }; let now = at(2026, 6, 10, 10, 0); let next = next_occurrence(&trigger, &now).unwrap().unwrap(); @@ -527,6 +466,7 @@ mod tests { enabled: true, trigger: RoutineTrigger::Cron { cron: "* * * * *".into(), + timezone: "UTC".into(), }, run_template: project_management::projects::types::RoutineRunTemplate { prompt: String::new(), @@ -546,6 +486,11 @@ mod tests { output_policy: Default::default(), last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; diff --git a/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs b/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs index c302e8dbb7..c7ade8af35 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/work_item_scheduler.rs @@ -297,7 +297,10 @@ pub fn migrate_cron_schedules() -> Result { name: format!("Recurring: {}", fm.title), description: format!("Migrated from work item {} recurring schedule", fm.short_id), enabled: true, - trigger: RoutineTrigger::Cron { cron }, + trigger: RoutineTrigger::Cron { + cron, + timezone: "UTC".to_string(), + }, run_template: RoutineRunTemplate { prompt: fm.title.clone(), target: RoutineRunTarget::AgentDefinition { @@ -321,6 +324,11 @@ pub fn migrate_cron_schedules() -> Result { }, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; @@ -511,6 +519,7 @@ mod tests { labels: vec![], milestone: None, parent: None, + stage: None, start_date: start_date.map(|s| s.to_string()), target_date: None, created_by: None, diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/mod.rs b/src-tauri/crates/agent-core/src/core/definitions/builtin/mod.rs index a7f0a8b34b..d37bae2d24 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/mod.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/builtin/mod.rs @@ -19,10 +19,10 @@ //! //! The old gateway router and specialist-agent wrappers were retired: //! - The gateway router pattern rejected general conversational traffic. -//! - Specialist wrappers only wrapped 2-3 tools each (`manage_project`, -//! `manage_work_item`, `manage_agent_def`); making them parent-callable -//! tools on OS Agent removes a delegation hop and lets users -//! toggle them on/off per agent like any other built-in tool. +//! - Specialist wrappers only wrapped 2-3 tools each (e.g. +//! `manage_agent_def`); making them parent-callable tools on OS Agent +//! removes a delegation hop and lets users toggle them on/off per +//! agent like any other built-in tool. mod ade_manager; mod ai_research; diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs b/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs index e61e07d47d..89d9256588 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/builtin/os.rs @@ -78,8 +78,9 @@ pub fn os_agent() -> AgentDefinition { // OS Agent ships with capability-mismatched tools excluded by // default (coding tools — edit_file, query_lsp, etc.). // Desktop, browser, plugins, management, and core tools remain available. - // `manage_project`, `manage_work_item`, and `manage_agent_def` - // are available directly as management-capability tools. + // `manage_agent_def` is available directly as a + // management-capability tool; work items and projects are + // reached through the `org2-pm` CLI, not tools. tools: AgentToolSelection { excluded_tools, ..Default::default() diff --git a/src-tauri/crates/agent-core/src/core/definitions/capabilities.rs b/src-tauri/crates/agent-core/src/core/definitions/capabilities.rs index d0fd0e5ac6..a832fb0699 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/capabilities.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/capabilities.rs @@ -30,8 +30,7 @@ pub enum RequiredCapability { Gateway, /// Requires `data: Some(_)` (manage_nodes). Data, - /// Requires `management: Some(_)` (manage_session, manage_project, - /// manage_work_item, manage_agent_def). + /// Requires `management: Some(_)` (manage_session, manage_agent_def). Management, /// Agent orchestration plumbing (agent, tool_search, …). Always available. Orchestration, diff --git a/src-tauri/crates/agent-core/src/core/definitions/resolved.rs b/src-tauri/crates/agent-core/src/core/definitions/resolved.rs index b59df265da..ece272bcee 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/resolved.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/resolved.rs @@ -537,7 +537,7 @@ mod tests { fn user_allowed_tools_do_not_override_missing_capabilities() { let mut def = with_pinned_model(get_builtin_agent("builtin:sde").expect("sde builtin exists")); - def.tools.user_allowed_tools = vec![crate::tools::names::MANAGE_PROJECT.to_string()]; + def.tools.user_allowed_tools = vec![crate::tools::names::MANAGE_SESSION.to_string()]; let resolved = ResolvedAgent::resolve(&def, None, &empty_overrides()).expect("resolves"); @@ -545,14 +545,14 @@ mod tests { resolved .tools .excluded - .contains(&crate::tools::names::MANAGE_PROJECT.to_string()), + .contains(&crate::tools::names::MANAGE_SESSION.to_string()), "SDE cannot regain management tools through stale or user-authored user_allowed_tools" ); assert!( !resolved .tools .restrict_to - .contains(&crate::tools::names::MANAGE_PROJECT.to_string()), + .contains(&crate::tools::names::MANAGE_SESSION.to_string()), "capability-blocked tools must not enter strict allow-lists either" ); } diff --git a/src-tauri/crates/agent-core/src/core/session/exec_modes.rs b/src-tauri/crates/agent-core/src/core/session/exec_modes.rs index e7457ba262..6139c21a0d 100644 --- a/src-tauri/crates/agent-core/src/core/session/exec_modes.rs +++ b/src-tauri/crates/agent-core/src/core/session/exec_modes.rs @@ -276,7 +276,7 @@ impl AgentExecMode { "### Review process\n", "1. Run `git diff ..HEAD` via `run_shell` to get the full diff (the base branch is provided in the task)\n", "2. Read changed files for full context around the diff hunks and search for callers, dependencies, or impact when the diff crosses symbol boundaries\n", - "3. Use the `manage_work_item` tool (action `read_item`) to read the linked work item for requirements and acceptance criteria\n", + "3. Read the linked work item for requirements and acceptance criteria with `org2-pm work show --output json` via `run_shell`\n", "4. Evaluate: correctness, edge cases, error handling, security, performance, test coverage, code style\n", "5. Produce your verdict in the EXACT structured format below\n\n", "### Output format (MANDATORY)\n", diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs index 0b53605d72..b79d27744e 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs @@ -246,6 +246,7 @@ pub(super) async fn materialize_org_member_sessions( project_slug: project_slug.clone(), work_item_id: work_item_id.clone(), agent_role: None, + product_mode: work_item_id.as_ref().map(|_| "project".to_string()), user_input: String::new(), ide_context: None, mode: agent_exec_mode.clone(), diff --git a/src-tauri/crates/agent-core/src/core/session/launch/mod.rs b/src-tauri/crates/agent-core/src/core/session/launch/mod.rs index 3bf7d5d79e..1bce6fc0f0 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/mod.rs @@ -152,6 +152,50 @@ pub(crate) struct AgentRunLaunchResult { pub project_slug: Option, pub work_item_id: Option, pub agent_role: Option, + pub product_mode: Option, +} + +/// Write the fail-closed session-context marker (design M6) into the +/// session workspace: `org2-pm` locks its identity to this file when +/// present, so an agent inside the workspace can never act as a human +/// or as another session. +pub fn write_agent_session_marker( + workspace_path: &str, + session_id: &str, + agent_definition_id: Option<&str>, + product_mode: Option<&str>, + project_slug: Option<&str>, + org_id: Option<&str>, +) { + if workspace_path.trim().is_empty() { + return; + } + let agent = agent_definition_id + .unwrap_or("os") + .trim_start_matches("builtin:"); + let capabilities: Vec<&str> = if product_mode == Some("project") { + vec!["work.read", "work.mutate", "routine.invoke", "project.mutate"] + } else { + vec!["work.read"] + }; + let marker = serde_json::json!({ + "apiVersion": "orgtrack/v1", + "sessionRef": format!("org2:{session_id}"), + "actor": format!("agent:{agent}"), + "productMode": product_mode, + "scope": project_slug, + "org": project_management::projects::io::resolve_local_org_scope(org_id), + "capabilities": capabilities, + "issuedAt": chrono::Utc::now().to_rfc3339(), + }); + let dir = std::path::Path::new(workspace_path).join(".orgii"); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let path = dir.join("agent_session_context.json"); + if let Ok(raw) = serde_json::to_string_pretty(&marker) { + let _ = std::fs::write(path, raw); + } } async fn generate_title_before_first_turn( @@ -498,6 +542,10 @@ pub(crate) async fn launch_rust_agent_run( .and_then(|value| value.as_str()) .ok_or("create_session_impl did not return sessionId")? .to_string(); + let resolved_product_mode = create_result + .get("productMode") + .and_then(|value| value.as_str()) + .map(str::to_string); if let (Some(project_slug_value), Some(work_item_id_value)) = (project_slug.as_deref(), work_item_id.as_deref()) @@ -710,6 +758,7 @@ pub(crate) async fn launch_rust_agent_run( project_slug, work_item_id, agent_role, + product_mode: resolved_product_mode.clone(), }); } @@ -742,6 +791,18 @@ pub(crate) async fn launch_rust_agent_run( } }; + write_agent_session_marker( + &prepared_workspace + .worktree_path + .clone() + .unwrap_or_else(|| workspace_path.clone()), + &session_id, + agent_definition_id.as_deref(), + resolved_product_mode.as_deref(), + project_slug.as_deref(), + Some(request.org_context.org_id.as_str()), + ); + if has_initial_content { let state_for_send = state.clone(); let session_id_for_send = session_id.clone(); @@ -837,5 +898,6 @@ pub(crate) async fn launch_rust_agent_run( project_slug, work_item_id, agent_role, + product_mode: resolved_product_mode, }) } diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs index 70bdd7ed57..af4749403c 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/ops.rs @@ -329,6 +329,20 @@ pub fn mark_stale_running_sessions_abandoned() -> SqliteResult { }) } +/// Statuses that settle a turn from the Work Item's point of view: the +/// linked-session mirror only runs once a session leaves the in-flight / +/// pre-flight set, so `linked_sessions` never flaps mid-turn. +fn settles_linked_session(status: SessionStatus) -> bool { + !matches!( + status, + SessionStatus::Pending + | SessionStatus::Running + | SessionStatus::WaitingForUser + | SessionStatus::WaitingForFunds + | SessionStatus::Paused + ) +} + /// Update session status. pub fn update_status(session_id: &str, status: SessionStatus) -> SqliteResult { let changed = with_sessions_writer(|| -> SqliteResult { @@ -342,6 +356,9 @@ pub fn update_status(session_id: &str, status: SessionStatus) -> SqliteResult, +) -> LinkedSession { + let status = linked_session_status(&session.status); + let completed_at = matches!( + status, + LinkedSessionStatus::Completed + | LinkedSessionStatus::Failed + | LinkedSessionStatus::Cancelled + ) + .then(|| session.updated_at.clone()); + LinkedSession { + session_id: session.session_id.clone(), + session_type: linked_session_type(&session.session_type), + agent_role: parse_agent_role(agent_role.or(session.agent_role.as_deref())), + started_at: session.created_at.clone(), + completed_at, + status, + cost_usd: 0.0, + total_tokens: session.total_tokens.max(0) as u64, + parent_session_id: session.parent_session_id.clone(), + sub_agent_name: None, + sub_agent_instance: None, + result_preview: session + .name + .is_empty() + .then(|| session.user_input.clone()) + .flatten() + .or_else(|| Some(session.name.clone())), + } +} + +fn linked_session_status(raw: &str) -> LinkedSessionStatus { + match SessionStatus::parse(raw) { + Some(SessionStatus::Failed) => LinkedSessionStatus::Failed, + Some(SessionStatus::Cancelled | SessionStatus::Abandoned | SessionStatus::Timeout) => { + LinkedSessionStatus::Cancelled + } + Some( + SessionStatus::Running | SessionStatus::WaitingForUser | SessionStatus::WaitingForFunds, + ) => LinkedSessionStatus::Running, + _ => LinkedSessionStatus::Completed, + } +} + +fn linked_session_type(session_type: &str) -> LinkedSessionType { + match session_type { + super::session_type::CODING + | super::session_type::GENERIC + | super::session_type::DESKTOP + | super::session_type::SUBAGENT + | super::session_type::ORG_MEMBER => LinkedSessionType::Native, + _ => LinkedSessionType::Native, + } +} + +pub(crate) fn parse_agent_role(raw: Option<&str>) -> AgentRole { + match raw.unwrap_or_default() { + "review" => AgentRole::Review, + "orchestrator" => AgentRole::Orchestrator, + "custom" => AgentRole::Custom, + "sub_agent" => AgentRole::SubAgent, + _ => AgentRole::Coding, + } +} + +fn apply_linked_session_upsert( + frontmatter: &mut WorkItemFrontmatter, + session: &UnifiedSessionRecord, + agent_role: Option<&str>, +) { + let linked = linked_session_from_record(session, agent_role); + match frontmatter + .linked_sessions + .iter_mut() + .find(|candidate| candidate.session_id == session.session_id) + { + Some(existing) => { + existing.session_type = linked.session_type; + existing.agent_role = linked.agent_role; + existing.status = linked.status; + existing.completed_at = linked.completed_at; + existing.total_tokens = linked.total_tokens; + if existing.result_preview.is_none() { + existing.result_preview = linked.result_preview; + } + } + None => frontmatter.linked_sessions.push(linked), + } + + // Older claim paths could append the same durable Session more than once + // when a barrier wake resumed it. Keep the first canonical row and drop + // stale duplicates whenever the authoritative Session record is mirrored. + let mut found = false; + frontmatter.linked_sessions.retain(|candidate| { + if candidate.session_id != session.session_id { + return true; + } + if found { + return false; + } + found = true; + true + }); + + if linked_session_status(&session.status) != LinkedSessionStatus::Running + && frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_deref()) + == Some(session.session_id.as_str()) + { + frontmatter.execution_lock = None; + } + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); +} + +pub(crate) fn upsert_linked_session_on_work_item( + project_slug: &str, + work_item_id: &str, + session: &UnifiedSessionRecord, + agent_role: Option<&str>, +) -> Result<(), String> { + project_management::projects::io::update_work_item_atomic( + project_slug, + work_item_id, + |frontmatter, _body| { + apply_linked_session_upsert(frontmatter, session, agent_role); + Ok(()) + }, + ) + .map(|_| ()) +} + +pub(crate) fn upsert_linked_session_on_standalone_work_item( + work_item_id: &str, + session: &UnifiedSessionRecord, + agent_role: Option<&str>, +) -> Result<(), String> { + let org_id = + project_management::projects::io::resolve_local_org_scope(session.org_id.as_deref()); + project_management::projects::io::update_standalone_work_item_atomic( + org_id.as_deref(), + work_item_id, + |frontmatter, _body| { + apply_linked_session_upsert(frontmatter, session, agent_role); + Ok(()) + }, + ) + .map(|_| ()) +} + +/// Mirror a settled session status onto the bound Work Item's +/// `linked_sessions` entry, then nudge the frontend to re-fetch. No-ops for +/// sessions without a Work Item link; failures are logged, never surfaced — +/// the session-status write this piggybacks on must not fail on PM state. +pub(crate) fn mirror_session_status_to_linked_work_item(session_id: &str) { + let record = match get_session(session_id) { + Ok(Some(record)) => record, + Ok(None) => return, + Err(error) => { + warn!( + session_id, + error = %error, + "[linked-session-mirror] failed to load session record" + ); + return; + } + }; + let Some(work_item_id) = record.work_item_id.clone() else { + return; + }; + let agent_role = record.agent_role.clone(); + let result = match record.project_slug.as_deref() { + Some(project_slug) => upsert_linked_session_on_work_item( + project_slug, + &work_item_id, + &record, + agent_role.as_deref(), + ), + None => upsert_linked_session_on_standalone_work_item( + &work_item_id, + &record, + agent_role.as_deref(), + ), + }; + match result { + Ok(()) => { + info!( + session_id, + work_item_id, + status = %record.status, + "[linked-session-mirror] mirrored session status onto work item" + ); + project_management::projects::events::notify_data_changed(); + } + Err(error) => warn!( + session_id, + work_item_id, + error = %error, + "[linked-session-mirror] failed to mirror session status" + ), + } +} + +#[cfg(test)] +mod tests { + use project_management::projects::types::{ + WorkItemExecutionLock, WorkItemExecutionLockReason, WorkItemFrontmatter, + }; + + use super::*; + + fn work_item() -> WorkItemFrontmatter { + serde_json::from_value(serde_json::json!({ + "id": "WI-0001", + "short_id": "WI-0001", + "title": "Linked session mirror", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("minimal work item") + } + + fn session(status: &str) -> UnifiedSessionRecord { + UnifiedSessionRecord { + session_id: "session-1".to_string(), + name: "Run".to_string(), + status: status.to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:01:00Z".to_string(), + session_type: super::super::session_type::GENERIC.to_string(), + ..UnifiedSessionRecord::default() + } + } + + #[test] + fn terminal_mirror_deduplicates_session_and_releases_its_lock() { + let mut frontmatter = work_item(); + let running = session("running"); + let linked = linked_session_from_record(&running, Some("custom")); + frontmatter.linked_sessions = vec![linked.clone(), linked]; + frontmatter.execution_lock = Some(WorkItemExecutionLock { + active_session_id: Some(running.session_id.clone()), + active_agent_org_run_id: None, + execution_target: None, + locked_at: Some(running.created_at.clone()), + lock_reason: Some(WorkItemExecutionLockReason::FollowUp), + locked_by_member_id: None, + }); + + let completed = session("completed"); + apply_linked_session_upsert(&mut frontmatter, &completed, Some("custom")); + + assert_eq!(frontmatter.linked_sessions.len(), 1); + assert_eq!( + frontmatter.linked_sessions[0].status, + LinkedSessionStatus::Completed + ); + assert!(frontmatter.execution_lock.is_none()); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index 61ce217263..d1a4ceb5de 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -11,6 +11,7 @@ //! - Message storage uses `agent_messages` (shared) mod crud; +pub(crate) mod linked_work_item; mod messages; mod sidebar; diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs b/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs index 48ec733ba1..8e3d0996ec 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/section_builders.rs @@ -251,10 +251,10 @@ pub(super) fn build_channel_behavioral_rules( ) -> String { let workspace_path = resolve_workspace_path_string(config); - // The PM guidance must track the effective tool surface: outside a - // Project session the product-mode policy strips `manage_project` / - // `manage_work_item`, and instructing the model to call tools it - // cannot see degrades every turn. + // The PM guidance must track the effective surface: outside a + // Project session `org2-pm` refuses mutations at the application + // boundary, and instructing the model to run commands that will be + // refused degrades every turn. let mut guidelines: Vec = vec![ "Always read files before editing them.".to_string(), "Prefer minimal, precise edits over rewriting entire files.".to_string(), @@ -265,12 +265,12 @@ pub(super) fn build_channel_behavioral_rules( "When asked to browse the web, use the `browser` tool freely. You can navigate to any website, interact with pages, fill forms, search, shop, or extract information. Do not refuse web tasks.".to_string(), ]; if include_pm_guidance { - guidelines.push("Projects and work items live in a global workspace store. Use `manage_project` (actions: list/read/create/update/delete/find/list_members/list_contributors) and `manage_work_item` (actions: list_items/read_item/create_item/update_item/delete_item/start_item) directly. Examples: \"find work items about authentication\", \"list all projects\", \"create a work item for Alice to fix the login bug in project X\".".to_string()); + guidelines.push("Projects and work items live in a global workspace store reachable from your shell through the `org2-pm` CLI: `org2-pm project list|show|find|create|update`, `org2-pm work list|show|create|update|transition|claim|note`. Always pass `--output json`. Examples: `org2-pm project find --query authentication`, `org2-pm work create --title \"Fix login bug\" --scope project-x`.".to_string()); } guidelines.push(format!("Your personal workspace is at `{workspace_path}`. Use it for tasks NOT related to any code repository — personal reminders, shopping lists, non-coding research, life tasks. Use the personal workspace path when creating personal projects/items. For coding or repo-related tasks, the default repo is used automatically. Unless the user explicitly asks to create a new project, check the Personal Workspace section above first — if a suitable project already exists, add the work item to it instead of creating a duplicate.")); if include_pm_guidance { guidelines.push("Before creating a work item, decide: is this task about the code in the active repository? Look at the repository description and project list above. If yes, use the default repo. If no (personal errand, general research, non-code task), route it to your personal workspace instead.".to_string()); - guidelines.push("When the user asks for a **periodic or recurring task** (e.g. \"check this website every morning\", \"send me a daily summary\", \"remind me every Monday\"), always create a **work item with a schedule** via `manage_work_item(action=create_item)`. Set a `schedule` field with a cron expression (e.g. `0 9 * * *` for daily at 9 AM, `0 9 * * 1` for every Monday). Do NOT use one-off reminders or rely on memory for repeating tasks.".to_string()); + guidelines.push("When the user asks for a **periodic or recurring task** (e.g. \"check this website every morning\", \"send me a daily summary\", \"remind me every Monday\"), always create a **work item with a schedule**: `org2-pm work create --title ... --schedule-cron \"0 9 * * *\"` (daily at 9 AM; `0 9 * * 1` = every Monday). Do NOT use one-off reminders or rely on memory for repeating tasks.".to_string()); } guidelines.push("Use `send_to_inbox` to deliver results, summaries, or notifications to the user. Whenever you complete a task that produces output the user should review later (reports, research findings, periodic check results), send a summary to the inbox. Do not only print results in chat — the user may not be watching.".to_string()); guidelines.push("Agent and organization management lives in `~/.orgii/`. Use `manage_agent_def` directly (actions: list/get/create/update/remove/list_orgs/get_org/create_org/update_org/remove_org) to inspect or modify the user's library of custom agents and orgs. Examples: \"create an agent called QA-Bot that runs tests\", \"list all agent organizations\", \"disable the browser tool for my Reviewer agent\".".to_string()); @@ -534,11 +534,11 @@ pub(super) fn build_task_routing_section(include_pm_guidance: bool) -> String { - Casual requests (open app, search the web, run a command)\n\ - Simple file edits (change a config value, update an env var)\n\n" .to_string(); - // Only Project sessions expose `manage_work_item`; elsewhere the - // create-a-work-item branch would point at a policy-denied tool. + // Only Project sessions may mutate the work system; elsewhere the + // create-a-work-item branch would point at a refused command. if include_pm_guidance { section.push_str( - "**Create a work item (via `manage_work_item(action=create_item)`) when:**\n\ + "**Create a work item (via `org2-pm work create`) when:**\n\ - The task needs a full coding workflow (branch, tests, commit, PR)\n\ - The user explicitly asks to track/schedule something\n\ - The task requires long async execution the user wants to monitor\n\ diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/sections.rs b/src-tauri/crates/agent-core/src/core/session/prompt/sections.rs index 53d87e479c..0dd4570da8 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/sections.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/sections.rs @@ -290,12 +290,12 @@ impl PromptSection for BehavioralRulesSection { } fn render(&self, ctx: &PromptCtx) -> Option { if ctx.is_channel_session { - // Tool summaries are already policy-filtered (product-mode - // layer included), so the PM guidance tracks the surface the - // model can actually call this turn. + // Only Project sessions may mutate the work system through + // `org2-pm`; the guidance tracks that application-boundary + // gate rather than a tool surface. Some(build_channel_behavioral_rules( ctx.config, - ctx.has_tool(tool_names::MANAGE_WORK_ITEM), + ctx.config.product_mode.as_deref() == Some("project"), )) } else if ctx.config.workspace.is_some() { Some(sde_behavioral_rules()) @@ -749,7 +749,7 @@ impl PromptSection for TaskRoutingSection { } fn render(&self, ctx: &PromptCtx) -> Option { Some(build_task_routing_section( - ctx.has_tool(tool_names::MANAGE_WORK_ITEM), + ctx.config.product_mode.as_deref() == Some("project"), )) } } diff --git a/src-tauri/crates/agent-core/src/core/session/tests/exec_modes_tests.rs b/src-tauri/crates/agent-core/src/core/session/tests/exec_modes_tests.rs index 678aa5ca74..3ed0e7fdbf 100644 --- a/src-tauri/crates/agent-core/src/core/session/tests/exec_modes_tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/tests/exec_modes_tests.rs @@ -150,10 +150,6 @@ fn review_mode_uses_deny_delta() { deny.contains(&"create_plan".to_string()), "Review must deny create_plan" ); - assert!( - !deny.contains(&"manage_work_item".to_string()), - "Review must allow manage_work_item" - ); assert!( !deny.contains(&"read_file".to_string()), "Review must allow read_file" @@ -449,8 +445,6 @@ fn mode_prompts_never_reference_tools_their_policy_denies() { crate::tools::names::TOOL_SEARCH, crate::tools::names::CREATE_PLAN, crate::tools::names::SUGGEST_MODE_SWITCH, - crate::tools::names::MANAGE_WORK_ITEM, - crate::tools::names::MANAGE_PROJECT, crate::tools::names::MANAGE_AGENT_DEF, crate::tools::names::SEND_TO_INBOX, crate::tools::names::CONTROL_DESKTOP_WITH_PEEKABOO, diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs index 6d25e5565d..f1f07f30d3 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/mod.rs @@ -26,6 +26,7 @@ pub(super) mod member_idle; mod post_turn_dispatch; pub(super) mod prefetch; mod prompt; +mod receipt_fallback; use serde_json::Value; use std::sync::atomic::Ordering; @@ -253,21 +254,12 @@ impl UnifiedMessageProcessor { } /// Tool policy actually used for this turn, including the exec-mode - /// and product-mode overlays (`orgtrack/v1` §5.1: only Project - /// sessions expose the WorkItem/Routine mutation tools). + /// overlay. Product mode is not a tool overlay: `org2-pm` enforces + /// it at the application boundary via the injected ORGII_MODE. fn effective_tool_policy(&self) -> Arc { - let product_mode = tokio::task::block_in_place(|| { - unified_persistence::get_session(&self.session.id) - .ok() - .flatten() - .and_then(|record| record.product_mode) - }); match self.agent_mode { - Some(mode) => Arc::new(self.policy.with_modes(mode, product_mode.as_deref())), - None => match ResolvedToolPolicy::product_mode_layer(product_mode.as_deref()) { - Some(layer) => Arc::new(self.policy.with_extra_layer(layer)), - None => Arc::clone(&self.policy), - }, + Some(mode) => Arc::new(self.policy.with_exec_mode(mode)), + None => Arc::clone(&self.policy), } } @@ -514,6 +506,9 @@ impl UnifiedMessageProcessor { .turn_id .clone() .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + // Anchor for the receipt-fallback audit window: CLI writes during + // this turn carry occurred_at >= this instant. + let turn_started_at_ms = chrono::Utc::now().timestamp_millis(); // 0b. Restore persisted SM state on first turn (lazy init) if self.sm_config.enabled { @@ -1034,6 +1029,7 @@ impl UnifiedMessageProcessor { result: &result, tool_calls_count, final_turn_state, + turn_started_at_ms, }) .await; diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs index 5ee616a8ae..dcf5da6eba 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/post_turn_dispatch.rs @@ -38,6 +38,7 @@ pub(super) struct PostTurnInputs<'a> { pub result: &'a TurnResult, pub tool_calls_count: u32, pub final_turn_state: DialogTurnState, + pub turn_started_at_ms: i64, } impl UnifiedMessageProcessor { @@ -52,6 +53,7 @@ impl UnifiedMessageProcessor { result, tool_calls_count, final_turn_state, + turn_started_at_ms, } = inputs; // 9. Broadcast completion FIRST — user sees "done" immediately. @@ -153,6 +155,18 @@ impl UnifiedMessageProcessor { } } + // 9d½. Work Item receipt fallback — when a linked-item turn ends + // with no `work.note` from this agent, synthesize the Discussion + // receipt from the final output (fire-and-forget; gating on the + // session record happens inside the spawned task). + if final_turn_state == DialogTurnState::Completed && !result.is_stream_error { + self.spawn_work_item_receipt_fallback( + session_id, + response_text, + turn_started_at_ms, + ); + } + // 9e. Goal continuation loop (Ralph loop) — judge the completed // turn against the standing goal and enqueue a continuation when // the presence policy enables it (Invisible / custom autonomous diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs index e534980ed0..7b6a3c7548 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/prompt.rs @@ -19,27 +19,85 @@ use crate::core::session::prompt::cache::SkillListingCacheKey; use crate::core::session::prompt::sections::build_agent_org_context_section_with_task_snapshot; use crate::core::session::types::{SystemPromptConfig, ToolSummary}; +fn render_orgtrack_cli_brief(product_mode: Option<&str>, project_slug: Option<&str>) -> Option { + if product_mode != Some("project") { + return None; + } + let scope_line = match project_slug { + Some(slug) => format!("Your scope is injected (ORGII_SCOPE={slug}); omit --scope."), + None => "Pass --scope when the target project is known.".to_string(), + }; + Some(format!( + "## Work Management (org2-pm)\n\n\ + The work system is also reachable from your shell through the `org2-pm` CLI. \ + Use `--output json`; run `org2-pm --help` or `org2-pm --help` for anything beyond the core set.\n\n\ + - `org2-pm work show ` / `org2-pm work list [--status ] [--ready]`\n\ + - `org2-pm work create --title \"...\" [--body ...] [--parent ]`\n\ + - `org2-pm work update [--title ...] [--body ...|--body-file ] [--expected-revision N]`\n\ + - `org2-pm work transition --to --reason \"...\"`\n\ + - `org2-pm work claim ` / `org2-pm work release `\n\ + - `org2-pm work note --kind --body \"...\"`\n\n\ + For multi-line or code-bearing bodies, write the text to a file in your working \ + directory and pass `--body-file ` — inline --body goes through the shell, \ + which mangles backticks and $().\n\n\ + Rules:\n\ + - Your session identity is injected; never pass --actor yourself.\n\ + - {}\n\ + - Split the work into sub items (`work create --parent `) whenever the plan has \ + more than one independently completable step; keep single-step work on the item itself.\n\ + - When you finish working an item, post exactly ONE receipt on it — \ + `org2-pm work note --kind progress --body \"...\"` — stating the outcome, not the \ + process. Do NOT post progress updates or plans as notes while you work. Chat text is \ + not delivered to the work item; only notes land on it.\n\ + - If blocked, run `org2-pm work transition --to blocked --reason \"...\"` and post \ + one note explaining the blocker.\n\ + - Your harness's built-in planning tools (task lists, todos) are local scratch state — \ + they do NOT update the work system. Only `org2-pm` writes count.", + scope_line + )) +} + fn render_linked_work_item_context(work_item_id: &str, project_slug: Option<&str>) -> String { - let scope_instruction = match project_slug { - Some(project_slug) => format!( - "Set `project_slug` to {} on every `manage_work_item` call for this item.", - serde_json::to_string(project_slug).expect("project slug is JSON serializable") + let (scope_instruction, standalone_flag) = match project_slug { + Some(project_slug) => ( + format!( + "Pass `--scope {}` on every `org2-pm work` command for this item (or rely on the injected ORGII_SCOPE).", + project_slug + ), + "", + ), + None => ( + "This item is standalone (no project): pass `--standalone` on every `org2-pm work` command for it and omit `--scope`." + .to_string(), + " --standalone", ), - None => "Omit `project_slug` on every `manage_work_item` call for this standalone item." - .to_string(), }; format!( "## Linked Work Item\n\n\ - This planning session is already linked to Work Item `short_id` {}. \ + This planning session is already linked to Work Item {}. \ {} \ + Use the `org2-pm` CLI from your shell to read and fill it: `org2-pm work show {}` to read, \ + `org2-pm work update {} --title \"...\" --body \"...\"` to fill or refine the draft. \ + ⚠️ Every deliverable of this session MUST land on the Work Item through `org2-pm` — \ + whatever the user asks for here IS this item's content: write it into the item body \ + (and sub items via `org2-pm work create{} --parent {}` when the work has multiple \ + independently completable steps), then post one receipt with \ + `org2-pm work note {} --kind progress --body \"...\"`. \ + Chat replies are conversation, not delivery; a turn that ends with the work only in \ + chat has delivered nothing, even when the content itself is correct. \ Scope rule: the linked item is THIS session's original deliverable. \ When the user iterates on that same request (refine, expand, correct, retitle), update the linked draft instead of creating a duplicate. \ - When the user asks for a NEW or additional Work Item — a different topic, an example, \"another one\" — create a fresh item with `manage_work_item(action=create_item)` and leave the linked item untouched; never repurpose it by overwriting its title and body with unrelated content. \ - Keep the current session linked after every update. \ + When the user asks for a NEW or additional Work Item — a different topic, an example, \"another one\" — create a fresh item with `org2-pm work create{} --title \"...\"` and leave the linked item untouched; never repurpose it by overwriting its title and body with unrelated content. \ Apply all of this silently: never announce the linkage, ids, or drafting mechanics to the user (no \"this session is already linked to…\") — just acknowledge the request and do the work.", serde_json::to_string(work_item_id).expect("work item id is JSON serializable"), scope_instruction, + standalone_flag, + standalone_flag, + standalone_flag, + serde_json::to_string(work_item_id).expect("work item id is JSON serializable"), + standalone_flag, + standalone_flag, ) } @@ -68,6 +126,13 @@ impl UnifiedMessageProcessor { .and_then(|ctx| ctx.user_profile.clone()) .or_else(crate::interaction::profile_state::global_profile); + let product_mode = tokio::task::block_in_place(|| { + super::unified_persistence::get_session(session_id) + .ok() + .flatten() + .and_then(|record| record.product_mode) + }); + let prompt_config = SystemPromptConfig { model: self.runtime.model.clone(), agent_id: self.agent_id.clone(), @@ -80,6 +145,7 @@ impl UnifiedMessageProcessor { channel: self.channel.clone(), chat_id: self.chat_id.clone(), agent_mode: self.agent_mode, + product_mode, ide_context: self.ide_context.clone(), user_presence, user_profile, @@ -210,6 +276,12 @@ impl UnifiedMessageProcessor { )); } } + if let Some(brief) = render_orgtrack_cli_brief( + session.product_mode.as_deref(), + session.project_slug.as_deref(), + ) { + dynamic_sections.push(brief); + } } Ok(None) => {} Err(error) => warn!( @@ -490,8 +562,9 @@ mod linked_work_item_context_tests { fn renders_project_scope_without_ambiguous_discovery() { let prompt = render_linked_work_item_context("AUTH-0042", Some("auth-core")); - assert!(prompt.contains("`short_id` \"AUTH-0042\"")); - assert!(prompt.contains("Set `project_slug` to \"auth-core\"")); + assert!(prompt.contains("Work Item \"AUTH-0042\"")); + assert!(prompt.contains("--scope auth-core")); + assert!(!prompt.contains("--standalone")); assert!(prompt.contains("instead of creating a duplicate")); } @@ -499,8 +572,10 @@ mod linked_work_item_context_tests { fn renders_standalone_scope_without_fake_project() { let prompt = render_linked_work_item_context("ORG-0042", None); - assert!(prompt.contains("`short_id` \"ORG-0042\"")); - assert!(prompt.contains("Omit `project_slug`")); + assert!(prompt.contains("Work Item \"ORG-0042\"")); + assert!(prompt.contains("`org2-pm work show --standalone`")); + assert!(prompt.contains("`org2-pm work create --standalone")); + assert!(!prompt.contains("--scope auth-core")); assert!(!prompt.contains("personal-org")); } } diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs new file mode 100644 index 0000000000..796e43bdcf --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/receipt_fallback.rs @@ -0,0 +1,154 @@ +//! Turn-end Work Item receipt fallback. +//! +//! The linked-work-item delivery mandate tells the agent every deliverable +//! must land on the Work Item through `org2-pm`, with exactly one +//! Discussion receipt per turn. When a turn completes without any +//! `work.note` from this agent on the linked item, the final assistant +//! message is the only record of the outcome — and it lives in chat, which +//! the Work Item Discussion never sees. This fallback synthesizes that +//! missing receipt from the final output so the Discussion trail stays +//! complete even when the model forgets the CLI. +//! +//! Mirrors the injection gate in `prompt.rs` (`agent_role == "custom"` with +//! a linked `work_item_id`) and the actor derivation in the exec injection +//! kit (`agent:{definition}` after the `builtin:` trim), so the audit check +//! sees the same actor id the CLI writes carry — including subagent writes, +//! which root-walk to the same top-level session actor. + +use tracing::{info, warn}; + +use super::{unified_persistence, UnifiedMessageProcessor}; + +const MIN_RECEIPT_CHARS: usize = 40; +const MAX_RECEIPT_CHARS: usize = 8_000; + +/// Trim, suppress trivial acks, and clip on a char boundary. `None` means +/// the output is too small to be worth a synthesized receipt. +pub(super) fn receipt_body(response_text: &str) -> Option { + let trimmed = response_text.trim(); + if trimmed.chars().count() < MIN_RECEIPT_CHARS { + return None; + } + let clipped: String = trimmed.chars().take(MAX_RECEIPT_CHARS).collect(); + Some(format!("(auto) {clipped}")) +} + +impl UnifiedMessageProcessor { + /// Fire-and-forget synthesis of the missing Discussion receipt. + /// Failures are logged, never surfaced to the turn. + pub(super) fn spawn_work_item_receipt_fallback( + &self, + session_id: &str, + response_text: &str, + turn_started_at_ms: i64, + ) { + let Some(body) = receipt_body(response_text) else { + return; + }; + let session_id = session_id.to_string(); + tokio::task::spawn_blocking(move || { + if let Err(error) = + synthesize_receipt_blocking(&session_id, &body, turn_started_at_ms) + { + warn!( + session_id, + error = %error, + "[receipt_fallback] failed to synthesize Work Item receipt" + ); + } + }); + } +} + +fn synthesize_receipt_blocking( + session_id: &str, + body: &str, + turn_started_at_ms: i64, +) -> Result<(), String> { + let Some(record) = unified_persistence::get_session(session_id) + .map_err(|err| format!("load session: {err}"))? + else { + return Ok(()); + }; + if record.agent_role.as_deref() != Some("custom") { + return Ok(()); + } + let Some(work_item_id) = record.work_item_id.as_deref() else { + return Ok(()); + }; + + let agent = record + .agent_definition_id + .as_deref() + .unwrap_or("os") + .trim_start_matches("builtin:") + .to_string(); + let actor_id = format!("agent:{agent}"); + + if project_management::work_service::work_item_noted_by_actor_since( + work_item_id, + &actor_id, + turn_started_at_ms, + )? { + return Ok(()); + } + + let actor = project_management::projects::types::WorkItemMutationActor { + id: actor_id.clone(), + name: agent, + }; + match record.project_slug.as_deref() { + Some(project_slug) => project_management::work_service::note_project_work_item( + project_slug, + work_item_id, + "progress", + body, + Some(&actor), + )?, + None => { + let org_id = project_management::projects::io::resolve_local_org_scope( + record.org_id.as_deref(), + ); + project_management::work_service::note_standalone_work_item( + org_id.as_deref(), + work_item_id, + "progress", + body, + Some(&actor), + )? + } + } + info!( + session_id, + work_item_id, + actor_id, + "[receipt_fallback] synthesized turn-end Discussion receipt" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::receipt_body; + + #[test] + fn suppresses_trivial_output() { + assert_eq!(receipt_body(" Done. "), None); + assert_eq!(receipt_body(""), None); + } + + #[test] + fn keeps_substantial_output_with_auto_marker() { + let text = "Implemented the export flow and verified the generated CSV against the fixture data."; + let body = receipt_body(text).expect("substantial output"); + assert!(body.starts_with("(auto) Implemented")); + assert!(body.contains("fixture data.")); + } + + #[test] + fn clips_to_max_chars_on_char_boundary() { + let text = "汉".repeat(9_000); + let body = receipt_body(&text).expect("substantial output"); + assert_eq!(body.chars().count(), "(auto) ".chars().count() + 8_000); + } +} diff --git a/src-tauri/crates/agent-core/src/core/session/types/context.rs b/src-tauri/crates/agent-core/src/core/session/types/context.rs index 3146dc0f88..58fc9b2512 100644 --- a/src-tauri/crates/agent-core/src/core/session/types/context.rs +++ b/src-tauri/crates/agent-core/src/core/session/types/context.rs @@ -282,6 +282,10 @@ pub struct SystemPromptConfig { pub channel: Option, pub chat_id: Option, pub agent_mode: Option, + /// Product mode (`orgtrack/v1` §5.2) of the session. Gates the PM + /// guidance sections: only `project` sessions are told to mutate the + /// work system through `org2-pm`. + pub product_mode: Option, pub ide_context: Option, /// User presence snapshot (online / invisible / away). Plumbed out /// of [`IdeContext::user_presence`] at prompt-build time so the diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent.rs index 44208d5615..a6878fcfee 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent.rs @@ -1,9 +1,9 @@ //! Unified agent / orchestration tool entries (visible to the LLM). //! -//! `manage_project`, `manage_work_item`, and `manage_agent_def` are -//! first-class parent-agent tools. OS Agent ships with them on by default; -//! custom coordinator agents can opt in when their definition declares the -//! management capability. +//! `manage_agent_def` is a first-class parent-agent tool. OS Agent ships +//! with it on by default; custom coordinator agents can opt in when their +//! definition declares the management capability. Work-item / project +//! management is not a tool surface — agents use the `org2-pm` CLI. use super::aliases::*; use super::macros::action_sub; @@ -37,86 +37,6 @@ pub(super) static TOOLS: &[ToolEntry] = &[ required_capability: CapOrch, ..DEFAULT_TOOL_ENTRY }, - ToolEntry { - name: tool_names::MANAGE_PROJECT, - description: "Manage projects in the global project store.", - description_detail: "CRUD over projects (list, read, create, update, delete) and project members (list_members, list_contributors). Projects and work items live in a global workspace store independent of any single chat session.", - category: tool_categories::PROJECT, - icon_id: "layout-list", - simulator_app: AppProject, - app_subtool: SubProject, - chat_block: CbFallback, - label_running: "tools.manageProjectRunning", - label_done: "tools.manageProjectDone", - label_failed: "tools.manageProjectFailed", - actions: &[ - action_sub!("list", "List all projects", SubProject, labels: "tools.manageProjectListRunning", "tools.manageProjectListDone", "tools.manageProjectListFailed"), - action_sub!("read", "Read a project's metadata", SubProject, labels: "tools.manageProjectReadRunning", "tools.manageProjectReadDone", "tools.manageProjectReadFailed"), - action_sub!("create", "Create a new project", SubProject, labels: "tools.manageProjectCreateRunning", "tools.manageProjectCreateDone", "tools.manageProjectCreateFailed"), - action_sub!("update", "Update project metadata", SubProject, labels: "tools.manageProjectUpdateRunning", "tools.manageProjectUpdateDone", "tools.manageProjectUpdateFailed"), - action_sub!( - "delete", - "Delete a project", - SubProject, - labels: "tools.deleteProjectRunning", "tools.deleteProjectDone", "tools.deleteProjectFailed" - ), - action_sub!("list_items", "List work items on a project", SubProject, labels: "tools.manageProjectListItemsRunning", "tools.manageProjectListItemsDone", "tools.manageProjectListItemsFailed"), - action_sub!("read_item", "Read a work item", SubProject, labels: "tools.manageProjectReadItemRunning", "tools.manageProjectReadItemDone", "tools.manageProjectReadItemFailed"), - action_sub!("create_item", "Create a new work item", SubProject, labels: "tools.manageProjectCreateItemRunning", "tools.manageProjectCreateItemDone", "tools.manageProjectCreateItemFailed"), - action_sub!("update_item", "Update a work item", SubProject, labels: "tools.manageProjectUpdateItemRunning", "tools.manageProjectUpdateItemDone", "tools.manageProjectUpdateItemFailed"), - action_sub!( - "delete_item", - "Delete a work item", - SubProject, - labels: "tools.deleteWorkItemRunning", "tools.deleteWorkItemDone", "tools.deleteWorkItemFailed" - ), - action_sub!("start_item", "Dispatch a work item to the SDE agent", SubProject, labels: "tools.manageProjectStartItemRunning", "tools.manageProjectStartItemDone", "tools.manageProjectStartItemFailed"), - action_sub!("find", "Search projects/work items globally", SubProject, labels: "tools.manageProjectFindRunning", "tools.manageProjectFindDone", "tools.manageProjectFindFailed"), - action_sub!("list_members", "List team members", SubProject, labels: "tools.manageProjectListMembersRunning", "tools.manageProjectListMembersDone", "tools.manageProjectListMembersFailed"), - action_sub!("list_contributors", "Sync and list git contributors", SubProject, labels: "tools.manageProjectListContributorsRunning", "tools.manageProjectListContributorsDone", "tools.manageProjectListContributorsFailed"), - ], - required_capability: CapManagement, - ..DEFAULT_TOOL_ENTRY - }, - ToolEntry { - name: tool_names::MANAGE_WORK_ITEM, - description: "Manage standalone or project-scoped work items (tasks, issues, bugs).", - description_detail: "CRUD over Work Items (list/read/create/update/delete), link or unlink sessions, and batch multiple operations. Omit project_slug for standalone Work Items; set project_slug per batch item for multi-project creation.", - category: tool_categories::PROJECT, - icon_id: "layout-list", - simulator_app: AppProject, - app_subtool: SubProject, - chat_block: CbFallback, - label_running: "tools.manageWorkItemRunning", - label_done: "tools.manageWorkItemDone", - label_failed: "tools.manageWorkItemFailed", - actions: &[ - action_sub!("list_items", "List work items on a project", SubProject, labels: "tools.manageWorkItemListItemsRunning", "tools.manageWorkItemListItemsDone", "tools.manageWorkItemListItemsFailed"), - action_sub!("read_item", "Read a work item", SubProject, labels: "tools.manageWorkItemReadItemRunning", "tools.manageWorkItemReadItemDone", "tools.manageWorkItemReadItemFailed"), - action_sub!("create_item", "Create a new work item", SubProject, labels: "tools.manageWorkItemCreateItemRunning", "tools.manageWorkItemCreateItemDone", "tools.manageWorkItemCreateItemFailed"), - action_sub!("update_item", "Update a work item", SubProject, labels: "tools.manageWorkItemUpdateItemRunning", "tools.manageWorkItemUpdateItemDone", "tools.manageWorkItemUpdateItemFailed"), - action_sub!( - "delete_item", - "Delete a work item", - SubProject, - labels: "tools.deleteWorkItemRunning", "tools.deleteWorkItemDone", "tools.deleteWorkItemFailed" - ), - action_sub!("list", "List work items on a project", SubProject, labels: "tools.manageWorkItemListRunning", "tools.manageWorkItemListDone", "tools.manageWorkItemListFailed"), - action_sub!("read", "Read a work item", SubProject, labels: "tools.manageWorkItemReadRunning", "tools.manageWorkItemReadDone", "tools.manageWorkItemReadFailed"), - action_sub!("create", "Create a new work item", SubProject, labels: "tools.manageWorkItemCreateRunning", "tools.manageWorkItemCreateDone", "tools.manageWorkItemCreateFailed"), - action_sub!("update", "Update a work item", SubProject, labels: "tools.manageWorkItemUpdateRunning", "tools.manageWorkItemUpdateDone", "tools.manageWorkItemUpdateFailed"), - action_sub!( - "delete", - "Delete a work item", - SubProject, - labels: "tools.deleteWorkItemRunning", "tools.deleteWorkItemDone", "tools.deleteWorkItemFailed" - ), - action_sub!("start_item", "Dispatch a work item to the SDE agent", SubProject, labels: "tools.manageWorkItemStartItemRunning", "tools.manageWorkItemStartItemDone", "tools.manageWorkItemStartItemFailed"), - action_sub!("batch", "Run multiple standalone or project-scoped Work Item operations", SubProject, labels: "tools.manageWorkItemRunning", "tools.manageWorkItemDone", "tools.manageWorkItemFailed"), - ], - required_capability: CapManagement, - ..DEFAULT_TOOL_ENTRY - }, ToolEntry { name: tool_names::MANAGE_AGENT_DEF, description: "Manage custom agent definitions and agent organizations.", diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs index 5fd9ab6413..f05c0835f3 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/aliases.rs @@ -12,7 +12,7 @@ pub(super) use crate::definitions::capabilities::RequiredCapability; pub(super) use AppSubtool::{ Browser as SubBrowser, Explore, FileRead, FileWrite, Glob as SubGlob, InternalBrowser as SubInternalBrowser, Message, OtherInteractions, OtherTool, - Project as SubProject, Search as SubSearch, Shell, Subagent as SubSubagent, + Search as SubSearch, Shell, Subagent as SubSubagent, Thinking as SubThinking, Todo as SubTodo, }; // ChatBlock aliases — one per actual React block component. @@ -32,7 +32,7 @@ pub(super) use HumanToolKey::{ }; pub(super) use SimulatorApp::{ BackgroundTasks as AppBackgroundTasks, Browser as AppBrowser, Canvas as AppCanvas, - Channels as AppChannels, CodeEditor as AppCode, ProjectManager as AppProject, + Channels as AppChannels, CodeEditor as AppCode, }; pub(super) use ToolDisplayBehavior::{ Instant as DisplayInstant, Stream as DisplayStream, WaitForResult as DisplayWaitForResult, diff --git a/src-tauri/crates/agent-core/src/core/tools/categories.rs b/src-tauri/crates/agent-core/src/core/tools/categories.rs index 4a3beaaa3e..21ed7d9424 100644 --- a/src-tauri/crates/agent-core/src/core/tools/categories.rs +++ b/src-tauri/crates/agent-core/src/core/tools/categories.rs @@ -50,8 +50,7 @@ pub const CHANNEL: &str = "channel"; /// Cross-channel messaging (`send_message`, `send_to_inbox`). pub const COMMS: &str = "comms"; -/// Project / work-item management tools (`manage_project`, -/// `manage_work_item`, `setup_repo`). +/// Project-adjacent tools (`setup_repo`). pub const PROJECT: &str = "project"; /// Knowledge-graph node management (`manage_nodes`). diff --git a/src-tauri/crates/agent-core/src/core/tools/defaults.rs b/src-tauri/crates/agent-core/src/core/tools/defaults.rs index 0565776c3e..47ab31bc36 100644 --- a/src-tauri/crates/agent-core/src/core/tools/defaults.rs +++ b/src-tauri/crates/agent-core/src/core/tools/defaults.rs @@ -51,8 +51,15 @@ pub const SUBAGENT_FORBIDDEN_TOOLS: &[&str] = &[ /// /// `task` and `spawn_sub_agent` were both early names for what is now /// the singular `agent` tool — see commit history of -/// `core/tools/impls/orchestration/agent`. -pub const SUBAGENT_RETIRED_TOOL_ALIASES: &[&str] = &["task", "spawn_sub_agent"]; +/// `core/tools/impls/orchestration/agent`. `manage_project` and +/// `manage_work_item` were the typed PM tools retired in favour of the +/// `org2-pm` CLI (orgtrack/v1 agent-plane unification). +pub const SUBAGENT_RETIRED_TOOL_ALIASES: &[&str] = &[ + "task", + "spawn_sub_agent", + tool_names::MANAGE_PROJECT, + tool_names::MANAGE_WORK_ITEM, +]; const NON_BUILTIN_REGISTERED_TOOLS: &[&str] = &[ tool_names::LIST_KNOWN_WORKSPACES, @@ -99,8 +106,6 @@ pub fn supported_agents_for(tool_name: &str) -> Vec { match tool_name { tool_names::CONTROL_ORGII | tool_names::MANAGE_SESSION - | tool_names::MANAGE_PROJECT - | tool_names::MANAGE_WORK_ITEM | tool_names::MANAGE_AGENT_DEF => vec![AgentKind::Os, AgentKind::Custom], _ => vec![AgentKind::Os, AgentKind::Sde, AgentKind::Custom], } @@ -180,12 +185,7 @@ mod tests { #[test] fn management_tools_are_not_advertised_for_sde_workers() { - for tool in [ - tool_names::MANAGE_SESSION, - tool_names::MANAGE_PROJECT, - tool_names::MANAGE_WORK_ITEM, - tool_names::MANAGE_AGENT_DEF, - ] { + for tool in [tool_names::MANAGE_SESSION, tool_names::MANAGE_AGENT_DEF] { let agents = supported_agents_for(tool); assert!(agents.contains(&AgentKind::Os), "{tool} on OS"); assert!(!agents.contains(&AgentKind::Sde), "{tool} not on SDE"); @@ -303,12 +303,7 @@ mod tests { excluded.contains(&tool_names::CONTROL_INTERNAL_BROWSER.to_string()), "SDE should exclude internal browser automation by default" ); - for tool in [ - tool_names::MANAGE_SESSION, - tool_names::MANAGE_PROJECT, - tool_names::MANAGE_WORK_ITEM, - tool_names::MANAGE_AGENT_DEF, - ] { + for tool in [tool_names::MANAGE_SESSION, tool_names::MANAGE_AGENT_DEF] { assert!( excluded.contains(&tool.to_string()), "SDE should exclude management tool {tool} by default" diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs index 78e6e14509..e853a426f3 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/exec/subprocess.rs @@ -281,6 +281,71 @@ async fn terminate_child_tree(_pid: u32, child: &mut tokio::process::Child) { } } +/// Inject the orgtrack identity for agent-plane CLI calls (design M6): +/// `org2-pm` resolves actor/session/scope/mode from these instead of +/// trusting model-typed flags, and the host binary directory rides the +/// front of PATH so the bundled CLI always matches the app version. +/// +/// Subagents resolve to their top-level ancestor: the workspace marker +/// (`agent_session_context.json`) is bound to the session that owns the +/// workspace, so the injected identity must match it or every CLI call +/// from a worker sharing that workspace would be refused as spoofing. +fn configure_orgtrack_environment(cmd: &mut tokio::process::Command, session_id: &str) { + let mut session_id = session_id.to_string(); + let mut record = match crate::session::persistence::get_session(&session_id) { + Ok(Some(record)) => record, + _ => return, + }; + for _ in 0..16 { + let Some(parent_id) = record.parent_session_id.clone() else { + break; + }; + match crate::session::persistence::get_session(&parent_id) { + Ok(Some(parent)) => { + session_id = parent_id; + record = parent; + } + _ => break, + } + } + cmd.env("ORGII_SESSION_REF", format!("org2:{session_id}")); + let agent = record + .agent_definition_id + .as_deref() + .unwrap_or("os") + .trim_start_matches("builtin:") + .to_string(); + cmd.env("ORGII_ACTOR", format!("agent:{agent}")); + if let Some(mode) = record.product_mode.as_deref() { + cmd.env("ORGII_MODE", mode); + } + if let Some(slug) = record.project_slug.as_deref() { + cmd.env("ORGII_SCOPE", slug); + } + if let Some(org) = + project_management::projects::io::resolve_local_org_scope(record.org_id.as_deref()) + { + cmd.env("ORGII_ORG", org); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let base_path = cmd + .as_std() + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PATH")) + .and_then(|(_, value)| value.map(|value| value.to_os_string())) + .or_else(|| std::env::var_os("PATH")); + let mut paths = vec![dir.to_path_buf()]; + if let Some(existing_path) = base_path { + paths.extend(std::env::split_paths(&existing_path)); + } + if let Ok(joined_path) = std::env::join_paths(paths) { + cmd.env("PATH", joined_path); + } + } + } +} + fn configure_git_environment(cmd: &mut tokio::process::Command) { let resolved = match git::resolved_git_executable_details() { Ok(resolved) => resolved, @@ -560,6 +625,7 @@ pub async fn execute_via_command( if let Ok(path) = std::env::var("PATH") { cmd.env("PATH", path); } + configure_orgtrack_environment(&mut cmd, &identity.session_id); cmd.arg(command) .current_dir(&work_dir) .stdin(Stdio::null()) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/mod.rs index 44cb7bf4d0..b45159086b 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/mod.rs @@ -16,8 +16,10 @@ //! - [`nodes`] — compute-node CRUD (`manage_nodes`) //! - [`orchestration`] — subagent invocation, session management, mode/next-step suggestions, ask_user_questions //! - [`plan_mode`] — plan-mode planning tool (`create_plan`) -//! - [`project`] — Project + work-item CRUD (`manage_project`, `manage_work_item`) //! - [`web`] — web search/fetch, browser control +//! +//! Work-item / project management is not a tool surface: agents reach the +//! work system through the `org2-pm` CLI from their shell. pub mod agent_def; pub mod coding; @@ -27,5 +29,4 @@ pub mod meta; pub mod nodes; pub mod orchestration; pub mod plan_mode; -pub mod project; pub mod web; diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs index a0de40d30f..7f8e46d300 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/mod.rs @@ -20,9 +20,8 @@ //! `user_allowed_tools` on top of this list (capability-gated) and //! honours `excluded_tools` from either source. //! -//! `manage_project`, `manage_work_item`, and `manage_agent_def` are -//! management-capability tools for OS/coordinator-style sessions, not -//! default SDE worker tools. +//! `manage_agent_def` is a management-capability tool for +//! OS/coordinator-style sessions, not a default SDE worker tool. use async_trait::async_trait; use serde_json::Value; @@ -404,7 +403,6 @@ impl AgentTool { plan_slot_cache: None, agent_org_context: self.config.agent_org_context.as_deref().cloned(), agent_org_current_member_id: None, - session_org_id: None, channel_context: None, }; let mut overlay = ToolRegistry::with_fallback(base_registry); @@ -1041,17 +1039,16 @@ impl Tool for AgentTool { }; // Mode overlay (see the comment above step 6): the worker's - // policy must reflect the parent's CURRENT exec + product modes, - // not the base policy snapshotted at init. A Plan-mode parent - // therefore spawns read-only workers, and a non-Project parent - // spawns workers with the PM mutation tools denied (deny-delta — - // delegation cannot escalate past the parent's own surface). + // policy must reflect the parent's CURRENT exec mode, not the + // base policy snapshotted at init. A Plan-mode parent therefore + // spawns read-only workers. Product mode is not a tool overlay: + // the child record inherits it below, and `org2-pm` enforces it + // at the application boundary via the injected ORGII_MODE. let effective_policy = Self::overlay_parent_modes( effective_policy, parent_agent_exec_mode .as_deref() .and_then(crate::session::AgentExecMode::parse), - parent_product_mode.as_deref(), ); { @@ -1076,6 +1073,7 @@ impl Tool for AgentTool { // fallback writes to ~/.orgii/plans/{agent_id}/ instead. workspace_path: self.config.workspace_path.clone(), agent_exec_mode: parent_agent_exec_mode, + product_mode: parent_product_mode, native_harness_type: parent_native_harness_type, created_at: chrono::Utc::now().to_rfc3339(), updated_at: chrono::Utc::now().to_rfc3339(), diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs index 7c1496e34f..f33c7f0550 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/policy.rs @@ -4,35 +4,23 @@ //! - **Path A**: inherit parent registry, overlay policy (deny + optional //! allow). //! - **Path B**: build a fresh registry from an explicit allowlist — used -//! for specialist agents whose tools (`manage_project`, `manage_work_item`, -//! `manage_agent_def`) are deliberately denied on the parent overlay. +//! for specialist agents whose tools (`manage_agent_def`) are +//! deliberately denied on the parent overlay. use tracing::warn; use crate::definitions::AgentDefinition; use crate::tools::builtin_tools::builtin_tool_required_capability; use crate::tools::defaults::{subagent_forbidden_tools, SUBAGENT_RETIRED_TOOL_ALIASES}; -use crate::tools::impls::project::manage_work_item::WorkItemTool; use crate::tools::names as tool_names; use crate::tools::policy::{ResolvedToolPolicy, ToolPolicyLayer}; use crate::tools::registry::ToolRegistry; use crate::tools::traits::ToolError; use crate::tools::impls::agent_def::AgentDefinitionTool; -use crate::tools::impls::project::manage_project::ProjectTool; use super::AgentTool; -fn session_org_for(session_id: &str) -> Option { - if session_id.is_empty() { - return None; - } - crate::session::persistence::get_session(session_id) - .ok() - .flatten() - .and_then(|record| record.org_id) -} - pub(super) fn agent_supports_builtin_tool(agent: &AgentDefinition, tool_name: &str) -> bool { let Some(required) = builtin_tool_required_capability(tool_name) else { return true; @@ -69,30 +57,25 @@ impl AgentTool { ToolPolicyLayer { allow: None, deny } } - /// Layer the parent session's CURRENT exec mode and product mode onto - /// a freshly-built worker policy. + /// Layer the parent session's CURRENT exec mode onto a freshly-built + /// worker policy. /// /// `parent_policy` is the session's BASE policy captured at init — it /// never carries the per-turn deny overlays the parent itself runs - /// under (`ResolvedToolPolicy::with_modes` composes those per turn). - /// Without re-applying them here, a Plan-mode parent could escape its - /// read-only guarantee by delegating writes to `builtin:general` - /// (which inherits edit_file/run_shell), and a non-Project parent - /// could escape the PM deny-delta by delegating `manage_work_item` / - /// `manage_project` to a specialist subagent (both dispatch paths — - /// inherited and fresh-registry — flow through this overlay). + /// under. Without re-applying them here, a Plan-mode parent could + /// escape its read-only guarantee by delegating writes to + /// `builtin:general` (which inherits edit_file/run_shell); both + /// dispatch paths — inherited and fresh-registry — flow through this + /// overlay. Work-system access is not tool-mediated: `org2-pm` + /// enforces product mode at the application boundary via the + /// injected ORGII_MODE, so there is no PM tool layer to re-apply. pub(super) fn overlay_parent_modes( policy: ResolvedToolPolicy, parent_exec_mode: Option, - parent_product_mode: Option<&str>, ) -> ResolvedToolPolicy { - let policy = match parent_exec_mode { + match parent_exec_mode { Some(mode) => policy.with_exec_mode(mode), None => policy, - }; - match ResolvedToolPolicy::product_mode_layer(parent_product_mode) { - Some(layer) => policy.with_extra_layer(layer), - None => policy, } } @@ -152,10 +135,10 @@ impl AgentTool { // ── Path B: Fresh registry for allowlist specialists ──────────── // // Used for subagents that declare `tools.system_restrict_to_tools = Some(list)` - // where the listed tools include management-capability tools (manage_project, - // manage_work_item, manage_agent_def) that may be absent from the - // parent session's overlay. Rebuilds just those tools with - // the same constructors registration uses. + // where the listed tools include management-capability tools + // (manage_agent_def) that may be absent from the parent session's + // overlay. Rebuilds just those tools with the same constructors + // registration uses. pub(super) async fn build_fresh_registry( &self, @@ -186,24 +169,6 @@ impl AgentTool { } match tool_name.as_str() { - tool_names::MANAGE_PROJECT => { - let parent_session_id = self.parent_session_id.lock().await.clone(); - let project_tool = ProjectTool::new( - self.config.app_handle.clone(), - self.config.session_account_id.clone(), - self.config.agent_model.clone(), - session_org_for(&parent_session_id), - ); - registry.register(Box::new(project_tool)); - } - tool_names::MANAGE_WORK_ITEM => { - let parent_session_id = self.parent_session_id.lock().await.clone(); - let session_org_id = session_org_for(&parent_session_id); - registry.register(Box::new(WorkItemTool::new( - parent_session_id, - session_org_id, - ))); - } tool_names::MANAGE_AGENT_DEF => { let handle = self.config.app_handle.as_ref().ok_or_else(|| { ToolError::ExecutionFailed("App handle not available".into()) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/tests.rs index 1f2295ff73..8a1a6bf61d 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent/tests.rs @@ -192,14 +192,14 @@ fn test_fresh_registry_management_tools_require_management_capability() { let agent_without_management = AgentDefinition { id: "custom:no-management".to_string(), tools: AgentToolSelection { - system_restrict_to_tools: Some(vec![tool_names::MANAGE_PROJECT.to_string()]), + system_restrict_to_tools: Some(vec![tool_names::MANAGE_AGENT_DEF.to_string()]), ..Default::default() }, ..Default::default() }; assert!(!agent_supports_builtin_tool( &agent_without_management, - tool_names::MANAGE_PROJECT + tool_names::MANAGE_AGENT_DEF )); let agent_with_management = AgentDefinition { @@ -209,14 +209,14 @@ fn test_fresh_registry_management_tools_require_management_capability() { ..Default::default() }), tools: AgentToolSelection { - system_restrict_to_tools: Some(vec![tool_names::MANAGE_PROJECT.to_string()]), + system_restrict_to_tools: Some(vec![tool_names::MANAGE_AGENT_DEF.to_string()]), ..Default::default() }, ..Default::default() }; assert!(agent_supports_builtin_tool( &agent_with_management, - tool_names::MANAGE_PROJECT + tool_names::MANAGE_AGENT_DEF )); } @@ -224,10 +224,9 @@ fn test_fresh_registry_management_tools_require_management_capability() { // // The worker policy is built from the parent's BASE policy (captured at // init, no per-turn mode layers). `overlay_parent_modes` must re-apply -// the parent's CURRENT exec + product modes so a Plan-mode parent -// cannot escape its read-only guarantee through `builtin:general`, and -// a non-Project parent cannot escape the PM deny-delta by delegating -// `manage_work_item` / `manage_project` to a specialist subagent. +// the parent's CURRENT exec mode so a Plan-mode parent cannot escape +// its read-only guarantee through `builtin:general`. Product mode is +// enforced by `org2-pm` at the application boundary, not by tool policy. #[test] fn plan_mode_parent_overlay_makes_worker_policy_read_only() { @@ -238,7 +237,6 @@ fn plan_mode_parent_overlay_makes_worker_policy_read_only() { let overlaid = AgentTool::overlay_parent_modes( ResolvedToolPolicy::permissive(), Some(AgentExecMode::Plan), - Some("project"), ); for denied in ["edit_file", "run_shell", "apply_patch", "delete_file"] { assert!( @@ -265,58 +263,19 @@ fn build_or_absent_parent_mode_leaves_worker_policy_untouched() { let build = AgentTool::overlay_parent_modes( ResolvedToolPolicy::permissive(), Some(AgentExecMode::Build), - Some("project"), ); assert!( build.is_allowed("edit_file"), "Build-mode parent keeps write tools for workers" ); - let absent = - AgentTool::overlay_parent_modes(ResolvedToolPolicy::permissive(), None, Some("project")); + let absent = AgentTool::overlay_parent_modes(ResolvedToolPolicy::permissive(), None); assert!( absent.is_allowed("edit_file"), "no parent mode => no overlay" ); } -#[test] -fn non_project_parent_overlay_denies_pm_tools_for_workers() { - use super::AgentTool; - use crate::tools::policy::ResolvedToolPolicy; - - // None and non-project product modes both subtract the PM surface, - // even when the worker's own policy (inherited or fresh-registry - // allowlist) would grant it. - for product_mode in [None, Some("build"), Some("plan")] { - let overlaid = AgentTool::overlay_parent_modes( - ResolvedToolPolicy::permissive(), - None, - product_mode, - ); - for denied in [tool_names::MANAGE_WORK_ITEM, tool_names::MANAGE_PROJECT] { - assert!( - !overlaid.is_allowed(denied), - "{product_mode:?} parent must deny {denied} for workers" - ); - } - assert!( - overlaid.is_allowed("read_file"), - "non-PM tools must survive the product-mode overlay" - ); - } - - let project = AgentTool::overlay_parent_modes( - ResolvedToolPolicy::permissive(), - None, - Some("project"), - ); - assert!( - project.is_allowed(tool_names::MANAGE_WORK_ITEM), - "Project parent keeps the PM surface for workers" - ); -} - // ── Default sub_agents on root agents ─────────────────────────────── #[test] diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/members.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/members.rs deleted file mode 100644 index 0bba2aea82..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/members.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Team-member action handlers (`list_members`, `list_contributors`). -//! -//! Members live in the global project store and are scoped to a project -//! id. `list_contributors` runs `git shortlog` against every repo listed -//! in the project's `linked_repos`, merges the result with existing -//! rows, and returns the updated roster. - -use crate::tools::traits::ToolError; -use project_management::projects::io as projects_io; -use project_management::projects::types::{MemberEntry, ProjectData}; - -pub(super) fn list_members(slug: Option<&str>) -> Result { - let projects = projects_for_optional_slug(slug)?; - if projects.is_empty() { - return Ok("No projects found.".to_string()); - } - - let mut output = String::new(); - for project in projects { - let members_file = - projects_io::read_members(&project.meta.id).map_err(ToolError::ExecutionFailed)?; - if members_file.members.is_empty() { - output.push_str(&format!( - "No team members found for `{}`. Use `list_contributors` to discover contributors from the project's linked git repositories.\n\n", - project.slug - )); - continue; - } - - output.push_str(&format!( - "Team members for `{}` ({}):\n\n", - project.slug, - members_file.members.len() - )); - for member in &members_file.members { - append_member_line(&mut output, member); - } - output.push('\n'); - } - - Ok(output.trim_end().to_string()) -} - -pub(super) fn list_contributors(slug: Option<&str>) -> Result { - let projects = projects_for_optional_slug(slug)?; - if projects.is_empty() { - return Ok("No projects found.".to_string()); - } - - let mut output = String::new(); - for project in projects { - let sync_result = projects_io::sync_members_from_git(&project.slug) - .map_err(ToolError::ExecutionFailed)?; - let members_file = - projects_io::read_members(&project.meta.id).map_err(ToolError::ExecutionFailed)?; - output.push_str(&format!( - "Synced git contributors for `{}`: {} added, {} updated.\nTeam members ({}):\n\n", - project.slug, - sync_result.added, - sync_result.updated, - members_file.members.len() - )); - for member in &members_file.members { - append_member_line(&mut output, member); - } - output.push('\n'); - } - - Ok(output.trim_end().to_string()) -} - -fn projects_for_optional_slug(slug: Option<&str>) -> Result, ToolError> { - match slug { - Some(project_slug) => projects_io::read_project(project_slug) - .map(|project| vec![project]) - .map_err(ToolError::ExecutionFailed), - None => projects_io::read_all_projects().map_err(ToolError::ExecutionFailed), - } -} - -fn append_member_line(output: &mut String, member: &MemberEntry) { - let status = if member.active { "" } else { " [inactive]" }; - output.push_str(&format!("- {} (`{}`)", member.name, member.id)); - if let Some(ref email) = member.email { - output.push_str(&format!(" <{}>", email)); - } - if let Some(ref gh) = member.github_username { - output.push_str(&format!(" @{}", gh)); - } - if let Some(ref date) = member.last_commit_date { - output.push_str(&format!(" (last commit: {})", date)); - } - output.push_str(status); - output.push('\n'); -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/mod.rs deleted file mode 100644 index 926d56b10d..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/mod.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! `manage_project` tool — agent-facing wrapper over `tool_infra::project`. -//! -//! Layout: -//! - [`schema`] — static description, dynamic `llm_description`, JSON Schema. -//! - [`params`] — shared param-extraction helpers (string arrays, todos, -//! schedule, orchestrator overrides). -//! - [`projects`] — project-level CRUD action handlers. -//! - [`work_items`] — work-item action handlers (`list_items`, `read_item`, -//! …, `find`). -//! - [`members`] — team-member action handlers. -//! -//! `mod.rs` itself only owns the `ProjectTool` struct, the `Tool` impl, and -//! the action-name → handler dispatch. - -mod members; -mod params; -mod projects; -mod schema; -mod work_items; - -use async_trait::async_trait; -use serde_json::Value; - -use crate::tools::names as tool_names; -use crate::tools::traits::{optional_string, required_string, Tool, ToolError}; - -/// Project management tool. -pub struct ProjectTool { - /// App handle for launching sessions (`start_item`). - app_handle: Option, - /// The parent session's own account id (shared with `SessionTool` when set). - session_account_id: Option, - /// Agent model id for the running session (used with session account for - /// `start_item`). - agent_model: String, - session_org_id: Option, -} - -impl ProjectTool { - pub fn new( - app_handle: Option, - session_account_id: Option, - agent_model: String, - session_org_id: Option, - ) -> Self { - Self { - app_handle, - session_account_id, - agent_model, - session_org_id, - } - } - - /// Resolve the `slug` parameter to a canonical project slug. - /// Accepts slug, display name, or project ID. - fn resolve_slug(params: &Value) -> Result { - let raw = required_string(params, "slug")?; - crate::tool_infra::resolve_slug(&raw).map_err(ToolError::ExecutionFailed) - } -} - -#[async_trait] -impl Tool for ProjectTool { - fn name(&self) -> &str { - tool_names::MANAGE_PROJECT - } - - fn category(&self) -> &str { - crate::tools::categories::PROJECT - } - - fn description(&self) -> &str { - schema::DESCRIPTION - } - - fn llm_description(&self) -> Option { - Some(schema::llm_description()) - } - - fn parameters(&self) -> Value { - schema::parameters() - } - - async fn execute_text( - &self, - params: Value, - _ctx: &crate::tools::traits::CallContext, - ) -> Result { - let action = required_string(¶ms, "action")?; - - match action.as_str() { - // ── Project actions ── - "list" => projects::list().await, - "read" => { - let slug = Self::resolve_slug(¶ms)?; - projects::read(&slug).await - } - "create" => projects::create(¶ms, self.session_org_id.as_deref()).await, - "update" => { - let slug = Self::resolve_slug(¶ms)?; - projects::update(&slug, ¶ms).await - } - "delete" => { - let slug = Self::resolve_slug(¶ms)?; - projects::delete(&slug).await - } - - // ── Work item actions ── - // - // The duplicate CRUD surface (list_items/read_item/create_item/ - // update_item/delete_item) was consolidated into - // `manage_work_item` (Orgtrack migration Phase 8) — one tool - // owns work-item CRUD. Recoverable misuse returns structured - // guidance instead of a trajectory-visible execution error so - // the model self-corrects in one step. - "list_items" | "read_item" | "create_item" | "update_item" | "delete_item" => { - let equivalent = match action.as_str() { - "list_items" => "list", - "read_item" => "read", - "create_item" => "create", - "update_item" => "update", - _ => "delete", - }; - Ok(format!( - "{{\"guidance\": \"work-item CRUD moved to the manage_work_item tool; call manage_work_item with action='{}' and the same parameters\", \"movedTo\": \"manage_work_item\"}}", - equivalent - )) - } - "start_item" => { - let slug = Self::resolve_slug(¶ms)?; - let short_id = required_string(¶ms, "short_id")?; - work_items::start( - &slug, - &short_id, - self.app_handle.as_ref(), - self.session_account_id.as_deref(), - &self.agent_model, - ) - .await - } - "find" => { - let query = required_string(¶ms, "query")?; - work_items::find(&query).await - } - - // ── Member actions ── - "list_members" => match optional_string(¶ms, "slug") { - Some(raw) if !raw.trim().is_empty() => { - let slug = crate::tool_infra::resolve_slug(&raw) - .map_err(ToolError::ExecutionFailed)?; - members::list_members(Some(&slug)) - } - _ => members::list_members(None), - }, - "list_contributors" => match optional_string(¶ms, "slug") { - Some(raw) if !raw.trim().is_empty() => { - let slug = crate::tool_infra::resolve_slug(&raw) - .map_err(ToolError::ExecutionFailed)?; - members::list_contributors(Some(&slug)) - } - _ => members::list_contributors(None), - }, - - _ => Err(ToolError::InvalidParams(format!( - "Unknown project action: '{}'. Valid: list, read, create, update, delete, \ - list_items, read_item, create_item, update_item, delete_item, start_item, \ - find, list_members, list_contributors", - action - ))), - } - } -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/params.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/params.rs deleted file mode 100644 index 2970d5be8a..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/params.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Param-extraction helpers shared by every action handler. -//! -//! Pulled out of the dispatch file so each handler reads a flat list of -//! `optional_*` calls instead of repeating the same `Value` ceremony. -//! -//! The work-item helpers (`optional_todos`, `optional_schedule`, -//! `orchestrator_overrides_from_params`) left with the duplicate CRUD -//! surface (Orgtrack migration Phase 8) — work-item creation params, -//! including the retired cron schedule entry point, now live only on -//! `manage_work_item`. - -use serde_json::Value; - -/// Extract an optional array of strings from params. -pub(super) fn optional_string_array(params: &Value, key: &str) -> Option> { - params.get(key).and_then(|val| { - val.as_array().map(|arr| { - arr.iter() - .filter_map(|item| item.as_str().map(String::from)) - .collect() - }) - }) -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/projects.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/projects.rs deleted file mode 100644 index bca2fb26f3..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/projects.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Project-level CRUD action handlers (`list`, `read`, `create`, `update`, -//! `delete`). - -use serde_json::Value; - -use crate::tools::traits::{optional_string, required_string, ToolError}; - -use super::params::optional_string_array; - -pub(super) async fn list() -> Result { - crate::tool_infra::list_projects() - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn read(slug: &str) -> Result { - crate::tool_infra::read_project(slug) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn create( - params: &Value, - default_org_id: Option<&str>, -) -> Result { - let name = required_string(params, "name")?; - let description = optional_string(params, "description").unwrap_or_default(); - let org_id = optional_string(params, "project_org_id") - .filter(|value| !value.trim().is_empty()) - .or_else(|| default_org_id.map(String::from)); - let status = optional_string(params, "status"); - let priority = optional_string(params, "priority"); - let health = optional_string(params, "health"); - let lead = optional_string(params, "lead"); - let members = optional_string_array(params, "members"); - let labels = optional_string_array(params, "labels"); - let linked_repos = optional_string_array(params, "linked_repos"); - let start_date = optional_string(params, "start_date"); - let target_date = optional_string(params, "target_date"); - - crate::tool_infra::create_project( - &name, - &description, - org_id.as_deref(), - status.as_deref(), - priority.as_deref(), - health.as_deref(), - lead.as_deref(), - members, - labels, - linked_repos, - start_date.as_deref(), - target_date.as_deref(), - ) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn update(slug: &str, params: &Value) -> Result { - let name = optional_string(params, "name"); - let description = optional_string(params, "description"); - let status = optional_string(params, "status"); - let priority = optional_string(params, "priority"); - let health = optional_string(params, "health"); - let lead = optional_string(params, "lead"); - let members = optional_string_array(params, "members"); - let labels = optional_string_array(params, "labels"); - let linked_repos = optional_string_array(params, "linked_repos"); - let start_date = optional_string(params, "start_date"); - let target_date = optional_string(params, "target_date"); - - crate::tool_infra::update_project( - slug, - name.as_deref(), - description.as_deref(), - status.as_deref(), - priority.as_deref(), - health.as_deref(), - lead.as_deref(), - members, - labels, - linked_repos, - start_date.as_deref(), - target_date.as_deref(), - ) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn delete(slug: &str) -> Result { - crate::tool_infra::delete_project(slug) - .await - .map_err(ToolError::ExecutionFailed) -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/schema.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/schema.rs deleted file mode 100644 index 82ee02d95e..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/schema.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Static description + JSON Schema for the `manage_project` tool. -//! -//! Pulled into its own module so the `Tool` impl in `mod.rs` reads as a -//! thin dispatch layer — see the `actions` submodule for the per-verb -//! handlers. -//! -//! Work-item CRUD moved to `manage_work_item` (Orgtrack migration -//! Phase 8); this tool keeps project CRUD, members, global `find`, and -//! `start_item` — the orchestrator-launch capability `manage_work_item` -//! does not have. - -use serde_json::{json, Value}; - -pub(super) const DESCRIPTION: &str = - "Manage projects (Work Item parent containers) in the global project store.\n\n\ - **Projects**: list, read, create, update, delete.\n\ - **Execution**: start_item — launch a work item's orchestrator run via the SDE agent.\n\ - **Search**: find — search work items and projects globally by ID, title, or keyword.\n\ - **Members**: list_members — list team members. list_contributors — sync and list git contributors.\n\n\ - Work item CRUD (list/read/create/update/delete) lives on the manage_work_item tool."; - -pub(super) fn llm_description() -> String { - "Manage projects in the global project store.\n\n\ - Projects: list, read, create, update, delete.\n\ - Execution: start_item (launch a work item's orchestrator run).\n\ - Search: find — global.\n\ - Members: list_members, list_contributors.\n\ - Work item CRUD lives on manage_work_item." - .to_string() -} - -pub(super) fn parameters() -> Value { - json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "description": "The operation to perform.", - "enum": ["list", "read", "create", "update", "delete", - "start_item", "find", "list_members", "list_contributors"] - }, - "query": { - "type": "string", - "description": "Search term for 'find' action: work item ID, title keyword, project name, or assignee. Searches across ALL IDE workspaces." - }, - "slug": { - "type": "string", - "description": "Project identifier — accepts slug, display name, or project ID (e.g. 'my-project', 'My Project', or 'project-my-project'). Required for project-specific actions. Optional for list_members/list_contributors; omit it to list or sync across all projects. Use 'list' to discover available projects." - }, - "name": { - "type": "string", - "description": "Project name (required for create, optional for update)" - }, - "project_org_id": { - "type": "string", - "description": "Project-org id to create the project under (for 'create'). Omit to use the session's org context (falls back to 'personal-org'). NOT the agent org — see 'org_id'." - }, - "description": { - "type": "string", - "description": "Project description (markdown)" - }, - "status": { - "type": "string", - "description": "Project status", - "enum": ["backlog", "planned", "in_progress", "completed", "canceled"] - }, - "priority": { - "type": "string", - "description": "Project priority", - "enum": ["urgent", "high", "medium", "low", "none"] - }, - "health": { - "type": "string", - "description": "Project health indicator", - "enum": ["on_track", "at_risk", "off_track", "no_updates"] - }, - "lead": { - "type": "string", - "description": "Member ID of the project lead. Pass empty string to clear." - }, - "members": { - "type": "array", - "items": { "type": "string" }, - "description": "Member IDs assigned to the project" - }, - "labels": { - "type": "array", - "items": { "type": "string" }, - "description": "Label IDs (e.g. ['lbl-bug', 'lbl-feature'])" - }, - "linked_repos": { - "type": "array", - "items": { "type": "string" }, - "description": "Linked repository paths or URLs" - }, - "start_date": { - "type": "string", - "description": "Project start date (ISO 8601, e.g. '2026-02-15'). Pass empty string to clear." - }, - "target_date": { - "type": "string", - "description": "Project target/due date (ISO 8601). Pass empty string to clear." - }, - "short_id": { - "type": "string", - "description": "Work item short ID, e.g. 'PROJ-001' (for start_item)" - } - }, - "required": ["action"] - }) -} - diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/work_items.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/work_items.rs deleted file mode 100644 index f6420f1cc2..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/work_items.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Work-item action handlers (`start_item`, `find`). -//! -//! The duplicate CRUD surface (`list_items`/`read_item`/`create_item`/ -//! `update_item`/`delete_item`) was consolidated into `manage_work_item` -//! (Orgtrack migration Phase 8): one tool owns work-item CRUD, this tool -//! keeps only the capabilities `manage_work_item` does not have — -//! starting a work item's orchestrator run and cross-workspace search. -//! The dispatcher returns structured guidance for the retired actions. - -use crate::tools::traits::ToolError; - -pub(super) async fn start( - slug: &str, - short_id: &str, - app_handle: Option<&tauri::AppHandle>, - session_account_id: Option<&str>, - agent_model: &str, -) -> Result { - let app = app_handle.ok_or_else(|| { - ToolError::ExecutionFailed( - "start_item requires app_handle (not available in this context)".to_string(), - ) - })?; - let override_account = session_account_id.filter(|acct| !acct.is_empty()); - let override_model = if !agent_model.trim().is_empty() { - Some(agent_model) - } else { - None - }; - crate::tool_infra::start_work_item(slug, short_id, app, override_account, override_model) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn find(query: &str) -> Result { - crate::tool_infra::find_across_workspaces(query) - .await - .map_err(ToolError::ExecutionFailed) -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs deleted file mode 100644 index 8409b0c3a7..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_work_item.rs +++ /dev/null @@ -1,1022 +0,0 @@ -//! Work item tool: manage project-scoped and standalone work items. -//! -//! Thin wrapper over `tool_infra::project` and `project_management::projects::io` — -//! shared implementations used by agent tools and Tauri work-item commands. - -use async_trait::async_trait; -use serde_json::{Map, Value}; - -use crate::tool_infra::OrchestratorConfigOverrides; -use crate::tools::names as tool_names; -use crate::tools::traits::{optional_bool, optional_string, required_string, Tool, ToolError}; -use core_types::workflow::{ - AgentRole, LinkedSession, LinkedSessionStatus, LinkedSessionType, WorkItemSchedule, -}; -use project_management::projects::{ - io, - types::{OrchestratorConfig, TodoEntry, WorkItemData, WorkItemFrontmatter}, -}; - -async fn run_blocking(label: &str, func: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, - T: Send + 'static, -{ - let label = label.to_string(); - tokio::task::spawn_blocking(func) - .await - .map_err(|err| format!("{label} task failed: {err}"))? -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum WorkItemScope { - Project(String), - Standalone, -} - -/// Work item (task/issue) management tool. -pub struct WorkItemTool { - session_id: String, - default_org_id: Option, -} - -impl WorkItemTool { - pub fn new(session_id: String, default_org_id: Option) -> Self { - Self { - session_id, - default_org_id: default_org_id.filter(|org| org != "personal-org"), - } - } - - fn linked_session_id(&self, params: &Value) -> String { - optional_string(params, "session_id") - .filter(|session_id| !session_id.trim().is_empty()) - .unwrap_or_else(|| self.session_id.clone()) - } - - fn read_standalone_scoped( - org_id: Option<&str>, - short_id: &str, - ) -> Result<(Option, WorkItemData), String> { - if let Some(org) = org_id { - if let Ok(item) = io::read_standalone_work_item(Some(org), short_id) { - return Ok((Some(org.to_string()), item)); - } - } - io::read_standalone_work_item(None, short_id).map(|item| (None, item)) - } - - fn resolve_scope(params: &Value) -> Result { - match optional_string(params, "project_slug") { - Some(raw) if !raw.trim().is_empty() => crate::tool_infra::resolve_slug(&raw) - .map(WorkItemScope::Project) - .map_err(ToolError::ExecutionFailed), - _ => Ok(WorkItemScope::Standalone), - } - } - - fn optional_string_array(params: &Value, key: &str) -> Option> { - params.get(key).and_then(|val| { - val.as_array().map(|arr| { - arr.iter() - .filter_map(|item| item.as_str().map(String::from)) - .collect() - }) - }) - } - - fn optional_todos(params: &Value) -> Option> { - params.get("todos").and_then(|val| { - val.as_array().map(|arr| { - arr.iter() - .filter_map(|item| { - let content = item.get("content")?.as_str()?.to_string(); - let status = item - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("pending") - .to_string(); - Some((content, status)) - }) - .collect() - }) - }) - } - - fn parse_schedule(params: &Value) -> Option { - let obj = params.get("schedule")?; - if obj.is_null() { - return None; - } - let at = obj.get("at").and_then(|v| v.as_str()).map(String::from); - let cron = obj.get("cron").and_then(|v| v.as_str()).map(String::from); - let enabled = obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); - Some(WorkItemSchedule { - at, - cron, - enabled, - last_run: None, - }) - } - - fn batch_items(params: &Value) -> Result, ToolError> { - let Some(items) = params.get("items") else { - return Err(ToolError::InvalidParams( - "items array is required for batch".to_string(), - )); - }; - let items = items.as_array().ok_or_else(|| { - ToolError::InvalidParams("items must be an array for batch".to_string()) - })?; - if items.is_empty() { - return Err(ToolError::InvalidParams( - "items must contain at least one operation".to_string(), - )); - } - - let mut merged_items = Vec::with_capacity(items.len()); - for (idx, item) in items.iter().enumerate() { - let item_obj = item.as_object().ok_or_else(|| { - ToolError::InvalidParams(format!("items[{idx}] must be an object")) - })?; - let mut merged = Map::new(); - if let Some(project_slug) = params.get("project_slug") { - merged.insert("project_slug".to_string(), project_slug.clone()); - } - if let Some(session_status) = params.get("session_status") { - merged.insert("session_status".to_string(), session_status.clone()); - } - if let Some(agent_role) = params.get("agent_role") { - merged.insert("agent_role".to_string(), agent_role.clone()); - } - for (key, value) in item_obj { - merged.insert(key.clone(), value.clone()); - } - if !merged.contains_key("action") { - return Err(ToolError::InvalidParams(format!( - "items[{idx}].action is required" - ))); - } - merged_items.push(Value::Object(merged)); - } - Ok(merged_items) - } - - async fn execute_batch( - &self, - params: &Value, - ctx: &crate::tools::traits::CallContext, - ) -> Result { - let items = Self::batch_items(params)?; - let mut lines = vec![format!("Batch completed: {} operation(s)", items.len())]; - for (idx, item) in items.into_iter().enumerate() { - let tool = WorkItemTool::new(self.session_id.clone(), self.default_org_id.clone()); - let result = Box::pin(tool.execute_text(item, ctx)).await; - match result { - Ok(text) => lines.push(format!("{}. OK: {}", idx + 1, text)), - Err(err) => lines.push(format!("{}. ERROR: {}", idx + 1, err)), - } - } - Ok(lines.join("\n")) - } - - fn orchestrator_overrides_from_params(params: &Value) -> Option { - let account = optional_string(params, "selected_account_id"); - let model = optional_string(params, "selected_model_id"); - let sub_agents = Self::optional_string_array(params, "sub_agent_ids"); - let org_id = optional_string(params, "org_id"); - let agent_definition_id = optional_string(params, "agent_definition_id"); - let worktree_path = optional_string(params, "worktree_path"); - let review_config = params.get("review_config").and_then(|rc| { - serde_json::from_value::(rc.clone()).ok() - }); - if account.is_some() - || model.is_some() - || sub_agents.as_ref().is_some_and(|v| !v.is_empty()) - || org_id.is_some() - || agent_definition_id.is_some() - || worktree_path.is_some() - || review_config.is_some() - { - Some(OrchestratorConfigOverrides { - selected_account_id: account, - selected_model_id: model, - sub_agent_ids: sub_agents.unwrap_or_default(), - org_id, - agent_definition_id, - worktree_path, - review_config, - }) - } else { - None - } - } - - fn todos_to_entries(todos: Vec<(String, String)>, existing: &[TodoEntry]) -> Vec { - todos - .into_iter() - .enumerate() - .map(|(idx, (content, status))| TodoEntry { - id: existing - .get(idx) - .map(|todo| todo.id.clone()) - .unwrap_or_else(|| format!("todo-{}", idx + 1)), - content, - status, - }) - .collect() - } - - fn config_from_overrides(overrides: OrchestratorConfigOverrides) -> OrchestratorConfig { - let mut config = OrchestratorConfig::default(); - if let Some(id) = overrides.selected_account_id.filter(|s| !s.is_empty()) { - config.selected_account_id = Some(id); - } - if let Some(id) = overrides.selected_model_id.filter(|s| !s.is_empty()) { - config.selected_model_id = Some(id); - } - if !overrides.sub_agent_ids.is_empty() { - config.sub_agent_ids = overrides.sub_agent_ids; - } - config.org_id = overrides.org_id; - config.agent_definition_id = overrides.agent_definition_id; - config.worktree_path = overrides.worktree_path; - config.review_config = overrides.review_config; - config - } - - fn apply_updates( - fm: &mut WorkItemFrontmatter, - body: &mut String, - params: &Value, - ) -> Result<(), ToolError> { - if let Some(title) = optional_string(params, "title") { - fm.title = title; - } - if let Some(description) = optional_string(params, "description") { - *body = description; - } - if let Some(project) = optional_string(params, "project") { - fm.project = if project.is_empty() { - None - } else { - Some(project) - }; - } - if let Some(status) = optional_string(params, "status") { - fm.status = status; - } - if let Some(priority) = optional_string(params, "priority") { - fm.priority = priority; - } - if let Some(assignee) = optional_string(params, "assignee") { - fm.assignee = if assignee.is_empty() { - None - } else { - Some(assignee) - }; - } - if let Some(labels) = Self::optional_string_array(params, "labels") { - fm.labels = labels; - } - if let Some(milestone) = optional_string(params, "milestone") { - fm.milestone = if milestone.is_empty() { - None - } else { - Some(milestone) - }; - } - if let Some(parent) = optional_string(params, "parent") { - fm.parent = if parent.is_empty() { - None - } else { - Some(parent) - }; - } - if let Some(start_date) = optional_string(params, "start_date") { - fm.start_date = if start_date.is_empty() { - None - } else { - Some(start_date) - }; - } - if let Some(target_date) = optional_string(params, "target_date") { - fm.target_date = if target_date.is_empty() { - None - } else { - Some(target_date) - }; - } - if let Some(starred) = optional_bool(params, "starred") { - fm.starred = starred; - } - if let Some(todos) = Self::optional_todos(params) { - fm.todos = Self::todos_to_entries(todos, &fm.todos); - } - if let Some(overrides) = Self::orchestrator_overrides_from_params(params) { - fm.orchestrator_config = Some(Self::config_from_overrides(overrides)); - } - if let Some(schedule) = Self::parse_schedule(params) { - fm.schedule = Some(schedule); - } - fm.updated_at = chrono::Utc::now().to_rfc3339(); - Ok(()) - } - - fn parse_linked_session_status(raw: Option) -> Result { - match raw.as_deref().unwrap_or("running") { - "running" => Ok(LinkedSessionStatus::Running), - "completed" => Ok(LinkedSessionStatus::Completed), - "failed" => Ok(LinkedSessionStatus::Failed), - "cancelled" => Ok(LinkedSessionStatus::Cancelled), - other => Err(ToolError::InvalidParams(format!( - "Unknown linked session status '{other}'" - ))), - } - } - - fn parse_agent_role(raw: Option) -> Result { - match raw.as_deref().unwrap_or("custom") { - "coding" | "sde" => Ok(AgentRole::Coding), - "review" => Ok(AgentRole::Review), - "orchestrator" => Ok(AgentRole::Orchestrator), - "custom" => Ok(AgentRole::Custom), - "sub_agent" => Ok(AgentRole::SubAgent), - other => Err(ToolError::InvalidParams(format!( - "Unknown linked session agent_role '{other}'" - ))), - } - } - - fn link_session_to_frontmatter( - &self, - fm: &mut WorkItemFrontmatter, - params: &Value, - ) -> Result { - let session_id = self.linked_session_id(params); - let status = Self::parse_linked_session_status(optional_string(params, "session_status"))?; - let agent_role = Self::parse_agent_role(optional_string(params, "agent_role"))?; - let now = chrono::Utc::now().to_rfc3339(); - - if let Some(existing) = fm - .linked_sessions - .iter_mut() - .find(|linked| linked.session_id == session_id) - { - existing.status = status; - existing.agent_role = agent_role; - if existing.completed_at.is_none() - && matches!( - existing.status, - LinkedSessionStatus::Completed - | LinkedSessionStatus::Failed - | LinkedSessionStatus::Cancelled - ) - { - existing.completed_at = Some(now.clone()); - } - } else { - fm.linked_sessions.push(LinkedSession { - session_id: session_id.clone(), - session_type: LinkedSessionType::Native, - agent_role, - started_at: now.clone(), - completed_at: None, - status, - cost_usd: 0.0, - total_tokens: 0, - parent_session_id: None, - sub_agent_name: None, - sub_agent_instance: None, - result_preview: None, - }); - } - fm.updated_at = now; - Ok(session_id) - } - - fn unlink_session_from_frontmatter( - &self, - fm: &mut WorkItemFrontmatter, - params: &Value, - ) -> String { - let session_id = self.linked_session_id(params); - fm.linked_sessions - .retain(|linked| linked.session_id != session_id); - fm.updated_at = chrono::Utc::now().to_rfc3339(); - session_id - } - - fn format_item(item: &WorkItemData) -> String { - let fm = &item.frontmatter; - let mut output = format!( - "Work Item: {} [{}]\nStatus: {}\nPriority: {}\nStarred: {}\nCreated: {}\nUpdated: {}\n", - fm.title, fm.short_id, fm.status, fm.priority, fm.starred, fm.created_at, fm.updated_at, - ); - if let Some(ref project) = fm.project { - output.push_str(&format!("Project: {}\n", project)); - } - if let Some(ref assignee) = fm.assignee { - output.push_str(&format!("Assignee: {}\n", assignee)); - } - if !fm.labels.is_empty() { - output.push_str(&format!("Labels: {}\n", fm.labels.join(", "))); - } - if !item.body.is_empty() { - output.push_str(&format!("\nDescription:\n{}\n", item.body)); - } - if !fm.linked_sessions.is_empty() { - output.push_str(&format!( - "\nLinked Sessions ({}):\n", - fm.linked_sessions.len() - )); - for linked in &fm.linked_sessions { - output.push_str(&format!( - " - {} (role: {:?}, status: {:?})\n", - linked.session_id, linked.agent_role, linked.status - )); - } - } - output - } - - async fn list_standalone_work_items(org_id: Option) -> Result { - run_blocking("list_standalone_work_items", move || { - let mut items = io::read_standalone_work_items(org_id.as_deref())?; - if org_id.is_some() { - items.extend(io::read_standalone_work_items(None)?); - } - if items.is_empty() { - return Ok("No standalone work items found.".to_string()); - } - let mut output = format!("Found {} standalone work item(s):\n", items.len()); - for item in &items { - let fm = &item.frontmatter; - output.push_str(&format!( - "\n- **{}** [{}]\n Status: {} | Priority: {}\n", - fm.title, fm.short_id, fm.status, fm.priority, - )); - } - Ok(output) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - - async fn read_standalone_work_item( - org_id: Option, - short_id: String, - ) -> Result { - run_blocking("read_standalone_work_item", move || { - let (_, item) = Self::read_standalone_scoped(org_id.as_deref(), &short_id)?; - Ok(Self::format_item(&item)) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - - async fn create_standalone_work_item( - org_id: Option, - params: Value, - ) -> Result { - let title = required_string(¶ms, "title")?; - let body = optional_string(¶ms, "description").unwrap_or_default(); - let project = optional_string(¶ms, "project"); - let status = optional_string(¶ms, "status").unwrap_or_else(|| "backlog".to_string()); - let priority = optional_string(¶ms, "priority").unwrap_or_else(|| "none".to_string()); - let assignee = optional_string(¶ms, "assignee"); - let labels = Self::optional_string_array(¶ms, "labels").unwrap_or_default(); - let milestone = optional_string(¶ms, "milestone"); - let parent = optional_string(¶ms, "parent"); - let start_date = optional_string(¶ms, "start_date"); - let target_date = optional_string(¶ms, "target_date"); - let starred = optional_bool(¶ms, "starred").unwrap_or(false); - let todos = Self::optional_todos(¶ms).unwrap_or_default(); - let orchestrator_config = - Self::orchestrator_overrides_from_params(¶ms).map(Self::config_from_overrides); - let schedule = Self::parse_schedule(¶ms); - - run_blocking("create_standalone_work_item", move || { - let short_id = io::allocate_standalone_short_id(org_id.as_deref())?; - // Canonical work.create: the application service owns row - // construction and audits the creation. - let request = project_management::work_service::CreateWorkItemRequest { - title: title.clone(), - body: body.clone(), - project_id: project, - status: Some(status), - priority: Some(priority), - assignee, - assignee_type: None, - labels, - milestone, - parent, - start_date, - target_date, - created_by: Some("agent".to_string()), - starred, - schedule, - orchestrator_config, - todos: Self::todos_to_entries(todos, &[]), - handoff: None, - linked_sessions: vec![], - }; - project_management::work_service::create_standalone_work_item( - org_id.as_deref(), - &short_id, - &request, - None, - )?; - Ok(format!( - "Created standalone work item '{}' [{}]", - title, short_id - )) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - - async fn update_standalone_work_item( - org_id: Option, - short_id: String, - params: Value, - ) -> Result { - run_blocking("update_standalone_work_item", move || { - let (found_org, _) = Self::read_standalone_scoped(org_id.as_deref(), &short_id)?; - // Atomic RMW: apply_updates runs inside the BEGIN IMMEDIATE - // transaction instead of a read + whole-row write (lost-update - // race under concurrent edits). - let mut title = String::new(); - io::update_standalone_work_item_atomic( - found_org.as_deref(), - &short_id, - |frontmatter, body| { - Self::apply_updates(frontmatter, body, ¶ms) - .map_err(|err| err.to_string())?; - title = frontmatter.title.clone(); - Ok(()) - }, - )?; - Ok(format!( - "Updated standalone work item '{}' [{}]", - title, short_id - )) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - - async fn delete_standalone_work_item( - org_id: Option, - short_id: String, - ) -> Result { - run_blocking("delete_standalone_work_item", move || { - let (found_org, _) = Self::read_standalone_scoped(org_id.as_deref(), &short_id)?; - io::update_standalone_work_item_atomic( - found_org.as_deref(), - &short_id, - |frontmatter, _body| { - let now = chrono::Utc::now().to_rfc3339(); - frontmatter.deleted_at = Some(now.clone()); - frontmatter.updated_at = now; - Ok(()) - }, - )?; - Ok(format!("Deleted standalone work item [{}]", short_id)) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - - async fn link_session( - &self, - scope: WorkItemScope, - short_id: String, - params: Value, - ) -> Result { - match scope { - WorkItemScope::Project(project_slug) => { - let session_id = self.session_id.clone(); - run_blocking("link_project_work_item_session", move || { - let mut linked_session_id = String::new(); - io::update_work_item_atomic(&project_slug, &short_id, |fm, _body| { - let tool = WorkItemTool::new(session_id.clone(), None); - linked_session_id = tool - .link_session_to_frontmatter(fm, ¶ms) - .map_err(|err| err.to_string())?; - Ok(()) - })?; - Ok(format!( - "Linked session {} to work item [{}]", - linked_session_id, short_id - )) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - WorkItemScope::Standalone => { - let session_id = self.session_id.clone(); - let default_org_id = self.default_org_id.clone(); - run_blocking("link_standalone_work_item_session", move || { - let (found_org, _) = - Self::read_standalone_scoped(default_org_id.as_deref(), &short_id)?; - let mut linked_session_id = String::new(); - io::update_standalone_work_item_atomic( - found_org.as_deref(), - &short_id, - |frontmatter, _body| { - let tool = WorkItemTool::new(session_id.clone(), None); - linked_session_id = tool - .link_session_to_frontmatter(frontmatter, ¶ms) - .map_err(|err| err.to_string())?; - Ok(()) - }, - )?; - Ok(format!( - "Linked session {} to standalone work item [{}]", - linked_session_id, short_id - )) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - } - } - - async fn unlink_session( - &self, - scope: WorkItemScope, - short_id: String, - params: Value, - ) -> Result { - match scope { - WorkItemScope::Project(project_slug) => { - let session_id = self.session_id.clone(); - run_blocking("unlink_project_work_item_session", move || { - let mut unlinked_session_id = String::new(); - io::update_work_item_atomic(&project_slug, &short_id, |fm, _body| { - let tool = WorkItemTool::new(session_id.clone(), None); - unlinked_session_id = tool.unlink_session_from_frontmatter(fm, ¶ms); - Ok(()) - })?; - Ok(format!( - "Unlinked session {} from work item [{}]", - unlinked_session_id, short_id - )) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - WorkItemScope::Standalone => { - let session_id = self.session_id.clone(); - let default_org_id = self.default_org_id.clone(); - run_blocking("unlink_standalone_work_item_session", move || { - let (found_org, _) = - Self::read_standalone_scoped(default_org_id.as_deref(), &short_id)?; - let mut unlinked_session_id = String::new(); - io::update_standalone_work_item_atomic( - found_org.as_deref(), - &short_id, - |frontmatter, _body| { - let tool = WorkItemTool::new(session_id.clone(), None); - unlinked_session_id = - tool.unlink_session_from_frontmatter(frontmatter, ¶ms); - Ok(()) - }, - )?; - Ok(format!( - "Unlinked session {} from standalone work item [{}]", - unlinked_session_id, short_id - )) - }) - .await - .map_err(ToolError::ExecutionFailed) - } - } - } -} - -#[async_trait] -impl Tool for WorkItemTool { - fn name(&self) -> &str { - tool_names::MANAGE_WORK_ITEM - } - - fn category(&self) -> &str { - crate::tools::categories::PROJECT - } - - fn description(&self) -> &str { - "Manage standalone or project-scoped work items, including linking the current chat session." - } - - fn llm_description(&self) -> Option { - Some( - "Manage work items (tasks, issues, bugs) in the global project store. Omit project_slug for standalone work items. \ - Supports: list, read, create, update, delete, add_delegation, link_session, unlink_session, batch. \ - Use batch for multiple Work Items and put project_slug on each item when operations target different Projects. \ - Use link_session to attach the current chat/session to an existing work item; create may be used first." - .to_string(), - ) - } - - fn parameters(&self) -> Value { - serde_json::json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "description": "The operation to perform.", - "enum": ["list", "read", "create", "update", "delete", "add_delegation", "link_session", "unlink_session", "batch"] - }, - "project_slug": { - "type": "string", - "description": "Optional project identifier. Omit for standalone Work Items that are not bound to a Project." - }, - "short_id": { - "type": "string", - "description": "Work item short ID, e.g. 'ORG-0001' or project-prefixed ID (for read, update, delete, link_session, unlink_session). Use list first to discover IDs." - }, - "session_id": { - "type": "string", - "description": "Session ID to link/unlink. Defaults to the current chat/session." - }, - "session_status": { - "type": "string", - "enum": ["running", "completed", "failed", "cancelled"], - "description": "Status for link_session. Defaults to running." - }, - "agent_role": { - "type": "string", - "enum": ["coding", "review", "orchestrator", "custom", "sub_agent"], - "description": "Role for the linked session. Defaults to custom for chat-panel links." - }, - "title": { "type": "string", "description": "Work item title (required for create, optional for update)" }, - "description": { "type": "string", "description": "Work item body/description (markdown)" }, - "project": { "type": "string", "description": "Project reference stored in frontmatter metadata." }, - "status": { "type": "string", "enum": ["backlog", "planned", "in_progress", "in_review", "completed", "cancelled"] }, - "priority": { "type": "string", "enum": ["urgent", "high", "medium", "low", "none"] }, - "assignee": { "type": "string", "description": "Member ID to assign this work item to. Pass empty string to unassign." }, - "labels": { "type": "array", "items": { "type": "string" } }, - "milestone": { "type": "string", "description": "Milestone ID. Pass empty string to clear." }, - "parent": { "type": "string", "description": "Parent work item short ID. Pass empty string to clear." }, - "start_date": { "type": "string", "description": "Start date (ISO 8601). Pass empty string to clear." }, - "target_date": { "type": "string", "description": "Target/due date (ISO 8601). Pass empty string to clear." }, - "starred": { "type": "boolean" }, - "todos": { - "type": "array", - "items": { - "type": "object", - "properties": { - "content": { "type": "string" }, - "status": { "type": "string", "enum": ["pending", "in_progress", "completed"] } - }, - "required": ["content"] - } - }, - "selected_account_id": { "type": "string" }, - "selected_model_id": { "type": "string" }, - "sub_agent_ids": { "type": "array", "items": { "type": "string" } }, - "org_id": { "type": "string" }, - "agent_definition_id": { "type": "string" }, - "delegation": { - "type": "object", - "properties": { - "task_id": { "type": "string" }, - "agent_app_id": { "type": "string" }, - "agent_app_name": { "type": "string" }, - "skill_id": { "type": "string" }, - "status": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "cancelled"] }, - "cost_usd": { "type": "number" } - } - }, - "schedule": { - "type": "object", - "properties": { - "at": { "type": "string" }, - "cron": { "type": "string" }, - "enabled": { "type": "boolean" } - } - }, - "items": { - "type": "array", - "description": "Batch operations. Each item is a manage_work_item parameter object with its own action. Parent project_slug/session_status/agent_role are inherited when omitted; item project_slug overrides parent for multi-project batches.", - "items": { "type": "object" } - } - }, - "required": ["action"] - }) - } - - async fn execute_text( - &self, - params: Value, - _ctx: &crate::tools::traits::CallContext, - ) -> Result { - let action = required_string(¶ms, "action")?; - if action == "batch" { - return self.execute_batch(¶ms, _ctx).await; - } - let scope = Self::resolve_scope(¶ms)?; - - match action.as_str() { - "list" => match scope { - WorkItemScope::Project(project_slug) => crate::tool_infra::list_work_items(&project_slug) - .await - .map_err(ToolError::ExecutionFailed), - WorkItemScope::Standalone => { - Self::list_standalone_work_items(self.default_org_id.clone()).await - } - }, - "read" => { - let short_id = required_string(¶ms, "short_id")?; - match scope { - WorkItemScope::Project(project_slug) => { - crate::tool_infra::read_work_item(&project_slug, &short_id) - .await - .map_err(ToolError::ExecutionFailed) - } - WorkItemScope::Standalone => { - Self::read_standalone_work_item(self.default_org_id.clone(), short_id) - .await - } - } - } - "create" => match scope { - WorkItemScope::Project(project_slug) => { - let title = required_string(¶ms, "title")?; - crate::tool_infra::create_work_item( - &project_slug, - &title, - optional_string(¶ms, "description").unwrap_or_default().as_str(), - optional_string(¶ms, "project") - .as_deref() - .or(Some(project_slug.as_str())), - optional_string(¶ms, "status").as_deref(), - optional_string(¶ms, "priority").as_deref(), - optional_string(¶ms, "assignee").as_deref(), - None, - Self::optional_string_array(¶ms, "labels"), - optional_string(¶ms, "milestone").as_deref(), - optional_string(¶ms, "parent").as_deref(), - optional_string(¶ms, "start_date").as_deref(), - optional_string(¶ms, "target_date").as_deref(), - optional_bool(¶ms, "starred"), - Self::optional_todos(¶ms), - Self::orchestrator_overrides_from_params(¶ms), - Self::parse_schedule(¶ms), - ) - .await - .map_err(ToolError::ExecutionFailed) - } - WorkItemScope::Standalone => { - Self::create_standalone_work_item(self.default_org_id.clone(), params).await - } - }, - "update" => { - let short_id = required_string(¶ms, "short_id")?; - match scope { - WorkItemScope::Project(project_slug) => { - crate::tool_infra::update_work_item( - &project_slug, - &short_id, - optional_string(¶ms, "title").as_deref(), - optional_string(¶ms, "description").as_deref(), - optional_string(¶ms, "project").as_deref(), - optional_string(¶ms, "status").as_deref(), - optional_string(¶ms, "priority").as_deref(), - optional_string(¶ms, "assignee").as_deref(), - None, - Self::optional_string_array(¶ms, "labels"), - optional_string(¶ms, "milestone").as_deref(), - optional_string(¶ms, "parent").as_deref(), - optional_string(¶ms, "start_date").as_deref(), - optional_string(¶ms, "target_date").as_deref(), - optional_bool(¶ms, "starred"), - Self::optional_todos(¶ms), - Self::orchestrator_overrides_from_params(¶ms), - Self::parse_schedule(¶ms), - ) - .await - .map_err(ToolError::ExecutionFailed) - } - WorkItemScope::Standalone => { - Self::update_standalone_work_item( - self.default_org_id.clone(), - short_id, - params, - ) - .await - } - } - } - "delete" => { - let short_id = required_string(¶ms, "short_id")?; - match scope { - WorkItemScope::Project(project_slug) => { - crate::tool_infra::delete_work_item(&project_slug, &short_id) - .await - .map_err(ToolError::ExecutionFailed) - } - WorkItemScope::Standalone => { - Self::delete_standalone_work_item(self.default_org_id.clone(), short_id) - .await - } - } - } - "link_session" => { - let short_id = required_string(¶ms, "short_id")?; - self.link_session(scope, short_id, params).await - } - "unlink_session" => { - let short_id = required_string(¶ms, "short_id")?; - self.unlink_session(scope, short_id, params).await - } - "add_delegation" => { - let WorkItemScope::Project(project_slug) = scope else { - return Err(ToolError::InvalidParams( - "add_delegation currently requires project_slug because delegation history is project-scoped" - .to_string(), - )); - }; - let short_id = required_string(¶ms, "short_id")?; - let delegation_params = params.get("delegation").ok_or_else(|| { - ToolError::InvalidParams("delegation object is required for add_delegation".to_string()) - })?; - let task_id = delegation_params["task_id"].as_str().ok_or_else(|| { - ToolError::InvalidParams("delegation.task_id is required".to_string()) - })?; - let agent_app_id = delegation_params["agent_app_id"].as_str().ok_or_else(|| { - ToolError::InvalidParams("delegation.agent_app_id is required".to_string()) - })?; - let agent_app_name = delegation_params["agent_app_name"] - .as_str() - .unwrap_or("Unknown Agent"); - let skill_id = delegation_params["skill_id"].as_str().ok_or_else(|| { - ToolError::InvalidParams("delegation.skill_id is required".to_string()) - })?; - let status = delegation_params["status"].as_str().unwrap_or("pending"); - let cost_usd = delegation_params["cost_usd"].as_f64().unwrap_or(0.0); - - io::update_work_item_atomic(&project_slug, &short_id, |frontmatter, _body| { - let entry = project_management::projects::types::DelegationEntry { - task_id: task_id.to_string(), - agent_app_id: agent_app_id.to_string(), - agent_app_name: agent_app_name.to_string(), - skill_id: skill_id.to_string(), - status: status.to_string(), - cost_usd, - created_at: chrono::Utc::now().to_rfc3339(), - completed_at: None, - }; - frontmatter.delegations.push(entry); - frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); - Ok(()) - }) - .map_err(ToolError::ExecutionFailed)?; - - Ok(format!( - "Delegation added to work item {}: task_id={}, agent={} ({}), skill={}, status={}", - short_id, task_id, agent_app_name, agent_app_id, skill_id, status - )) - } - _ => Err(ToolError::InvalidParams(format!( - "Unknown work_item action: '{}'. Valid actions: list, read, create, update, delete, add_delegation, link_session, unlink_session, batch", - action - ))), - } - } -} - -#[cfg(test)] -mod default_org_tests { - use serde_json::json; - - use super::WorkItemTool; - - #[test] - fn personal_org_default_normalizes_to_none() { - let tool = WorkItemTool::new("s".to_string(), Some("personal-org".to_string())); - assert!(tool.default_org_id.is_none()); - } - - #[test] - fn collab_org_default_is_kept() { - let tool = WorkItemTool::new("s".to_string(), Some("org-1".to_string())); - assert_eq!(tool.default_org_id.as_deref(), Some("org-1")); - } - - #[test] - fn blank_structured_output_session_id_falls_back_to_current_session() { - let tool = WorkItemTool::new("session-42".to_string(), None); - - assert_eq!( - tool.linked_session_id(&json!({ "session_id": "" })), - "session-42" - ); - assert_eq!( - tool.linked_session_id(&json!({ "session_id": "explicit" })), - "explicit" - ); - } -} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs deleted file mode 100644 index e4805a4544..0000000000 --- a/src-tauri/crates/agent-core/src/core/tools/impls/project/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Project tools — global project store lifecycle and per-project work-item CRUD. -//! -//! - [`manage_project`] — `manage_project` (project metadata, init, list) -//! - [`manage_work_item`] — `manage_work_item` (work-item CRUD under the project) -//! -//! Category: [`tool_categories::PROJECT`]. -//! -//! [`tool_categories::PROJECT`]: crate::tools::categories::PROJECT - -pub mod manage_project; -pub mod manage_work_item; diff --git a/src-tauri/crates/agent-core/src/core/tools/policy.rs b/src-tauri/crates/agent-core/src/core/tools/policy.rs index ce61a13c84..6f2d68390a 100644 --- a/src-tauri/crates/agent-core/src/core/tools/policy.rs +++ b/src-tauri/crates/agent-core/src/core/tools/policy.rs @@ -73,12 +73,7 @@ pub const TOOL_GROUPS: &[(&str, &[&str])] = &[ ), ( "group:project", - &[ - tool_names::MANAGE_PROJECT, - tool_names::MANAGE_WORK_ITEM, - tool_names::AGENT, - tool_names::MANAGE_WORKSPACE, - ], + &[tool_names::AGENT, tool_names::MANAGE_WORKSPACE], ), ("group:search", &[tool_names::CODE_SEARCH]), ( @@ -274,38 +269,6 @@ impl ResolvedToolPolicy { } } - /// Product-mode deny-delta (`orgtrack/v1` §5.1): only `project` - /// sessions expose the persistent WorkItem/Routine mutation surface; - /// every other product mode (build/plan/ask, or unset = build) - /// subtracts the PM tools. Deny-delta like exec modes — product mode - /// never grants tools, so switching to Project cannot escalate an - /// actor beyond what its definition/policy already allows. - pub fn product_mode_layer(product_mode: Option<&str>) -> Option { - if product_mode == Some("project") { - return None; - } - Some(ToolPolicyLayer::deny_only(vec![ - crate::tools::names::MANAGE_WORK_ITEM.to_string(), - crate::tools::names::MANAGE_PROJECT.to_string(), - ])) - } - - /// The full per-turn composition: exec-mode overlay + product-mode - /// overlay. Same single-composition-point contract as - /// [`Self::with_exec_mode`] — the per-turn executor and the - /// effective-tools RPC both call this. - pub fn with_modes( - &self, - exec_mode: crate::session::AgentExecMode, - product_mode: Option<&str>, - ) -> Self { - let composed = self.with_exec_mode(exec_mode); - match Self::product_mode_layer(product_mode) { - Some(layer) => composed.with_extra_layer(layer), - None => composed, - } - } - /// Create a new policy with an additional layer appended. /// /// Used by agent modes (plan, explore) to add mode-specific restrictions diff --git a/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs b/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs index a27d590899..3f289d9aca 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registration/agent_ops.rs @@ -1,5 +1,5 @@ //! Agent operations tool registration: session, comms, question, nodes, -//! project, agent definition. +//! agent definition. //! //! Note: The unified `agent` tool is registered separately in OS/SDE init //! because it requires additional dependencies (LLM provider). @@ -15,8 +15,6 @@ use crate::tools::impls::nodes::manage_nodes::NodesTool; use crate::tools::impls::orchestration::ask_user_questions::{QuestionTool, QuestionToolContext}; use crate::tools::impls::orchestration::manage_secrets::{SecretTool, SecretToolContext}; use crate::tools::impls::orchestration::manage_session::SessionTool; -use crate::tools::impls::project::manage_project::ProjectTool; -use crate::tools::impls::project::manage_work_item::WorkItemTool; use crate::tools::registry::ToolRegistry; use super::{register_if_enabled, ToolDeps}; @@ -30,8 +28,7 @@ fn should_register_question_tool( /// Register all agent-operations tools that `deps` can support. /// -/// Covers: `session`, `message`, `question`, `nodes`, -/// `project`, `agent_definition`. +/// Covers: `session`, `message`, `question`, `nodes`, `agent_definition`. /// /// Note: The unified `agent` tool is registered separately in OS/SDE agent init /// because it requires an LLM provider which is created at agent initialization time. @@ -88,26 +85,6 @@ pub fn register(registry: &mut ToolRegistry, deps: &ToolDeps, disabled: &HashSet ); } - // ── Project / work items ── - register_if_enabled( - registry, - Box::new(ProjectTool::new( - deps.app_handle.clone(), - deps.session_account_id.clone(), - deps.agent_model.clone(), - deps.session_org_id.clone(), - )), - disabled, - ); - register_if_enabled( - registry, - Box::new(WorkItemTool::new( - deps.session_id.clone(), - deps.session_org_id.clone(), - )), - disabled, - ); - // ── Agent definition ── if let Some(ref handle) = deps.app_handle { register_if_enabled( diff --git a/src-tauri/crates/agent-core/src/core/tools/registration/mod.rs b/src-tauri/crates/agent-core/src/core/tools/registration/mod.rs index ccb8777af4..fb550394b8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/registration/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/registration/mod.rs @@ -121,8 +121,6 @@ pub struct ToolDeps { /// from member; `agent_id` can be shared by multiple participants. pub agent_org_current_member_id: Option, - pub session_org_id: Option, - // ── Channel workspace ── /// Origin channel context — set for channel-attached sessions (OS agent /// serving external channels). Provides the source channel and chat_id diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/base_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/base_tests.rs index c64e464d90..c67c80672e 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/base_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/base_tests.rs @@ -253,14 +253,6 @@ fn search_tool_llm_description_contains_repo() { assert!(desc.contains("Search code")); } -#[test] -fn work_item_tool_llm_description_mentions_global_store() { - use crate::tools::impls::project::manage_work_item::WorkItemTool; - let tool = WorkItemTool::new("test-session".to_string(), None); - let desc = tool.llm_description().expect("should return description"); - assert!(desc.contains("global project store")); -} - #[test] fn mode_switch_llm_description_none_without_mode() { use crate::interaction::mode_switch::ModeSwitchManager; diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs index 1d299b6087..0426d04182 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/defaults_tests.rs @@ -15,12 +15,7 @@ use std::collections::HashSet; #[test] fn management_tools_are_not_supported_on_sde_worker_kind() { - for tool_name in [ - tool_names::MANAGE_SESSION, - tool_names::MANAGE_PROJECT, - tool_names::MANAGE_WORK_ITEM, - tool_names::MANAGE_AGENT_DEF, - ] { + for tool_name in [tool_names::MANAGE_SESSION, tool_names::MANAGE_AGENT_DEF] { let agents = supported_agents_for(tool_name); assert!(agents.contains(&AgentKind::Os), "{tool_name} on OS"); assert!(agents.contains(&AgentKind::Custom), "{tool_name} on Custom"); @@ -36,8 +31,6 @@ fn non_management_tools_supported_on_every_parent_agent_kind() { !matches!( entry.name, tool_names::MANAGE_SESSION - | tool_names::MANAGE_PROJECT - | tool_names::MANAGE_WORK_ITEM | tool_names::MANAGE_AGENT_DEF | tool_names::CONTROL_ORGII ) @@ -122,7 +115,7 @@ fn derive_capabilities_no_longer_gate_tools() { "control_desktop_with_peekaboo must NOT be disabled just because desktop capability is absent" ); assert!( - !disabled.contains(tool_names::MANAGE_PROJECT), + !disabled.contains(tool_names::MANAGE_SESSION), "derive_disabled_tools only applies explicit restrict/exclude lists; capability defaults own management gating" ); } diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs index 08ec2c6c18..f637b730d2 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs @@ -36,8 +36,6 @@ fn invokable_canonical_tool_names() -> BTreeSet<&'static str> { names::SPOTLIGHT, names::REPLY_SESSION_COMMENT, names::AGENT, - names::MANAGE_PROJECT, - names::MANAGE_WORK_ITEM, names::MANAGE_AGENT_DEF, names::MANAGE_NODES, names::ASK_USER_QUESTIONS, @@ -171,8 +169,6 @@ fn every_renderable_tool_has_non_default_chat_block() { names::CONTROL_ORGII, names::SPOTLIGHT, names::REPLY_SESSION_COMMENT, - names::MANAGE_PROJECT, - names::MANAGE_WORK_ITEM, names::MANAGE_AGENT_DEF, names::MANAGE_NODES, names::ASK_USER_QUESTIONS, @@ -225,24 +221,6 @@ fn every_builtin_tool_has_detail_text() { } } -#[test] -fn project_tools_route_to_project_manager() { - let tools = builtin_tool_entries("builtin".into()); - for tool_name in [names::MANAGE_PROJECT, names::MANAGE_WORK_ITEM] { - let tool = tools - .iter() - .find(|entry| entry.name == tool_name) - .unwrap_or_else(|| panic!("missing project tool metadata for {tool_name}")); - - assert_eq!( - tool.simulator_app, - SimulatorApp::ProjectManager, - "{tool_name}" - ); - assert_eq!(tool.app_subtool, AppSubtool::Project, "{tool_name}"); - } -} - #[test] fn internal_tool_calls_route_to_code_editor_other_tool_usage() { let tools = builtin_tool_entries("builtin".into()); diff --git a/src-tauri/crates/agent-core/src/foundation/persistence/test_schema.rs b/src-tauri/crates/agent-core/src/foundation/persistence/test_schema.rs index fd152da354..adfb244c66 100644 --- a/src-tauri/crates/agent-core/src/foundation/persistence/test_schema.rs +++ b/src-tauri/crates/agent-core/src/foundation/persistence/test_schema.rs @@ -43,6 +43,7 @@ pub(crate) const AGENT_SESSIONS_TEST_DDL: &str = r#" workspace_additional_json TEXT NOT NULL DEFAULT '{}', key_source TEXT NOT NULL DEFAULT 'own_key', agent_exec_mode TEXT, + product_mode TEXT, native_harness_type TEXT, draft_text TEXT, reply_target_event_id TEXT, @@ -143,6 +144,7 @@ pub(crate) fn ensure_agent_sessions_schema(conn: &rusqlite::Connection) { ("reply_target_event_id", "reply_target_event_id TEXT"), ("pinned", "pinned INTEGER NOT NULL DEFAULT 0"), ("agent_exec_mode", "agent_exec_mode TEXT"), + ("product_mode", "product_mode TEXT"), ( "workspace_additional_json", "workspace_additional_json TEXT NOT NULL DEFAULT '{}'", diff --git a/src-tauri/crates/agent-core/src/foundation/session_bridge.rs b/src-tauri/crates/agent-core/src/foundation/session_bridge.rs index 5dbfb5245d..f945a3fbe2 100644 --- a/src-tauri/crates/agent-core/src/foundation/session_bridge.rs +++ b/src-tauri/crates/agent-core/src/foundation/session_bridge.rs @@ -62,6 +62,10 @@ pub struct CliLaunchParams { pub project_slug: Option, pub work_item_id: Option, pub agent_role: Option, + /// Product-mode axis (orgtrack/v1 §5.2), persisted on the CLI session + /// row so Project-mode CLI sessions bootstrap a root Work Item and + /// get the `work.mutate` capability surface. + pub product_mode: Option, // Run-side params pub user_input: String, diff --git a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs index df4d92e993..cdb07f9265 100644 --- a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/mod.rs @@ -1,7 +1,6 @@ //! Shared project service: project + work-item operations. //! //! Used by: -//! - Agent `ProjectTool` //! - Tauri commands for frontend project/work-item operations //! //! Wraps `project_management::projects::io` (global SQLite store) diff --git a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/tests/mod.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/tests/mod.rs index 6040949df5..591321f870 100644 --- a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/tests/mod.rs +++ b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/tests/mod.rs @@ -18,6 +18,7 @@ fn make_frontmatter(title: &str, todos: Vec) -> WorkItemFrontmatter { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/work_items.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/work_items.rs index d8350b4678..70a4e821fb 100644 --- a/src-tauri/crates/agent-core/src/foundation/tool_infra/project/work_items.rs +++ b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/work_items.rs @@ -328,6 +328,7 @@ pub async fn create_work_item( // Canonical work.create: the application service owns row // construction and audits the creation. let request = project_management::work_service::CreateWorkItemRequest { + stage: None, title: title.clone(), body: body.clone(), project_id: project, diff --git a/src-tauri/crates/agent-core/src/init/mod.rs b/src-tauri/crates/agent-core/src/init/mod.rs index 5377c5b946..d629e8e07f 100644 --- a/src-tauri/crates/agent-core/src/init/mod.rs +++ b/src-tauri/crates/agent-core/src/init/mod.rs @@ -464,7 +464,6 @@ async fn ensure_session_initialized( let agent_org_current_member_id = session_record .as_ref() .and_then(|record| record.org_member_id.clone()); - let session_org_id = session_record.and_then(|record| record.org_id); let mut readonly_extra_dirs = vec![crate::skills::loader::global_skills_dir()]; readonly_extra_dirs.extend( @@ -512,7 +511,6 @@ async fn ensure_session_initialized( plan_slot_cache: Some(session_handle.plan_slot_cache.clone()), agent_org_context: agent_org_context.clone(), agent_org_current_member_id: agent_org_current_member_id.clone(), - session_org_id, channel_context: None, }; diff --git a/src-tauri/crates/agent-core/src/state/commands/routines.rs b/src-tauri/crates/agent-core/src/state/commands/routines.rs index eb66d8326a..696e8d33d3 100644 --- a/src-tauri/crates/agent-core/src/state/commands/routines.rs +++ b/src-tauri/crates/agent-core/src/state/commands/routines.rs @@ -232,6 +232,7 @@ async fn create_work_item_from_routine( .unwrap_or_else(|| routine.run_template.prompt.clone()); let frontmatter = types::WorkItemFrontmatter { + stage: None, id: short_id.clone(), short_id: short_id.clone(), title, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/create.rs b/src-tauri/crates/agent-core/src/state/commands/session/create.rs index 4bcc057b0d..97a633dd8f 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/create.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/create.rs @@ -152,6 +152,7 @@ pub(crate) async fn create_session_impl( native_harness_type: resolved_native_harness_type, ..Default::default() }; + let resolved_product_mode = session.product_mode.clone(); tokio::task::spawn_blocking(move || session_persistence::upsert_session(&session)) .await @@ -220,5 +221,6 @@ pub(crate) async fn create_session_impl( Ok(serde_json::json!({ "sessionId": session_id, "workspacePath": workspace_path, + "productMode": resolved_product_mode, })) } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs b/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs index a5b7eef303..12d4271b40 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs @@ -156,12 +156,9 @@ pub async fn debug_session_execute_tool( tool_name: String, params: Value, ) -> Result { - if !matches!( - tool_name.as_str(), - names::READ_FILE | names::MANAGE_PROJECT | names::MANAGE_WORK_ITEM - ) { + if tool_name.as_str() != names::READ_FILE { return Err(format!( - "debug_session_execute_tool only allows audited tools [read_file, manage_project, manage_work_item]; got '{tool_name}'" + "debug_session_execute_tool only allows audited tools [read_file]; got '{tool_name}'" )); } @@ -178,9 +175,9 @@ pub async fn debug_session_execute_tool( .ok_or_else(|| format!("session runtime not initialized: {session_id}"))?; // Enforce the same per-turn policy composition the LLM path uses - // (exec-mode + product-mode layers over the base policy). Without - // this the debug hook silently bypasses the PM deny-delta and E2E - // runs prove nothing about the gated surface. + // (exec-mode layer over the base policy). Without this the debug + // hook silently bypasses mode restrictions and E2E runs prove + // nothing about the gated surface. let session_record = crate::session::persistence::get_session(&session_id) .map_err(|err| format!("failed to load session record {session_id}: {err}"))?; let record_exec_mode = session_record @@ -188,14 +185,11 @@ pub async fn debug_session_execute_tool( .and_then(|record| record.agent_exec_mode.as_deref()) .and_then(crate::session::AgentExecMode::parse) .unwrap_or_default(); - let product_mode = session_record - .as_ref() - .and_then(|record| record.product_mode.as_deref()); - let effective_policy = runtime.policy.with_modes(record_exec_mode, product_mode); + let effective_policy = runtime.policy.with_exec_mode(record_exec_mode); if !effective_policy.is_allowed(&tool_name) { return Err(format!( "tool '{tool_name}' is denied by the session's effective policy \ - (exec_mode={record_exec_mode:?}, product_mode={product_mode:?})" + (exec_mode={record_exec_mode:?})" )); } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/debug/prompt.rs b/src-tauri/crates/agent-core/src/state/commands/session/debug/prompt.rs index d3f80a56a1..0623008861 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/debug/prompt.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/debug/prompt.rs @@ -182,6 +182,10 @@ pub async fn prompt_dump( channel: None, chat_id: None, agent_mode: None, + product_mode: crate::session::persistence::get_session(&session_id) + .ok() + .flatten() + .and_then(|record| record.product_mode), ide_context: None, user_presence: None, user_profile: None, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/launch.rs b/src-tauri/crates/agent-core/src/state/commands/session/launch.rs index 87640c8cfb..ba97fe6cda 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/launch.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/launch.rs @@ -121,6 +121,7 @@ pub struct SessionLaunchResult { pub project_slug: Option, pub work_item_id: Option, pub agent_role: Option, + pub product_mode: Option, pub worktree_path: Option, pub worktree_branch: Option, pub base_ref: Option, @@ -286,6 +287,7 @@ async fn launch_rust_agent( project_slug: result.project_slug, work_item_id: result.work_item_id, agent_role: result.agent_role, + product_mode: result.product_mode, worktree_path: result.worktree_path, worktree_branch: result.worktree_branch, base_ref: result.base_ref, @@ -384,6 +386,7 @@ async fn launch_cli_agent( project_slug: project_slug.clone(), work_item_id: work_item_id.clone(), agent_role: agent_role.clone(), + product_mode: params.product_mode.clone(), user_input: params.content, ide_context: params.ide_context, mode: params.mode, @@ -419,6 +422,7 @@ async fn launch_cli_agent( project_slug, work_item_id, agent_role, + product_mode: params.product_mode, worktree_path, worktree_branch, base_ref, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs index 93736b4939..95ab0cf4c7 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs @@ -11,14 +11,6 @@ //! earlier attempt created the item but failed to link it, the replay //! returns the stored short id and only the link is re-applied. -use project_management::projects::types::WorkItemMutationActor; -use project_management::work_service::{ - run_idempotent, CreateWorkItemRequest, IdempotencyOutcome, -}; - -const BOOTSTRAP_OPERATION: &str = "work.bootstrap"; -const BOOTSTRAP_TITLE_MAX_CHARS: usize = 80; - /// Best-effort bootstrap called from the message-accept path. Failures /// are logged, never turned into a turn error — a broken PM store must /// not take chat down with it. @@ -48,18 +40,6 @@ pub(super) async fn ensure_project_root_work_item(session_id: &str, content: &st } } -/// Derive the short UI title from the first line of the request. The -/// original request stays in the body untouched. -fn derive_title(content: &str) -> String { - let first_line = content.trim().lines().next().unwrap_or("").trim(); - let title = crate::utils::safe_truncate_chars_to_string(first_line, BOOTSTRAP_TITLE_MAX_CHARS); - if title.is_empty() { - "Untitled project".to_string() - } else { - title - } -} - /// Blocking core, also driven directly by the `Track this` command — /// there the "first accepted submission" already happened, so the root /// is created from the recorded user input at conversion time. @@ -76,73 +56,12 @@ pub(crate) fn bootstrap_root_work_item( return Ok(None); } - // The standalone store's org FK only accepts rows that exist in the - // local `orgs` table. Session rows carry looser scopes: the implicit - // personal org (`personal-org`, no row — same normalization as - // `WorkItemTool::new`) and cloud sidebar scopes (`cloud:`, - // also not local rows). Anything without a local org row falls back - // to the NULL (personal) standalone scope instead of failing the - // insert. - let org_id = record - .org_id - .clone() - .filter(|org| org != project_management::projects::types::PERSONAL_ORG_ID) - .filter(|org| { - project_management::projects::io::read_project_orgs() - .map(|orgs| orgs.iter().any(|row| &row.id == org)) - .unwrap_or(false) - }); - let session_ref = format!("org2:{session_id}"); - let actor = WorkItemMutationActor { - id: session_ref.clone(), - name: "ORG2 host".to_string(), - }; - let scope_id = org_id.clone().unwrap_or_else(|| "standalone".to_string()); - - // Canonical request deliberately excludes the message content: the - // key is "this session's root", and a retry after a create-then- - // link-failure may arrive with different content but must replay - // the SAME stored root instead of conflicting or duplicating. - let canonical = serde_json::json!({ "sessionRef": session_ref }); - let org_for_execute = org_id.clone(); - let title = derive_title(content); - let body = content.to_string(); - let actor_for_execute = actor.clone(); - let outcome = run_idempotent( - &session_ref, - BOOTSTRAP_OPERATION, - &scope_id, + let short_id = project_management::work_service::bootstrap_root_standalone_item( session_id, - &canonical, - move || { - let short_id = project_management::projects::io::allocate_standalone_short_id( - org_for_execute.as_deref(), - )?; - let request = CreateWorkItemRequest { - title, - body, - created_by: Some(actor_for_execute.id.clone()), - ..Default::default() - }; - project_management::work_service::create_standalone_work_item( - org_for_execute.as_deref(), - &short_id, - &request, - Some(&actor_for_execute), - )?; - Ok(serde_json::json!({ "shortId": short_id })) - }, + record.org_id.as_deref(), + content, )?; - let response = match outcome { - IdempotencyOutcome::Fresh(value) | IdempotencyOutcome::Replayed(value) => value, - }; - let short_id = response - .get("shortId") - .and_then(|value| value.as_str()) - .ok_or_else(|| format!("bootstrap response missing shortId: {response}"))? - .to_string(); - crate::session::persistence::link_bootstrap_work_item(session_id, &short_id) .map_err(|err| format!("link bootstrap work item: {err}"))?; Ok(Some(short_id)) diff --git a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs index 6cd8d59f38..a785a55d90 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/persistence.rs @@ -13,7 +13,6 @@ use crate::session::{SessionListFilter, SessionStatus}; use crate::state::control_flow::CancelReason; use crate::state::{AgentAppState, AgentSession}; use crate::tools::file_history; -use core_types::workflow::{AgentRole, LinkedSession, LinkedSessionStatus, LinkedSessionType}; use database::db::{get_connection, with_sessions_writer}; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; @@ -993,7 +992,12 @@ fn link_session_to_work_item_sync( .then_some(()) .ok_or_else(|| format!("Session not found: {session_id}"))?; - upsert_linked_session_on_work_item(project_slug, work_item_id, &session, agent_role)?; + session_persistence::linked_work_item::upsert_linked_session_on_work_item( + project_slug, + work_item_id, + &session, + agent_role, + )?; session_persistence::get_session(session_id) .map_err(|err| err.to_string())? @@ -1022,107 +1026,6 @@ fn remove_linked_session_from_work_item( .map(|_| ()) } -fn upsert_linked_session_on_work_item( - project_slug: &str, - work_item_id: &str, - session: &session_persistence::UnifiedSessionRecord, - agent_role: Option<&str>, -) -> Result<(), String> { - project_management::projects::io::update_work_item_atomic( - project_slug, - work_item_id, - |frontmatter, _body| { - let linked = linked_session_from_record(session, agent_role); - match frontmatter - .linked_sessions - .iter_mut() - .find(|candidate| candidate.session_id == session.session_id) - { - Some(existing) => { - existing.session_type = linked.session_type; - existing.agent_role = linked.agent_role; - existing.status = linked.status; - existing.completed_at = linked.completed_at; - existing.total_tokens = linked.total_tokens; - if existing.result_preview.is_none() { - existing.result_preview = linked.result_preview; - } - } - None => frontmatter.linked_sessions.push(linked), - } - frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); - Ok(()) - }, - ) - .map(|_| ()) -} - -fn linked_session_from_record( - session: &session_persistence::UnifiedSessionRecord, - agent_role: Option<&str>, -) -> LinkedSession { - let status = linked_session_status(&session.status); - let completed_at = matches!( - status, - LinkedSessionStatus::Completed - | LinkedSessionStatus::Failed - | LinkedSessionStatus::Cancelled - ) - .then(|| session.updated_at.clone()); - LinkedSession { - session_id: session.session_id.clone(), - session_type: linked_session_type(&session.session_type), - agent_role: parse_agent_role(agent_role.or(session.agent_role.as_deref())), - started_at: session.created_at.clone(), - completed_at, - status, - cost_usd: 0.0, - total_tokens: session.total_tokens.max(0) as u64, - parent_session_id: session.parent_session_id.clone(), - sub_agent_name: None, - sub_agent_instance: None, - result_preview: session - .name - .is_empty() - .then(|| session.user_input.clone()) - .flatten() - .or_else(|| Some(session.name.clone())), - } -} - -fn linked_session_status(raw: &str) -> LinkedSessionStatus { - match SessionStatus::parse(raw) { - Some(SessionStatus::Failed) => LinkedSessionStatus::Failed, - Some(SessionStatus::Cancelled | SessionStatus::Abandoned | SessionStatus::Timeout) => { - LinkedSessionStatus::Cancelled - } - Some( - SessionStatus::Running | SessionStatus::WaitingForUser | SessionStatus::WaitingForFunds, - ) => LinkedSessionStatus::Running, - _ => LinkedSessionStatus::Completed, - } -} - -fn linked_session_type(session_type: &str) -> LinkedSessionType { - match session_type { - session_persistence::session_type::CODING - | session_persistence::session_type::GENERIC - | session_persistence::session_type::DESKTOP - | session_persistence::session_type::SUBAGENT - | session_persistence::session_type::ORG_MEMBER => LinkedSessionType::Native, - _ => LinkedSessionType::Native, - } -} - -fn parse_agent_role(raw: Option<&str>) -> AgentRole { - match raw.unwrap_or_default() { - "review" => AgentRole::Review, - "orchestrator" => AgentRole::Orchestrator, - "custom" => AgentRole::Custom, - "sub_agent" => AgentRole::SubAgent, - _ => AgentRole::Coding, - } -} #[cfg(test)] mod tests { diff --git a/src-tauri/crates/agent-core/src/state/commands/tools.rs b/src-tauri/crates/agent-core/src/state/commands/tools.rs index 684e3a7713..2b6257deae 100644 --- a/src-tauri/crates/agent-core/src/state/commands/tools.rs +++ b/src-tauri/crates/agent-core/src/state/commands/tools.rs @@ -231,10 +231,7 @@ pub async fn list_effective_tools_for_session( .and_then(|record| record.agent_exec_mode.as_deref()) }); let agent_exec_mode = resolve_agent_mode(mode_source)?; - let product_mode = session_record - .as_ref() - .and_then(|record| record.product_mode.as_deref()); - let effective_policy = runtime.policy.with_modes(agent_exec_mode, product_mode); + let effective_policy = runtime.policy.with_exec_mode(agent_exec_mode); let mut registered_tool_names = runtime.tool_registry.tool_names(); registered_tool_names.sort(); diff --git a/src-tauri/crates/e2e-test/src/sde/agent_definition.rs b/src-tauri/crates/e2e-test/src/sde/agent_definition.rs index a66e7c11f1..dea97718f1 100644 --- a/src-tauri/crates/e2e-test/src/sde/agent_definition.rs +++ b/src-tauri/crates/e2e-test/src/sde/agent_definition.rs @@ -9,20 +9,14 @@ use agent_core::definitions::builtin::ADE_MANAGER_ID; use agent_core::definitions::{OS_AGENT_ID, SDE_AGENT_ID}; use agent_core::tools::names::{ ASK_USER_QUESTIONS, CONTROL_BROWSER_WITH_PLAYWRIGHT, CONTROL_DESKTOP_WITH_PEEKABOO, - CONTROL_ORGII, EDIT_FILE, MANAGE_AGENT_DEF, MANAGE_PROJECT, MANAGE_SESSION, MANAGE_WORK_ITEM, - READ_FILE, RUN_SHELL, + CONTROL_ORGII, EDIT_FILE, MANAGE_AGENT_DEF, MANAGE_SESSION, READ_FILE, RUN_SHELL, }; use super::tmp_workspace_path; use crate::config::Config; use crate::harness; -const MANAGEMENT_TOOLS: [&str; 4] = [ - MANAGE_SESSION, - MANAGE_PROJECT, - MANAGE_WORK_ITEM, - MANAGE_AGENT_DEF, -]; +const MANAGEMENT_TOOLS: [&str; 2] = [MANAGE_SESSION, MANAGE_AGENT_DEF]; pub async fn agent_definition_sde_endpoint_rejects_missing_explicit_definition( cfg: &Config, diff --git a/src-tauri/crates/orgtrack-pm-cli/Cargo.toml b/src-tauri/crates/orgtrack-pm-cli/Cargo.toml index acf9b520dd..1c7369a350 100644 --- a/src-tauri/crates/orgtrack-pm-cli/Cargo.toml +++ b/src-tauri/crates/orgtrack-pm-cli/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] test_helpers = { path = "../test-helpers" } +jsonschema = "0.26" rusqlite = { workspace = true } # database is already a normal dependency; integration tests reuse it for # sandbox schema init. diff --git a/src-tauri/crates/orgtrack-pm-cli/src/commands.rs b/src-tauri/crates/orgtrack-pm-cli/src/commands.rs index 40ccc4cd8c..a693cca5d2 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/commands.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands.rs @@ -12,14 +12,14 @@ //! `open -> in_progress` transition; the lock is rolled back when the //! transition is rejected. Single-transaction claim replaces this //! composition when the claim service handler lands. -//! - `--idempotency-key` is accepted but not yet deduplicated +//! - `--idempotency-key` deduplicates every mutation via `pm_idempotency` //! (`pm_idempotency` wiring is the next slice). use std::collections::HashMap; use project_management::projects::io as pio; use project_management::projects::types::{ - WorkItemData, WorkItemExecutionLockReason, WorkItemMutationActor, WorkItemPartialUpdate, + WorkItemData, WorkItemExecutionLockReason, WorkItemMutationActor, WorkItemSchedule, }; use project_management::work_service; @@ -56,9 +56,11 @@ pub fn dispatch_work( ) -> i32 { match positionals.first().map(String::as_str) { Some("list") => cmd_work_list(context, flags), - Some("show") => cmd_work_show(context, positionals.get(1)), + Some("show") => cmd_work_show(context, positionals.get(1), flags), Some("create") => cmd_work_create(context, flags), Some("update") => cmd_work_update(context, positionals.get(1), flags), + Some("assign") => cmd_work_assign(context, positionals.get(1), flags), + Some("release") => cmd_work_release(context, positionals.get(1), flags), Some("claim") => cmd_work_claim(context, positionals.get(1), flags), Some("transition") => cmd_work_transition(context, positionals.get(1), flags), Some("note") => cmd_work_note(context, positionals.get(1), flags), @@ -98,6 +100,42 @@ fn guarded( } } +/// Body text from `--body` or `--body-file ` (file wins). Shell +/// quoting mangles backticks/`$()` in inline bodies; agents write the +/// body to a file and pass the path instead. +fn resolve_body_flag(flags: &HashMap) -> Result, CliError> { + if let Some(path) = flags.get("body-file").filter(|value| !value.trim().is_empty()) { + return std::fs::read_to_string(path) + .map(Some) + .map_err(|err| { + CliError::new( + ErrorCode::InvalidArgument, + format!("--body-file {path}: {err}"), + ) + }); + } + Ok(flags.get("body").cloned()) +} + +/// Route a bare short id to the org's standalone store when it cannot be +/// served from a project scope: either no scope resolves at all, or the +/// resolved project has no such item while a standalone row exists. Lets +/// a session bound to a standalone root item (Project-mode bootstrap) +/// address it without knowing the `--standalone` flag. +fn standalone_fallback_item( + context: &ExecutionContext, + short_id: &str, +) -> Option { + let org = context.org_id.as_deref(); + match context.require_scope() { + Err(_) => pio::read_standalone_work_item(org, short_id).ok(), + Ok(scope) => match pio::read_work_item(scope, short_id) { + Ok(_) => None, + Err(_) => pio::read_standalone_work_item(org, short_id).ok(), + }, + } +} + fn require_short_id(short_id: Option<&String>) -> Result { short_id.cloned().ok_or_else(|| { CliError::new( @@ -108,27 +146,52 @@ fn require_short_id(short_id: Option<&String>) -> Result { } fn cmd_work_list(context: &ExecutionContext, flags: &HashMap) -> i32 { - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), - Err(err) => return emit_error(err), + let items = if flags.contains_key("standalone") { + match pio::read_standalone_work_items(context.org_id.as_deref()) { + Ok(items) => items, + Err(err) => return emit_error(CliError::from_service(err)), + } + } else { + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; + match pio::read_all_work_items(&scope) { + Ok(items) => items, + Err(err) => return emit_error(CliError::from_service(err)), + } }; - let items = match pio::read_all_work_items(&scope) { - Ok(items) => items, - Err(err) => return emit_error(CliError::from_service(err)), + let status_filter = match flags.get("status") { + None => None, + Some(raw) => match parse_portable_state(raw) { + Ok(state) => Some(state), + Err(err) => return emit_error(err), + }, }; - let status_filter = flags.get("status"); let ready_only = flags.contains_key("ready"); let limit: usize = flags .get("limit") .and_then(|value| value.parse().ok()) .unwrap_or(50); + let cursor = flags.get("cursor").cloned(); - let filtered: Vec = items - .iter() + let mut sorted: Vec<_> = items.iter().collect(); + sorted.sort_by(|a, b| a.frontmatter.short_id.cmp(&b.frontmatter.short_id)); + let mut matched: Vec<&_> = sorted + .into_iter() .filter(|item| item.frontmatter.deleted_at.is_none()) + .filter(|item| { + cursor + .as_deref() + .map(|last| item.frontmatter.short_id.as_str() > last) + .unwrap_or(true) + }) .filter(|item| { status_filter - .map(|status| &item.frontmatter.status == status) + .map(|state| { + work_service::state::map_legacy_status(&item.frontmatter.status) + == Some(state) + }) .unwrap_or(true) }) .filter(|item| { @@ -149,22 +212,70 @@ fn cmd_work_list(context: &ExecutionContext, flags: &HashMap) -> .is_none(); open && unclaimed }) - .take(limit) - .map(|item| item_to_wire(item, None)) .collect(); + let next_cursor = if matched.len() > limit { + matched + .get(limit - 1) + .map(|item| item.frontmatter.short_id.clone()) + } else { + None + }; + matched.truncate(limit); + let filtered: Vec = + matched.iter().map(|item| item_to_wire(item, None)).collect(); - emit_success(serde_json::json!({ "items": filtered }), None, None) + emit_success(serde_json::json!({ "items": filtered }), None, next_cursor) } -fn cmd_work_show(context: &ExecutionContext, short_id: Option<&String>) -> i32 { - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), - Err(err) => return emit_error(err), - }; +fn parse_portable_state(raw: &str) -> Result { + use work_service::WorkItemState::*; + match raw { + "open" => Ok(Open), + "in_progress" => Ok(InProgress), + "blocked" => Ok(Blocked), + "completed" => Ok(Completed), + "failed" => Ok(Failed), + "cancelled" => Ok(Cancelled), + other => Err(CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown state '{}'; expected open|in_progress|blocked|completed|failed|cancelled", + other + ), + )), + } +} + +fn cmd_work_show( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { let short_id = match require_short_id(short_id) { Ok(short_id) => short_id, Err(err) => return emit_error(err), }; + if flags.contains_key("standalone") { + let org = context.org_id.as_deref(); + let item = match pio::read_standalone_work_item(org, &short_id) { + Ok(item) => item, + Err(err) => return emit_error(CliError::from_service(err)), + }; + let wire = item_to_wire(&item, None); + return emit_success(wire, None, None); + } + if let Some(item) = standalone_fallback_item(context, &short_id) { + let relations = work_service::list_work_item_relations(&short_id).unwrap_or_default(); + let mut wire = item_to_wire(&item, None); + if let Some(object) = wire.as_object_mut() { + object.insert("relations".into(), serde_json::json!(relations)); + } + return emit_success(wire, None, None); + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; let item = match pio::read_work_item(&scope, &short_id) { Ok(item) => item, Err(err) => return emit_error(CliError::from_service(err)), @@ -182,10 +293,6 @@ fn cmd_work_create(context: &ExecutionContext, flags: &HashMap) if let Err(err) = context.require_project_mode("work.create") { return emit_error(err); } - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), - Err(err) => return emit_error(err), - }; let actor = match mutation_actor(context) { Ok(actor) => actor, Err(err) => return emit_error(err), @@ -196,19 +303,88 @@ fn cmd_work_create(context: &ExecutionContext, flags: &HashMap) "work create requires --title", )); }; + let schedule = match (flags.get("schedule-cron"), flags.get("schedule-at")) { + (None, None) => None, + (cron, at) => Some(WorkItemSchedule { + at: at.cloned(), + cron: cron.cloned(), + enabled: true, + last_run: None, + }), + }; + let parent = flags + .get("parent") + .filter(|value| !value.trim().is_empty()) + .cloned(); + let stage = match flags.get("stage") { + None => None, + Some(raw) => match raw.trim().parse::() { + Ok(value) if value >= 1 => Some(value), + _ => { + return emit_error( + CliError::new( + ErrorCode::InvalidArgument, + format!("Invalid --stage '{}'; expected a positive integer", raw), + ) + .with_details(serde_json::json!({ "field": "--stage", "value": raw })), + ) + } + }, + }; + let body_flag = match resolve_body_flag(flags) { + Ok(body) => body, + Err(err) => return emit_error(err), + }; + if flags.contains_key("standalone") { + let org = context.org_id.clone(); + let request = work_service::CreateWorkItemRequest { + title: title.clone(), + body: body_flag.clone().unwrap_or_default(), + status: flags.get("status").cloned(), + priority: flags.get("priority").cloned(), + created_by: Some(actor.id.clone()), + schedule: schedule.clone(), + parent: parent.clone(), + stage, + ..Default::default() + }; + let result = (|| { + let short_id = pio::allocate_standalone_short_id(org.as_deref()) + .map_err(CliError::from_service)?; + let item = work_service::create_standalone_work_item( + org.as_deref(), + &short_id, + &request, + Some(&actor), + ) + .map_err(CliError::from_service)?; + Ok::<_, CliError>(item_to_wire(&item, None)) + })(); + return match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + }; + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; let canonical = serde_json::json!({ "op": "work.create", "title": title, - "body": flags.get("body"), + "body": body_flag, "status": flags.get("status"), "priority": flags.get("priority"), }); let request = work_service::CreateWorkItemRequest { title: title.clone(), - body: flags.get("body").cloned().unwrap_or_default(), + body: body_flag.clone().unwrap_or_default(), status: flags.get("status").cloned(), priority: flags.get("priority").cloned(), created_by: Some(actor.id.clone()), + schedule, + parent, + stage, ..Default::default() }; let scope_for_exec = scope.clone(); @@ -246,10 +422,49 @@ fn cmd_work_update( if let Err(err) = context.require_project_mode("work.update") { return emit_error(err); } - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), + let body_flag = match resolve_body_flag(flags) { + Ok(body) => body, Err(err) => return emit_error(err), }; + let stage_update: Option> = match flags.get("stage") { + None => None, + Some(raw) if raw.trim() == "none" => Some(None), + Some(raw) => match raw.trim().parse::() { + Ok(value) if value >= 1 => Some(Some(value)), + _ => { + return emit_error( + CliError::new( + ErrorCode::InvalidArgument, + format!("Invalid --stage '{}'; expected a positive integer or 'none'", raw), + ) + .with_details(serde_json::json!({ "field": "--stage", "value": raw })), + ) + } + }, + }; + if flags.contains_key("standalone") { + let short_id = match require_short_id(short_id) { + Ok(short_id) => short_id, + Err(err) => return emit_error(err), + }; + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let org = context.org_id.clone(); + match work_service::patch_standalone_work_item( + org.as_deref(), + &short_id, + flags.get("title").map(String::as_str), + body_flag.as_deref(), + flags.get("priority").map(String::as_str), + stage_update, + Some(&actor), + ) { + Ok(item) => return emit_success(item_to_wire(&item, None), None, None), + Err(err) => return emit_error(CliError::from_service(err)), + } + } let short_id = match require_short_id(short_id) { Ok(short_id) => short_id, Err(err) => return emit_error(err), @@ -266,28 +481,155 @@ fn cmd_work_update( "work update does not change state; use work transition --to ", )); } - let updates = WorkItemPartialUpdate { - title: flags.get("title").cloned(), - body: flags.get("body").cloned(), - priority: flags.get("priority").cloned(), - actor: Some(actor), - ..Default::default() + if standalone_fallback_item(context, &short_id).is_some() { + return match work_service::patch_standalone_work_item( + context.org_id.as_deref(), + &short_id, + flags.get("title").map(String::as_str), + body_flag.as_deref(), + flags.get("priority").map(String::as_str), + stage_update, + Some(&actor), + ) { + Ok(item) => emit_success(item_to_wire(&item, None), None, None), + Err(err) => emit_error(CliError::from_service(err)), + }; + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; + let expected_revision = flags + .get("expected-revision") + .and_then(|value| value.parse::().ok()); + let canonical = serde_json::json!({ + "op": "work.update", + "shortId": short_id, + "title": flags.get("title"), + "body": body_flag, + "priority": flags.get("priority"), + "expectedRevision": expected_revision, + }); + let scope_for_exec = scope.clone(); + let short_id_for_exec = short_id.clone(); + let caller_session = context + .session_ref + .as_ref() + .map(|session| session.external_id.clone()); + let title = flags.get("title").cloned(); + let body = body_flag.clone(); + let priority = flags.get("priority").cloned(); + let result = guarded( + &actor.id.clone(), + "work.update", + &scope, + flags.get("idempotency-key"), + canonical, + move || { + let item = work_service::patch_project_work_item( + &scope_for_exec, + &short_id_for_exec, + title.as_deref(), + body.as_deref(), + priority.as_deref(), + stage_update, + Some(&actor), + expected_revision, + caller_session.as_deref(), + )?; + let revision = + work_service::read_project_work_item_revision(&scope_for_exec, &short_id_for_exec) + .ok(); + Ok(item_to_wire(&item, revision)) + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + } +} + +fn cmd_work_assign( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + if let Err(err) = context.require_project_mode("work.assign") { + return emit_error(err); + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; + let short_id = match require_short_id(short_id) { + Ok(short_id) => short_id, + Err(err) => return emit_error(err), + }; + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let Some(assignee) = flags.get("actor-target").or_else(|| flags.get("assignee")) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work assign requires --assignee ", + )); }; - match pio::update_work_item_partial(&scope, &short_id, &updates) { - Ok(item) => { - let revision = work_service::read_project_work_item_revision(&scope, &short_id).ok(); - emit_success(item_to_wire(&item, revision), revision, None) + let (assignee_type, assignee_id) = match assignee.split_once(':') { + Some((kind, id)) if matches!(kind, "human" | "agent") && !id.is_empty() => (kind, id), + _ => { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work assign --assignee must be human: or agent:", + )); } - Err(err) => emit_error(CliError::from_service(err)), + }; + let expected_revision = flags + .get("expected-revision") + .and_then(|value| value.parse::().ok()); + let canonical = serde_json::json!({ + "op": "work.assign", + "shortId": short_id, + "assignee": assignee, + "expectedRevision": expected_revision, + }); + let scope_for_exec = scope.clone(); + let short_id_for_exec = short_id.clone(); + let assignee_id = assignee_id.to_string(); + let assignee_type = assignee_type.to_string(); + let result = guarded( + &actor.id.clone(), + "work.assign", + &scope, + flags.get("idempotency-key"), + canonical, + move || { + let item = work_service::assign_project_work_item( + &scope_for_exec, + &short_id_for_exec, + &assignee_id, + Some(&assignee_type), + Some(&actor), + expected_revision, + )?; + let revision = + work_service::read_project_work_item_revision(&scope_for_exec, &short_id_for_exec) + .ok(); + Ok(item_to_wire(&item, revision)) + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), } } -fn cmd_work_claim( +fn cmd_work_release( context: &ExecutionContext, short_id: Option<&String>, flags: &HashMap, ) -> i32 { - if let Err(err) = context.require_project_mode("work.claim") { + if let Err(err) = context.require_project_mode("work.release") { return emit_error(err); } let scope = match context.require_scope() { @@ -302,6 +644,61 @@ fn cmd_work_claim( Ok(actor) => actor, Err(err) => return emit_error(err), }; + let Some(session_ref) = context.session_ref.as_ref() else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work release requires --session-ref (only the claim holder releases)", + )); + }; + let canonical = serde_json::json!({ + "op": "work.release", + "shortId": short_id, + "sessionRef": format!("{}:{}", session_ref.provider, session_ref.external_id), + }); + let scope_for_exec = scope.clone(); + let short_id_for_exec = short_id.clone(); + let session_id = session_ref.external_id.clone(); + let result = guarded( + &actor.id.clone(), + "work.release", + &scope, + flags.get("idempotency-key"), + canonical, + move || { + let item = work_service::release_project_work_item( + &scope_for_exec, + &short_id_for_exec, + &session_id, + Some(&actor), + )?; + let revision = + work_service::read_project_work_item_revision(&scope_for_exec, &short_id_for_exec) + .ok(); + Ok(item_to_wire(&item, revision)) + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + } +} + +fn cmd_work_claim( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + if let Err(err) = context.require_project_mode("work.claim") { + return emit_error(err); + } + let short_id = match require_short_id(short_id) { + Ok(short_id) => short_id, + Err(err) => return emit_error(err), + }; + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; let Some(session_ref) = context.session_ref.as_ref() else { return emit_error(CliError::new( ErrorCode::InvalidArgument, @@ -322,6 +719,26 @@ fn cmd_work_claim( let expected_revision = flags .get("expected-revision") .and_then(|value| value.parse::().ok()); + if flags.contains_key("standalone") + || standalone_fallback_item(context, &short_id).is_some() + { + return match work_service::claim_standalone_work_item( + context.org_id.as_deref(), + &short_id, + &session_ref.external_id, + Some("custom"), + WorkItemExecutionLockReason::ManualStart, + Some(&actor), + expected_revision, + ) { + Ok(item) => emit_success(item_to_wire(&item, None), None, None), + Err(err) => emit_error(CliError::from_service(err)), + }; + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; let canonical = serde_json::json!({ "op": "work.claim", @@ -340,41 +757,21 @@ fn cmd_work_claim( flags.get("idempotency-key"), canonical, move || { - // Acquire the claim record first (CAS — fails when another - // session holds it), then the strict open -> in_progress - // transition; roll the lock back if the transition is rejected. - pio::acquire_execution_lock( + let item = work_service::claim_project_work_item( &scope_for_exec, &short_id_for_exec, &session_id, Some("custom"), WorkItemExecutionLockReason::ManualStart, + Some(&actor_for_exec), + expected_revision, )?; - match work_service::transition_project_work_item( + let revision = work_service::read_project_work_item_revision( &scope_for_exec, &short_id_for_exec, - "in_progress", - Some("claimed"), - Some(&actor_for_exec), - expected_revision, - ) { - Ok(item) => { - let revision = work_service::read_project_work_item_revision( - &scope_for_exec, - &short_id_for_exec, - ) - .ok(); - Ok(item_to_wire(&item, revision)) - } - Err(err) => { - let _ = pio::release_execution_lock( - &scope_for_exec, - &short_id_for_exec, - &session_id, - ); - Err(err) - } - } + ) + .ok(); + Ok(item_to_wire(&item, revision)) }, ); match result { @@ -391,10 +788,6 @@ fn cmd_work_transition( if let Err(err) = context.require_project_mode("work.transition") { return emit_error(err); } - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), - Err(err) => return emit_error(err), - }; let short_id = match require_short_id(short_id) { Ok(short_id) => short_id, Err(err) => return emit_error(err), @@ -432,6 +825,30 @@ fn cmd_work_transition( let expected_revision = flags .get("expected-revision") .and_then(|value| value.parse::().ok()); + if flags.contains_key("standalone") + || standalone_fallback_item(context, &short_id).is_some() + { + let caller_session = context + .session_ref + .as_ref() + .map(|session| session.external_id.clone()); + return match work_service::transition_standalone_work_item( + context.org_id.as_deref(), + &short_id, + to_state, + flags.get("reason").map(String::as_str), + Some(&actor), + expected_revision, + caller_session.as_deref(), + ) { + Ok(item) => emit_success(item_to_wire(&item, None), None, None), + Err(err) => emit_error(CliError::from_service(err)), + }; + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; let canonical = serde_json::json!({ "op": "work.transition", "shortId": short_id, @@ -444,6 +861,10 @@ fn cmd_work_transition( let to_state_owned = to_state.clone(); let reason = flags.get("reason").cloned(); let actor_for_exec = actor.clone(); + let caller_session = context + .session_ref + .as_ref() + .map(|session| session.external_id.clone()); let result = guarded( &actor.id, "work.transition", @@ -451,13 +872,14 @@ fn cmd_work_transition( flags.get("idempotency-key"), canonical, move || { - let item = work_service::transition_project_work_item( + let item = work_service::transition_project_work_item_scoped( &scope_for_exec, &short_id_for_exec, &to_state_owned, reason.as_deref(), Some(&actor_for_exec), expected_revision, + caller_session.as_deref(), )?; let revision = work_service::read_project_work_item_revision( &scope_for_exec, @@ -481,10 +903,49 @@ fn cmd_work_note( if let Err(err) = context.require_project_mode("work.note") { return emit_error(err); } - let scope = match context.require_scope() { - Ok(scope) => scope.to_string(), - Err(err) => return emit_error(err), - }; + if flags.contains_key("standalone") { + let short_id = match require_short_id(short_id) { + Ok(short_id) => short_id, + Err(err) => return emit_error(err), + }; + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let body = match resolve_body_flag(flags) { + Ok(Some(body)) if !body.trim().is_empty() => body, + Ok(_) => { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work note requires --body or --body-file", + )) + } + Err(err) => return emit_error(err), + }; + let body = body.as_str(); + let kind = flags.get("kind").map(String::as_str).unwrap_or("comment"); + const KINDS: &[&str] = + &["comment", "progress", "blocker", "decision", "handoff", "review"]; + if !KINDS.contains(&kind) { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown note kind '{}'; expected comment|progress|blocker|decision|handoff|review", + kind + ), + )); + } + return match work_service::note_standalone_work_item( + context.org_id.as_deref(), + &short_id, + kind, + body, + Some(&actor), + ) { + Ok(()) => emit_success(serde_json::json!({ "appended": true, "kind": kind }), None, None), + Err(err) => emit_error(CliError::from_service(err)), + }; + } let short_id = match require_short_id(short_id) { Ok(short_id) => short_id, Err(err) => return emit_error(err), @@ -493,12 +954,17 @@ fn cmd_work_note( Ok(actor) => actor, Err(err) => return emit_error(err), }; - let Some(body) = flags.get("body").filter(|value| !value.trim().is_empty()) else { - return emit_error(CliError::new( - ErrorCode::InvalidArgument, - "work note requires --body", - )); + let body = match resolve_body_flag(flags) { + Ok(Some(body)) if !body.trim().is_empty() => body, + Ok(_) => { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work note requires --body or --body-file", + )) + } + Err(err) => return emit_error(err), }; + let body = body.as_str(); let kind = flags .get("kind") .map(String::as_str) @@ -513,6 +979,24 @@ fn cmd_work_note( ), )); } + if standalone_fallback_item(context, &short_id).is_some() { + return match work_service::note_standalone_work_item( + context.org_id.as_deref(), + &short_id, + kind, + body, + Some(&actor), + ) { + Ok(()) => { + emit_success(serde_json::json!({ "appended": true, "kind": kind }), None, None) + } + Err(err) => emit_error(CliError::from_service(err)), + }; + } + let scope = match context.require_scope() { + Ok(scope) => scope.to_string(), + Err(err) => return emit_error(err), + }; match work_service::note_project_work_item(&scope, &short_id, kind, body, Some(&actor)) { Ok(()) => emit_success(serde_json::json!({ "appended": true, "kind": kind }), None, None), Err(err) => emit_error(CliError::from_service(err)), @@ -708,7 +1192,8 @@ pub fn dispatch_routine( }; let input_map: std::collections::BTreeMap = inputs.iter().cloned().collect(); - match routine_service::invoke(name, &scope, &input_map, Some(&actor)) { + let invoke_key = flags.get("idempotency-key").map(String::as_str); + match routine_service::invoke(name, &scope, &input_map, Some(&actor), invoke_key) { Ok(run) => emit_success( serde_json::json!({ "runId": run.run_id, @@ -779,3 +1264,208 @@ pub fn dispatch_routine( fn _mode_witness(mode: ProductMode) -> &'static str { mode.as_str() } + +pub fn dispatch_project( + context: &ExecutionContext, + positionals: &[String], + flags: &HashMap, +) -> i32 { + match positionals.first().map(String::as_str) { + Some("list") => cmd_project_list(context, flags), + Some("show") => cmd_project_show(positionals.get(1)), + Some("find") => cmd_project_find(positionals.get(1)), + Some("members") => cmd_project_members(positionals.get(1)), + Some("create") => cmd_project_create(context, flags), + Some("update") => cmd_project_update(context, positionals.get(1), flags), + other => emit_error(CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown project subcommand '{}'; expected list|show|find|members|create|update", + other.unwrap_or("") + ), + )), + } +} + +fn project_to_wire(project: &project_management::projects::types::ProjectData) -> serde_json::Value { + serde_json::json!({ + "slug": project.slug, + "name": project.meta.name, + "orgId": project.meta.org_id, + "status": project.meta.status, + "priority": project.meta.priority, + "lead": project.meta.lead, + "labels": project.meta.labels, + "workItemPrefix": project.meta.work_item_prefix, + "createdAt": project.meta.created_at, + "updatedAt": project.meta.updated_at, + }) +} + +fn cmd_project_list(_context: &ExecutionContext, flags: &HashMap) -> i32 { + let org = flags.get("org").map(String::as_str); + match pio::read_all_projects_scoped(org) { + Ok(projects) => { + let items: Vec = + projects.iter().map(project_to_wire).collect(); + emit_success(serde_json::json!({ "items": items }), None, None) + } + Err(err) => emit_error(CliError::from_service(err)), + } +} + +fn cmd_project_show(slug: Option<&String>) -> i32 { + let Some(slug) = slug else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 project show ", + )); + }; + match pio::read_project(slug) { + Ok(project) => { + let mut wire = project_to_wire(&project); + wire["description"] = serde_json::Value::String(project.description.clone()); + emit_success(wire, None, None) + } + Err(err) => emit_error(CliError::from_service(err)), + } +} + +fn cmd_project_find(query: Option<&String>) -> i32 { + let Some(query) = query else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 project find ", + )); + }; + let needle = query.to_lowercase(); + match pio::read_all_projects_scoped(None) { + Ok(projects) => { + let items: Vec = projects + .iter() + .filter(|project| { + project.slug.to_lowercase().contains(&needle) + || project.meta.name.to_lowercase().contains(&needle) + }) + .map(project_to_wire) + .collect(); + emit_success(serde_json::json!({ "items": items }), None, None) + } + Err(err) => emit_error(CliError::from_service(err)), + } +} + +fn cmd_project_members(slug: Option<&String>) -> i32 { + let Some(slug) = slug else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 project members ", + )); + }; + match pio::read_project(slug) { + Ok(project) => emit_success( + serde_json::json!({ + "lead": project.meta.lead, + "members": project.meta.members, + }), + None, + None, + ), + Err(err) => emit_error(CliError::from_service(err)), + } +} + +fn cmd_project_create(context: &ExecutionContext, flags: &HashMap) -> i32 { + if let Err(err) = context.require_project_mode("project.create") { + return emit_error(err); + } + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let Some(name) = flags.get("name").filter(|value| !value.trim().is_empty()) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "project create requires --name", + )); + }; + let request = project_management::project_service::CreateProjectRequest { + name: name.clone(), + description: flags.get("description").cloned().unwrap_or_default(), + org_id: flags.get("org").cloned(), + status: flags.get("status").cloned(), + priority: flags.get("priority").cloned(), + lead: flags.get("lead").cloned(), + labels: vec![], + }; + let canonical = serde_json::json!({ + "op": "project.create", + "name": name, + "org": flags.get("org"), + }); + let result = guarded( + &actor.id, + "project.create", + flags.get("org").map(String::as_str).unwrap_or("personal-org"), + flags.get("idempotency-key"), + canonical, + move || { + let project = project_management::project_service::create_project(&request)?; + Ok(project_to_wire(&project)) + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + } +} + +fn cmd_project_update( + context: &ExecutionContext, + slug: Option<&String>, + flags: &HashMap, +) -> i32 { + if let Err(err) = context.require_project_mode("project.update") { + return emit_error(err); + } + let Some(slug) = slug else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 project update [--name ...] [--status ...]", + )); + }; + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let request = project_management::project_service::UpdateProjectRequest { + name: flags.get("name").cloned(), + description: flags.get("description").cloned(), + status: flags.get("status").cloned(), + priority: flags.get("priority").cloned(), + lead: flags.get("lead").cloned(), + }; + let canonical = serde_json::json!({ + "op": "project.update", + "slug": slug, + "name": flags.get("name"), + "status": flags.get("status"), + }); + let slug_owned = slug.clone(); + let result = guarded( + &actor.id, + "project.update", + &slug_owned.clone(), + flags.get("idempotency-key"), + canonical, + move || { + let project = + project_management::project_service::update_project(&slug_owned, &request)?; + Ok(project_to_wire(&project)) + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + } +} diff --git a/src-tauri/crates/orgtrack-pm-cli/src/context.rs b/src-tauri/crates/orgtrack-pm-cli/src/context.rs index 4d9e27b676..47e340c32c 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/context.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/context.rs @@ -16,6 +16,7 @@ pub const ENV_MODE: &str = "ORGII_MODE"; pub const ENV_ACTOR: &str = "ORGII_ACTOR"; pub const ENV_SCOPE: &str = "ORGII_SCOPE"; pub const ENV_SESSION_REF: &str = "ORGII_SESSION_REF"; +pub const ENV_ORG: &str = "ORGII_ORG"; pub const ALL_CAPABILITIES: &[&str] = &[ "work.read", @@ -176,6 +177,36 @@ impl ExecutionContext { } } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionMarker { + session_ref: String, + actor: String, + #[serde(default)] + product_mode: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + org: Option, +} + +/// Fail-closed session context (design M6): a harness-managed workspace +/// carries `.orgii/agent_session_context.json`, and inside it the CLI +/// locks its identity to the marker — the model cannot act as a human +/// or as another session no matter what flags it types. +fn read_session_marker() -> Option { + let mut dir = std::env::current_dir().ok()?; + loop { + let path = dir.join(".orgii/agent_session_context.json"); + if let Ok(raw) = std::fs::read_to_string(&path) { + return serde_json::from_str(&raw).ok(); + } + if !dir.pop() { + return None; + } + } +} + fn read_manifest() -> Option { let path = std::env::current_dir().ok()?.join(".orgii/orgtrack.json"); let raw = std::fs::read_to_string(path).ok()?; @@ -246,11 +277,66 @@ pub fn resolve( None => None, }; + let marker = read_session_marker(); + let (mode, scope_id, actor, session_ref, marker_org) = if let Some(marker) = marker { + let marker_actor = ActorRef::parse(&marker.actor).ok_or_else(|| { + CliError::new( + ErrorCode::ContextRequired, + "Session marker is unreadable; relaunch the session", + ) + })?; + if let Some(explicit) = &actor { + if explicit.kind != marker_actor.kind || explicit.id != marker_actor.id { + return Err(CliError::new( + ErrorCode::PermissionDenied, + format!( + "This workspace is bound to session actor '{}:{}'; --actor/{} may not override it", + marker_actor.kind, marker_actor.id, ENV_ACTOR + ), + )); + } + } + let marker_session = SessionRef::parse(&marker.session_ref).ok_or_else(|| { + CliError::new( + ErrorCode::ContextRequired, + "Session marker carries an invalid session ref; relaunch the session", + ) + })?; + if let Some(explicit) = &session_ref { + if explicit.provider != marker_session.provider + || explicit.external_id != marker_session.external_id + { + return Err(CliError::new( + ErrorCode::PermissionDenied, + "This workspace is bound to another session; --session-ref may not override it", + )); + } + } + let mode = match marker.product_mode.as_deref().and_then(ProductMode::parse) { + Some(marker_mode) => marker_mode, + None => mode, + }; + let scope_id = scope_id.or(marker.scope); + ( + mode, + scope_id, + Some(marker_actor), + Some(marker_session), + marker.org, + ) + } else { + (mode, scope_id, actor, session_ref, None) + }; + let capabilities = ExecutionContext::capabilities_for(mode); Ok(ExecutionContext { mode, scope_id, - org_id: manifest.as_ref().and_then(|m| m.org_id.clone()), + org_id: std::env::var(ENV_ORG) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| manifest.as_ref().and_then(|m| m.org_id.clone())) + .or(marker_org), actor, session_ref, capabilities, diff --git a/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs b/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs index dae360f21c..493e5bcaa2 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs @@ -169,10 +169,22 @@ impl CliError { ) .with_details(serde_json::json!({ "from": from, "to": to })); } + if let Some(rest) = err.strip_prefix(pm::ALREADY_EXISTS) { + let short_id = rest.trim_start_matches(':').to_string(); + return CliError::new( + ErrorCode::AlreadyExists, + format!("Work item '{}' already exists; creation refuses to overwrite", short_id), + ) + .with_details(serde_json::json!({ "shortId": short_id })); + } if err.contains("not found") || err.contains("Not found") { return CliError::new(ErrorCode::NotFound, err); } - if err.contains("already has an active execution session") { + if err.contains("already has an active execution session") + || err.contains("already has a running linked session") + || err.contains("is claimed by another session") + || err.contains("has no active claim to release") + { return CliError::new(ErrorCode::AlreadyClaimed, err); } CliError::new(ErrorCode::StoreUnavailable, err) @@ -288,5 +300,9 @@ mod tests { let err = CliError::from_service("Work item 'X' not found".into()); assert_eq!(err.code, ErrorCode::NotFound); + + let err = CliError::from_service("PM_ERR:ALREADY_EXISTS:AAA-0001".into()); + assert_eq!(err.code, ErrorCode::AlreadyExists); + assert_eq!(err.details["shortId"], "AAA-0001"); } } diff --git a/src-tauri/crates/orgtrack-pm-cli/src/main.rs b/src-tauri/crates/orgtrack-pm-cli/src/main.rs index 080685c8e1..4305975862 100644 --- a/src-tauri/crates/orgtrack-pm-cli/src/main.rs +++ b/src-tauri/crates/orgtrack-pm-cli/src/main.rs @@ -46,7 +46,7 @@ fn parse_args(args: &[String]) -> Result { while i < args.len() { let arg = &args[i]; if let Some(name) = arg.strip_prefix("--") { - if name == "json" || name == "ready" { + if name == "json" || name == "ready" || name == "standalone" { flags.insert(name.to_string(), "true".to_string()); i += 1; continue; @@ -127,19 +127,20 @@ fn run(args: &[String]) -> i32 { match parsed.positionals.first().map(String::as_str) { Some("context") => commands::cmd_context(&context), Some("work") => commands::dispatch_work(&context, &parsed.positionals[1..], flags), + Some("project") => commands::dispatch_project(&context, &parsed.positionals[1..], flags), Some("routine") => { commands::dispatch_routine(&context, &parsed.positionals[1..], flags, &parsed.inputs) } Some(other) => emit_error(CliError::new( ErrorCode::InvalidArgument, format!( - "Unknown command '{}'; expected context|work|routine", + "Unknown command '{}'; expected context|work|project|routine", other ), )), None => emit_error(CliError::new( ErrorCode::InvalidArgument, - "Usage: org2 ... (JSON envelope on stdout)", + "Usage: org2 ... (JSON envelope on stdout)", )), } } diff --git a/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs b/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs index 125613443f..ea83d1689f 100644 --- a/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs +++ b/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs @@ -47,6 +47,7 @@ fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatt labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, @@ -173,8 +174,21 @@ fn external_shell_agent_completes_a_work_item_end_to_end() { assert_eq!(exit, 0, "list envelope: {listed}"); assert_eq!(listed["data"]["items"][0]["frontmatter"]["short_id"], "AAA-0001"); - // Claim: lock + strict open -> in_progress. - let (exit, claimed) = run_cli(&[&["work", "claim", "AAA-0001"], &base[..]].concat()); + // Claim with the observed revision: lock + strict open -> in_progress + // and the OCC precondition hold in ONE transaction. + let shown_revision = { + let (exit, shown) = run_cli(&[&["work", "show", "AAA-0001"], &base[..]].concat()); + assert_eq!(exit, 0, "show envelope: {shown}"); + shown["data"]["revision"].as_i64().expect("revision") + }; + let revision_flag = shown_revision.to_string(); + let (exit, claimed) = run_cli( + &[ + &["work", "claim", "AAA-0001", "--expected-revision", &revision_flag], + &base[..], + ] + .concat(), + ); assert_eq!(exit, 0, "claim envelope: {claimed}"); assert_eq!(claimed["data"]["frontmatter"]["status"], "in_progress"); assert_eq!( @@ -462,3 +476,356 @@ fn wire_validation_maps_to_stable_codes() { "message points at the canonical id: {envelope}" ); } + +#[test] +fn assign_release_pagination_and_portable_filter() { + let _sandbox = test_env::sandbox(); + seed("demo"); + for n in 2..=4 { + let short_id = format!("AAA-000{n}"); + write_work_item( + "demo", + &short_id, + &work_item_fixture(&short_id, &short_id, &format!("Item {n}")), + "body", + ) + .expect("seed extra item"); + } + let base = [ + "--mode", + "project", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + "--session-ref", + "claude_code:session_e2e_2", + ]; + + let (exit, page1) = run_cli(&[&["work", "list", "--status", "open", "--limit", "2"], &base[..]].concat()); + assert_eq!(exit, 0, "{page1}"); + assert_eq!(page1["data"]["items"].as_array().expect("items").len(), 2); + let cursor = page1["meta"]["nextCursor"].as_str().expect("nextCursor").to_string(); + + let (exit, page2) = run_cli( + &[&["work", "list", "--status", "open", "--limit", "2", "--cursor", &cursor], &base[..]] + .concat(), + ); + assert_eq!(exit, 0, "{page2}"); + assert_eq!(page2["data"]["items"].as_array().expect("items").len(), 2); + assert!(page2["meta"].get("nextCursor").is_none(), "{page2}"); + assert_ne!( + page1["data"]["items"][0]["frontmatter"]["short_id"], + page2["data"]["items"][0]["frontmatter"]["short_id"] + ); + + let (exit, bad) = run_cli(&[&["work", "list", "--status", "backlog"], &base[..]].concat()); + assert_eq!(exit, 2, "legacy vocabulary is rejected: {bad}"); + assert_eq!(bad["error"]["code"], "INVALID_ARGUMENT"); + + let (exit, assigned) = run_cli( + &[&["work", "assign", "AAA-0002", "--assignee", "agent:builtin-os"], &base[..]].concat(), + ); + assert_eq!(exit, 0, "{assigned}"); + assert_eq!(assigned["data"]["frontmatter"]["assignee"], "builtin-os"); + assert_eq!(assigned["data"]["frontmatter"]["assignee_type"], "agent"); + + let (exit, claimed) = run_cli(&[&["work", "claim", "AAA-0002"], &base[..]].concat()); + assert_eq!(exit, 0, "{claimed}"); + + let (exit, released) = run_cli(&[&["work", "release", "AAA-0002"], &base[..]].concat()); + assert_eq!(exit, 0, "{released}"); + assert_eq!(released["data"]["frontmatter"]["status"], "open"); + assert!(released["data"]["frontmatter"]["execution_lock"].is_null(), "{released}"); + + let (exit, foreign) = run_cli( + &[ + &["work", "claim", "AAA-0002"], + &base[..8], + &["--session-ref", "claude_code:session_e2e_other"][..], + ] + .concat(), + ); + assert_eq!(exit, 0, "{foreign}"); + let (exit, denied) = run_cli(&[&["work", "release", "AAA-0002"], &base[..]].concat()); + assert_eq!(exit, 4, "release by non-holder must fail: {denied}"); + assert_eq!(denied["error"]["code"], "ALREADY_CLAIMED"); + + let (exit, foreign_update) = run_cli( + &[&["work", "update", "AAA-0002", "--title", "hijack"], &base[..]].concat(), + ); + assert_eq!(exit, 4, "update on a foreign-claimed item must fail: {foreign_update}"); + assert_eq!(foreign_update["error"]["code"], "ALREADY_CLAIMED"); + + let (exit, foreign_transition) = run_cli( + &[&["work", "transition", "AAA-0002", "--to", "completed"], &base[..]].concat(), + ); + assert_eq!(exit, 4, "transition on a foreign-claimed item must fail: {foreign_transition}"); + assert_eq!(foreign_transition["error"]["code"], "ALREADY_CLAIMED"); +} + +#[test] +fn project_family_creates_reads_and_updates_through_the_boundary() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let base = ["--mode", "project", "--actor", "human:vince"]; + + let (exit, created) = run_cli( + &[&["project", "create", "--name", "Dog Walker MVP", "--description", "walkies"], &base[..]] + .concat(), + ); + assert_eq!(exit, 0, "{created}"); + assert_eq!(created["data"]["slug"], "dog-walker-mvp"); + assert_eq!(created["data"]["orgId"], "personal-org"); + + let (exit, dup) = run_cli( + &[&["project", "create", "--name", "Dog Walker MVP"], &base[..]].concat(), + ); + assert_eq!(exit, 4, "duplicate slug must refuse: {dup}"); + assert_eq!(dup["error"]["code"], "ALREADY_EXISTS"); + + let (exit, shown) = run_cli(&["project", "show", "dog-walker-mvp"]); + assert_eq!(exit, 0, "{shown}"); + assert_eq!(shown["data"]["description"], "walkies"); + + let (exit, updated) = run_cli( + &[&["project", "update", "dog-walker-mvp", "--status", "active"], &base[..]].concat(), + ); + assert_eq!(exit, 0, "{updated}"); + assert_eq!(updated["data"]["status"], "active"); + + let (exit, found) = run_cli(&["project", "find", "dog"]); + assert_eq!(exit, 0, "{found}"); + assert_eq!(found["data"]["items"].as_array().expect("items").len(), 1); + + let (exit, gated) = run_cli(&["project", "create", "--name", "Nope"]); + assert_eq!(exit, 5, "mutation outside project mode: {gated}"); +} + +#[test] +fn session_marker_locks_identity_fail_closed() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let home = std::env::var("ORGII_HOME").expect("sandbox sets ORGII_HOME"); + let workspace = std::path::Path::new(&home).join("marker-workspace"); + std::fs::create_dir_all(workspace.join(".orgii")).expect("workspace dirs"); + std::fs::write( + workspace.join(".orgii/agent_session_context.json"), + serde_json::json!({ + "apiVersion": "orgtrack/v1", + "sessionRef": "org2:session_marker_1", + "actor": "agent:os", + "productMode": "project", + "scope": "demo", + "capabilities": ["work.read", "work.mutate"], + "issuedAt": "2026-08-07T00:00:00Z", + }) + .to_string(), + ) + .expect("marker written"); + + let run_in_workspace = |args: &[&str]| { + let exe = env!("CARGO_BIN_EXE_org2-pm"); + let output = Command::new(exe) + .args(args) + .current_dir(&workspace) + .output() + .expect("spawn org2-pm"); + let stdout = String::from_utf8_lossy(&output.stdout); + let value: serde_json::Value = + serde_json::from_str(stdout.trim()).unwrap_or_else(|err| panic!("{stdout}: {err}")); + (output.status.code().unwrap_or(-1), value) + }; + + let (exit, context) = run_in_workspace(&["context"]); + assert_eq!(exit, 0, "{context}"); + assert_eq!(context["data"]["mode"], "project"); + assert_eq!(context["data"]["scopeId"], "demo"); + assert_eq!(context["data"]["actor"]["kind"], "agent"); + assert_eq!(context["data"]["actor"]["id"], "os"); + + let (exit, created) = run_in_workspace(&["work", "create", "--title", "marker item"]); + assert_eq!(exit, 0, "identity injected from the marker: {created}"); + + let (exit, denied) = run_in_workspace(&["work", "create", "--title", "spoof", "--actor", "human:vince"]); + assert_eq!(exit, 8, "actor spoofing must be refused: {denied}"); + assert_eq!(denied["error"]["code"], "PERMISSION_DENIED"); + + let (exit, foreign) = run_in_workspace(&["work", "claim", "AAA-0001", "--session-ref", "claude_code:other"]); + assert_eq!(exit, 8, "session override must be refused: {foreign}"); +} + +#[test] +fn standalone_items_are_reachable_without_scope() { + let _sandbox = test_env::sandbox(); + let connection = database::db::get_projects_connection().expect("projects connection"); + project_management::projects::schema::init_project_tables(&connection).expect("schema"); + drop(connection); + + let fixture = work_item_fixture("s1", "STA-0001", "Standalone draft"); + project_management::projects::io::write_standalone_work_item( + None, + "STA-0001", + &fixture, + "draft body", + ) + .expect("seed standalone"); + + let base = [ + "--mode", + "project", + "--actor", + "agent:cli-tester", + "--session-ref", + "claude_code:session_e2e_sta", + ]; + + // A bare id with no resolvable project scope routes to the org's + // standalone store — a session bound to a standalone root item can + // address it without knowing the `--standalone` flag (the Discussion + // forward and delivery-mandate instructions all use bare ids). + let (exit, bare) = run_cli(&[&["work", "show", "STA-0001"], &base[..]].concat()); + assert_eq!(exit, 0, "scope-less bare-id show: {bare}"); + assert_eq!(bare["data"]["frontmatter"]["short_id"], "STA-0001"); + + let (exit, bare_noted) = run_cli( + &[ + &["work", "note", "STA-0001", "--kind", "comment", "--body", "bare-id receipt"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "scope-less bare-id note: {bare_noted}"); + + // Stage round-trips through create and update (barrier grouping). + let (exit, staged) = run_cli( + &[ + &["work", "create", "--standalone", "--title", "Staged child", "--parent", "STA-0001", "--stage", "2"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "staged create: {staged}"); + assert_eq!(staged["data"]["frontmatter"]["stage"], 2); + let staged_id = staged["data"]["frontmatter"]["short_id"] + .as_str() + .expect("short id") + .to_string(); + let (exit, restaged) = run_cli( + &[&["work", "update", &staged_id, "--stage", "1"], &base[..]].concat(), + ); + assert_eq!(exit, 0, "stage update: {restaged}"); + assert_eq!(restaged["data"]["frontmatter"]["stage"], 1); + + // The full lifecycle works on bare ids too: claim enters + // in_progress, transition completes — the path an agent takes to + // finish a standalone sub-item (which closes the parent barrier). + let (exit, claimed) = run_cli(&[&["work", "claim", "STA-0001"], &base[..]].concat()); + assert_eq!(exit, 0, "scope-less bare-id claim: {claimed}"); + assert_eq!(claimed["data"]["frontmatter"]["status"], "in_progress"); + let (exit, done) = run_cli( + &[&["work", "transition", "STA-0001", "--to", "completed"], &base[..]].concat(), + ); + assert_eq!(exit, 0, "scope-less bare-id transition: {done}"); + assert_eq!(done["data"]["frontmatter"]["status"], "completed"); + + // `--standalone` routes to the org-scoped store without any scope. + let (exit, shown) = run_cli(&[&["work", "show", "STA-0001", "--standalone"], &base[..]].concat()); + assert_eq!(exit, 0, "show envelope: {shown}"); + assert_eq!(shown["data"]["frontmatter"]["short_id"], "STA-0001"); + + let (exit, listed) = run_cli(&[&["work", "list", "--standalone"], &base[..]].concat()); + assert_eq!(exit, 0, "list envelope: {listed}"); + assert_eq!(listed["data"]["items"][0]["frontmatter"]["short_id"], "STA-0001"); + + // Progress note lands in the standalone item's comment thread. + let (exit, noted) = run_cli( + &[ + &["work", "note", "STA-0001", "--standalone", "--kind", "progress", "--body", "half way"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "note envelope: {noted}"); + let item = project_management::projects::io::read_standalone_work_item(None, "STA-0001") + .expect("noted read back"); + assert!( + item.frontmatter + .comments + .iter() + .any(|comment| comment.content == "[progress] half way" && comment.author == "cli-tester"), + "note must land in comments: {:?}", + item.frontmatter.comments + ); + + // `--body-file` sidesteps shell quoting for agent-authored bodies. + let body_path = std::env::temp_dir().join("orgii-cli-e2e-body.md"); + std::fs::write(&body_path, "literal `backticks` and $(no expansion)").expect("write body file"); + let body_path_str = body_path.to_string_lossy().to_string(); + let (exit, filed) = run_cli( + &[ + &["work", "update", "STA-0001", "--standalone", "--body-file", &body_path_str], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "body-file envelope: {filed}"); + assert_eq!( + filed["data"]["body"], + "literal `backticks` and $(no expansion)" + ); + + // A sub item created with --parent records the parent linkage. + let (exit, child) = run_cli( + &[ + &["work", "create", "--standalone", "--title", "Child task", "--parent", "STA-0001"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "child envelope: {child}"); + assert_eq!(child["data"]["frontmatter"]["parent"], "STA-0001"); + + // Fill the draft the way the AI work-item filler does. + let (exit, updated) = run_cli( + &[ + &[ + "work", + "update", + "STA-0001", + "--standalone", + "--title", + "Filled title", + "--body", + "Filled body", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "update envelope: {updated}"); + assert_eq!(updated["data"]["frontmatter"]["title"], "Filled title"); + + let item = project_management::projects::io::read_standalone_work_item(None, "STA-0001") + .expect("read back"); + assert_eq!(item.frontmatter.title, "Filled title"); + assert_eq!(item.body, "Filled body"); + + // "Another one" allocates a fresh standalone id. + let (exit, created) = run_cli( + &[ + &["work", "create", "--standalone", "--title", "Another one"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "create envelope: {created}"); + let new_id = created["data"]["frontmatter"]["short_id"] + .as_str() + .expect("short id") + .to_string(); + assert_ne!(new_id, "STA-0001"); + let item = project_management::projects::io::read_standalone_work_item(None, &new_id) + .expect("created read back"); + assert_eq!(item.frontmatter.title, "Another one"); +} diff --git a/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs b/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs new file mode 100644 index 0000000000..d354b82d09 --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/tests/conformance.rs @@ -0,0 +1,119 @@ +//! Minimal conformance gate (design §5.7): real CLI process output must +//! validate against the frozen JSON Schemas, and the stable error-code +//! fixtures must match the implementation's wire strings. + +use std::path::PathBuf; +use std::process::Command; + +use test_helpers::test_env; + +fn docs_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../docs/orgtrack-pm-protocol") +} + +fn load_json(path: &PathBuf) -> serde_json::Value { + let raw = std::fs::read_to_string(path).unwrap_or_else(|err| panic!("{path:?}: {err}")); + serde_json::from_str(&raw).unwrap_or_else(|err| panic!("{path:?}: {err}")) +} + +fn schema_validator(name: &str) -> jsonschema::Validator { + let dir = docs_dir().join("schemas"); + let schema = load_json(&dir.join(name)); + let common = load_json(&dir.join("common.schema.json")); + let retriever = InDirRetriever { common }; + jsonschema::options() + .with_retriever(retriever) + .build(&schema) + .unwrap_or_else(|err| panic!("compile {name}: {err}")) +} + +struct InDirRetriever { + common: serde_json::Value, +} + +impl jsonschema::Retrieve for InDirRetriever { + fn retrieve( + &self, + uri: &jsonschema::Uri<&str>, + ) -> Result> { + if uri.as_str().contains("common.schema.json") { + return Ok(self.common.clone()); + } + Err(format!("unexpected schema ref: {uri}").into()) + } +} + +fn run_cli(args: &[&str]) -> (i32, serde_json::Value) { + let output = Command::new(env!("CARGO_BIN_EXE_org2-pm")) + .args(args) + .env( + "ORGII_HOME", + std::env::var("ORGII_HOME").expect("sandbox sets ORGII_HOME"), + ) + .output() + .expect("spawn org2-pm"); + let stdout = String::from_utf8_lossy(&output.stdout); + let envelope: serde_json::Value = + serde_json::from_str(stdout.trim()).unwrap_or_else(|err| panic!("{stdout}: {err}")); + (output.status.code().unwrap_or(-1), envelope) +} + +fn assert_valid(validator: &jsonschema::Validator, value: &serde_json::Value, label: &str) { + let errors: Vec = validator + .iter_errors(value) + .map(|err| format!("{}: {}", err.instance_path, err)) + .collect(); + assert!(errors.is_empty(), "{label} failed schema validation:\n{}", errors.join("\n")); +} + +#[test] +fn real_envelopes_validate_against_frozen_schemas() { + let _sandbox = test_env::sandbox(); + let envelope_schema = schema_validator("envelope.schema.json"); + let context_schema = schema_validator("execution-context.schema.json"); + + let (exit, bare_context) = run_cli(&["context"]); + assert_eq!(exit, 0, "{bare_context}"); + assert_valid(&envelope_schema, &bare_context, "bare context envelope"); + assert_valid(&context_schema, &bare_context["data"], "bare context data (null scope/actor)"); + + let (exit, project_context) = run_cli(&[ + "context", "--mode", "project", "--scope", "demo", "--actor", "human:conformance", + ]); + assert_eq!(exit, 0, "{project_context}"); + assert_valid(&envelope_schema, &project_context, "project context envelope"); + assert_valid(&context_schema, &project_context["data"], "project context data"); + + let (exit, gated) = run_cli(&["work", "create", "--title", "x", "--scope", "demo"]); + assert_eq!(exit, 5, "{gated}"); + assert_valid(&envelope_schema, &gated, "error envelope"); +} + +#[test] +fn error_fixtures_match_the_implementation_strings() { + let fixtures = docs_dir().join("fixtures/errors"); + let mut checked = 0; + for entry in std::fs::read_dir(&fixtures).expect("fixtures dir") { + let path = entry.expect("entry").path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let fixture = load_json(&path); + let code = fixture["error"]["code"].as_str().expect("code"); + let retryable = fixture["error"]["retryable"].as_bool().expect("retryable"); + let expected = expected_retryable(code); + assert_eq!( + retryable, expected, + "{path:?}: fixture retryable disagrees with ErrorCode::retryable" + ); + checked += 1; + } + assert!(checked >= 18, "expected the 18 frozen error fixtures, found {checked}"); +} + +fn expected_retryable(code: &str) -> bool { + matches!( + code, + "REVISION_CONFLICT" | "NOT_READY" | "PROVIDER_UNAVAILABLE" | "STORE_UNAVAILABLE" + ) +} diff --git a/src-tauri/crates/project-management/Cargo.toml b/src-tauri/crates/project-management/Cargo.toml index d5a3887871..1012a6ccbb 100644 --- a/src-tauri/crates/project-management/Cargo.toml +++ b/src-tauri/crates/project-management/Cargo.toml @@ -30,6 +30,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" chrono = { workspace = true } +chrono-tz = "0.10.4" +croner = "2" tokio = { workspace = true } tokio-util = { workspace = true } tracing = "0.1.37" diff --git a/src-tauri/crates/project-management/src/lib.rs b/src-tauri/crates/project-management/src/lib.rs index 710b5309e4..a69f7703f4 100644 --- a/src-tauri/crates/project-management/src/lib.rs +++ b/src-tauri/crates/project-management/src/lib.rs @@ -13,6 +13,7 @@ pub mod lineage; pub mod orchestrator; pub mod projects; pub mod provider_host; +pub mod project_service; pub mod routine_service; pub mod sync; pub mod team_inbox; diff --git a/src-tauri/crates/project-management/src/orchestrator/follow_up.rs b/src-tauri/crates/project-management/src/orchestrator/follow_up.rs index d25735ac3e..d5aa649c0f 100644 --- a/src-tauri/crates/project-management/src/orchestrator/follow_up.rs +++ b/src-tauri/crates/project-management/src/orchestrator/follow_up.rs @@ -36,6 +36,7 @@ pub fn create_follow_up( labels: parent.labels.clone(), milestone: parent.milestone.clone(), parent: Some(parent_short_id.to_string()), + stage: None, start_date: None, target_date: parent.target_date.clone(), created_by: Some("orchestrator".to_string()), diff --git a/src-tauri/crates/project-management/src/orchestrator/state_machine.rs b/src-tauri/crates/project-management/src/orchestrator/state_machine.rs index b0043e4a71..fe48e3e817 100644 --- a/src-tauri/crates/project-management/src/orchestrator/state_machine.rs +++ b/src-tauri/crates/project-management/src/orchestrator/state_machine.rs @@ -246,7 +246,15 @@ pub fn complete_linked_session( let idx = frontmatter .linked_sessions .iter() - .position(|ls| ls.session_id == session_id) + .rposition(|ls| { + ls.session_id == session_id && ls.status == LinkedSessionStatus::Running + }) + .or_else(|| { + frontmatter + .linked_sessions + .iter() + .rposition(|ls| ls.session_id == session_id) + }) .or_else(|| { tracing::warn!( "[state_machine] No linked session for '{}', falling back to pending placeholder", diff --git a/src-tauri/crates/project-management/src/orchestrator/tests/proof_of_work_tests.rs b/src-tauri/crates/project-management/src/orchestrator/tests/proof_of_work_tests.rs index f2af2368f3..a54e504abb 100644 --- a/src-tauri/crates/project-management/src/orchestrator/tests/proof_of_work_tests.rs +++ b/src-tauri/crates/project-management/src/orchestrator/tests/proof_of_work_tests.rs @@ -13,6 +13,7 @@ fn empty_frontmatter() -> WorkItemFrontmatter { labels: Vec::new(), milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs b/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs index 5cfbe0bfe4..a18f325a43 100644 --- a/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs +++ b/src-tauri/crates/project-management/src/orchestrator/tests/state_machine_tests.rs @@ -14,6 +14,7 @@ fn make_frontmatter() -> WorkItemFrontmatter { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, @@ -369,3 +370,33 @@ fn complete_linked_session_falls_back_to_pending() { assert_eq!(fm.linked_sessions[0].session_id, "real-sess-id"); assert_eq!(fm.linked_sessions[0].status, LinkedSessionStatus::Completed); } + +#[test] +fn complete_linked_session_prefers_latest_running_duplicate() { + let mut fm = make_frontmatter(); + add_linked_session( + &mut fm, + "sess-1", + AgentRole::Coding, + LinkedSessionType::Native, + ); + fm.linked_sessions[0].status = LinkedSessionStatus::Completed; + add_linked_session( + &mut fm, + "sess-1", + AgentRole::Coding, + LinkedSessionType::Native, + ); + + complete_linked_session( + &mut fm, + "sess-1", + LinkedSessionStatus::Completed, + 0.25, + 750, + ); + + assert_eq!(fm.linked_sessions[0].total_tokens, 0); + assert_eq!(fm.linked_sessions[1].status, LinkedSessionStatus::Completed); + assert_eq!(fm.linked_sessions[1].total_tokens, 750); +} diff --git a/src-tauri/crates/project-management/src/project_service/mod.rs b/src-tauri/crates/project-management/src/project_service/mod.rs new file mode 100644 index 0000000000..578a67166d --- /dev/null +++ b/src-tauri/crates/project-management/src/project_service/mod.rs @@ -0,0 +1,151 @@ +//! Project application service: the single boundary for Project +//! creation and mutation. Slug allocation, org validation, audit and +//! the `pm_change_seq` watermark live here — callers (CLI, agent +//! tools, Tauri) describe the project; the service owns row +//! construction. + +use crate::projects::io as project_io; +use crate::projects::types::{ProjectData, ProjectMeta}; +use crate::work_service; + +pub fn slugify(name: &str) -> String { + let mut slug = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + } else if (ch == ' ' || ch == '-' || ch == '_') && !slug.ends_with('-') { + slug.push('-'); + } + } + slug.trim_matches('-').to_string() +} + +fn derive_prefix(name: &str) -> String { + let letters: String = name + .chars() + .filter(|ch| ch.is_ascii_alphabetic()) + .take(3) + .collect::() + .to_ascii_uppercase(); + if letters.len() >= 2 { + letters + } else { + "PRJ".to_string() + } +} + +fn audit_project( + operation: &'static str, + slug: &str, + org_id: Option<&str>, +) -> Result<(), String> { + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction() + .map_err(|err| format!("project audit tx: {err}"))?; + let seq = work_service::audit::bump_change_seq(&tx)?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation, + entity_type: "project", + entity_id: slug, + project_slug: Some(slug), + org_id, + actor: None, + revision: 0, + seq, + payload: serde_json::json!({}), + }, + )?; + tx.commit() + .map_err(|err| format!("project audit commit: {err}")) +} + +pub struct CreateProjectRequest { + pub name: String, + pub description: String, + pub org_id: Option, + pub status: Option, + pub priority: Option, + pub lead: Option, + pub labels: Vec, +} + +pub fn create_project(request: &CreateProjectRequest) -> Result { + let slug = slugify(&request.name); + if slug.is_empty() { + return Err("Cannot create project: name produces empty slug".to_string()); + } + if project_io::read_project(&slug).is_ok() { + return Err(work_service::error::already_exists(&slug)); + } + let org_id = match request.org_id.as_deref() { + Some(requested) if requested != "personal-org" => { + let orgs = project_io::read_project_orgs()?; + if !orgs.iter().any(|org| org.id == requested) { + return Err(format!("Unknown org_id '{requested}'")); + } + requested.to_string() + } + _ => "personal-org".to_string(), + }; + let now = chrono::Utc::now().to_rfc3339(); + let meta = ProjectMeta { + id: format!("proj-{slug}"), + name: request.name.clone(), + org_id: org_id.clone(), + status: request.status.clone().unwrap_or_else(|| "backlog".to_string()), + priority: request.priority.clone().unwrap_or_else(|| "none".to_string()), + health: "no_updates".to_string(), + lead: request.lead.clone(), + members: vec![], + labels: request.labels.clone(), + linked_repos: vec![], + start_date: None, + target_date: None, + created_at: now.clone(), + updated_at: now, + next_work_item_id: 1, + work_item_prefix: derive_prefix(&request.name), + work_item_prefix_custom: false, + agent_defaults: None, + }; + project_io::write_project(&slug, &meta, &request.description, true)?; + audit_project("project.create", &slug, Some(&org_id))?; + project_io::read_project(&slug) +} + +pub struct UpdateProjectRequest { + pub name: Option, + pub description: Option, + pub status: Option, + pub priority: Option, + pub lead: Option, +} + +pub fn update_project(slug: &str, request: &UpdateProjectRequest) -> Result { + let existing = project_io::read_project(slug)?; + let mut meta = existing.meta.clone(); + if let Some(name) = &request.name { + meta.name = name.clone(); + } + if let Some(status) = &request.status { + meta.status = status.clone(); + } + if let Some(priority) = &request.priority { + meta.priority = priority.clone(); + } + if let Some(lead) = &request.lead { + meta.lead = Some(lead.clone()); + } + meta.updated_at = chrono::Utc::now().to_rfc3339(); + let readme = request + .description + .clone() + .unwrap_or_else(|| existing.description.clone()); + let org_id = meta.org_id.clone(); + project_io::write_project(slug, &meta, &readme, false)?; + audit_project("project.update", slug, Some(&org_id))?; + project_io::read_project(slug) +} diff --git a/src-tauri/crates/project-management/src/projects/commands/work_items.rs b/src-tauri/crates/project-management/src/projects/commands/work_items.rs index c14e43340a..e98dee9f13 100644 --- a/src-tauri/crates/project-management/src/projects/commands/work_items.rs +++ b/src-tauri/crates/project-management/src/projects/commands/work_items.rs @@ -177,7 +177,13 @@ pub async fn project_write_work_item( body: String, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - io::write_work_item(&project_slug, &short_id, &frontmatter, &body) + crate::work_service::overwrite_project_work_item( + &project_slug, + &short_id, + &frontmatter, + &body, + None, + ) }) .await .map_err(|err| format!("Task join error: {}", err))? @@ -191,7 +197,13 @@ pub async fn work_item_write_standalone_item( body: String, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - io::write_standalone_work_item(org_id.as_deref(), &short_id, &frontmatter, &body) + crate::work_service::overwrite_standalone_work_item( + org_id.as_deref(), + &short_id, + &frontmatter, + &body, + None, + ) }) .await .map_err(|err| format!("Task join error: {}", err))? @@ -283,12 +295,16 @@ pub async fn project_transition_work_item( expected_revision: Option, ) -> Result { tokio::task::spawn_blocking(move || { + let actor = crate::projects::types::WorkItemMutationActor { + id: "human:desktop".to_string(), + name: "Desktop".to_string(), + }; crate::work_service::transition_project_work_item( &project_slug, &short_id, &to_status, reason.as_deref(), - None, + Some(&actor), expected_revision, ) }) diff --git a/src-tauri/crates/project-management/src/projects/events.rs b/src-tauri/crates/project-management/src/projects/events.rs index 40ad5b1351..a1fb28df5d 100644 --- a/src-tauri/crates/project-management/src/projects/events.rs +++ b/src-tauri/crates/project-management/src/projects/events.rs @@ -30,6 +30,37 @@ pub fn notify_data_changed() { } } +/// A work item that just crossed into a terminal portable state +/// (completed/failed/cancelled families) through the atomic RMW kernel. +/// Carries enough scope to re-read the item without another query shape. +#[derive(Debug, Clone)] +pub struct WorkItemTerminalEvent { + pub org_id: String, + pub project_slug: Option, + pub short_id: String, + pub parent: Option, + pub status: String, +} + +static WORK_ITEM_TERMINAL_NOTIFIER: OnceLock< + Box, +> = OnceLock::new(); + +/// App-level registration for terminal-transition observers (the +/// child-done parent wake). First call wins. +pub fn register_work_item_terminal_notifier( + notifier: Box, +) { + let _ = WORK_ITEM_TERMINAL_NOTIFIER.set(notifier); +} + +/// Fire after the mutation transaction commits. No-op before registration. +pub(crate) fn notify_work_item_terminal(event: WorkItemTerminalEvent) { + if let Some(notifier) = WORK_ITEM_TERMINAL_NOTIFIER.get() { + notifier(event); + } +} + /// Register the in-process wake-up used by the work-item schedule executor. /// /// This is deliberately separate from [`DATA_CHANGED_NOTIFIER`]: frontend diff --git a/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs b/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs index 4cfe4d6781..26bd53ca06 100644 --- a/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs +++ b/src-tauri/crates/project-management/src/projects/io/git_folder_sync.rs @@ -586,7 +586,12 @@ fn write_work_item_markdown(path: &Path, work_item: &WorkItemData) -> Result<(), fs::create_dir_all(parent) .map_err(|err| format!("Failed to create folder {}: {}", parent.display(), err))?; } - let frontmatter = serde_yaml::to_string(&work_item.frontmatter) + // Execution state stays out of the git-synced markdown: sessions and + // orchestrator runs are per-device runtime facts, not durable spec. + let mut frontmatter_for_export = work_item.frontmatter.clone(); + frontmatter_for_export.linked_sessions = Vec::new(); + frontmatter_for_export.orchestrator_state = None; + let frontmatter = serde_yaml::to_string(&frontmatter_for_export) .map_err(|err| format!("Failed to encode work item frontmatter: {}", err))?; let contents = format!("---\n{}---\n{}", frontmatter, work_item.body); fs::write(path, contents) @@ -654,6 +659,7 @@ mod tests { labels: Vec::new(), milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/mod.rs b/src-tauri/crates/project-management/src/projects/io/mod.rs index e47356dc79..135c8c9394 100644 --- a/src-tauri/crates/project-management/src/projects/io/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/mod.rs @@ -25,7 +25,7 @@ pub use members::{ pub use milestones::{read_milestones, write_milestones}; pub use orgs::{ configure_project_org_collab_sync, configure_project_org_git_folder_sync, create_project_org, - delete_project_org, read_project_org, read_project_orgs, + delete_project_org, read_project_org, read_project_orgs, resolve_local_org_scope, }; pub use projects::{ delete_project, derive_work_item_prefix, normalize_custom_work_item_prefix, read_all_projects, @@ -44,6 +44,7 @@ pub use routines::{ update_routine_schedule_marks, upsert_routine, }; pub use work_items::orchestrator_view; +pub(crate) use work_items::{allocate_short_id_in_tx, apply_execution_claim, resolve_project_scope_in_tx, write_work_item_in_tx}; pub use work_items::{ acquire_execution_lock, allocate_short_id, allocate_standalone_short_id, apply_remote_merge, batch_delete_work_items, batch_update_work_items, delete_work_item, find_by_external_ref, @@ -57,7 +58,9 @@ pub use work_items::{ read_work_items_view_data_scoped, read_work_items_view_data_scoped_for_view, read_workspace_work_items_data, release_execution_lock, restore_work_item, transition_standalone_work_item_handoff, transition_work_item_handoff, - update_standalone_work_item_atomic, update_standalone_work_item_partial, + update_standalone_work_item_atomic, update_standalone_work_item_atomic_by, + update_standalone_work_item_atomic_serviced, + update_standalone_work_item_partial, update_work_item_atomic, update_work_item_atomic_serviced, update_work_item_atomic_with_revisions, update_work_item_partial, update_work_item_partial_enriched, update_work_item_partial_with_revisions, diff --git a/src-tauri/crates/project-management/src/projects/io/orgs.rs b/src-tauri/crates/project-management/src/projects/io/orgs.rs index c15d100627..f04b63eff6 100644 --- a/src-tauri/crates/project-management/src/projects/io/orgs.rs +++ b/src-tauri/crates/project-management/src/projects/io/orgs.rs @@ -32,6 +32,31 @@ pub fn read_project_orgs() -> Result, String> { Ok(orgs) } +/// Map a session-plane org scope to a local project-org id. Session rows +/// carry looser scopes than the projects store: the implicit personal org +/// (`personal-org`), bare project-org uuids, and cloud sidebar tags +/// (`cloud:`). The standalone FK only accepts local `project_orgs` +/// rows, so anything else resolves to `None` — the personal standalone +/// scope — instead of failing the insert downstream. +pub fn resolve_local_org_scope(raw: Option<&str>) -> Option { + let bare = raw?.trim(); + let bare = bare.strip_prefix("cloud:").unwrap_or(bare); + if bare.is_empty() || bare == PERSONAL_ORG_ID { + return None; + } + let connection = conn().ok()?; + let exists = connection + .query_row( + "SELECT 1 FROM project_orgs WHERE id = ?1", + params![bare], + |_| Ok(()), + ) + .optional() + .ok()? + .is_some(); + exists.then(|| bare.to_string()) +} + pub fn read_project_org(org_id: &str) -> Result { let connection = conn()?; map_db( diff --git a/src-tauri/crates/project-management/src/projects/io/routines.rs b/src-tauri/crates/project-management/src/projects/io/routines.rs index b030953918..c53856d491 100644 --- a/src-tauri/crates/project-management/src/projects/io/routines.rs +++ b/src-tauri/crates/project-management/src/projects/io/routines.rs @@ -2,6 +2,7 @@ use rusqlite::{params, OptionalExtension}; +use super::super::routine_schedule; use super::helpers::{conn, from_iso8601, map_db, now_ms, to_iso8601}; use crate::projects::types::{ RoutineConcurrencyPolicy, RoutineDefinition, RoutineFire, RoutineFireStatus, @@ -45,14 +46,33 @@ fn row_to_routine(row: &rusqlite::Row<'_>) -> rusqlite::Result>(9)?.map(to_iso8601), next_fire_at: row.get::<_, Option>(10)?.map(to_iso8601), + last_fire_at: row.get::<_, Option>(11)?.map(to_iso8601), + last_fire_status: row + .get::<_, Option>(12)? + .map(|status| parse_fire_status(&status, 12)) + .transpose()?, + last_fire_error: row.get(13)?, + last_fire_session_id: row.get(14)?, + last_fire_work_item_id: row.get(15)?, created_at: to_iso8601(row.get(7)?), updated_at: to_iso8601(row.get(8)?), }) } const ROUTINE_SELECT_COLUMNS: &str = - "id, name, description, enabled, trigger_json, run_template_json, - output_policy_json, created_at, updated_at, last_evaluated_at, next_fire_at"; + "routine.id, routine.name, routine.description, routine.enabled, + routine.trigger_json, routine.run_template_json, routine.output_policy_json, + routine.created_at, routine.updated_at, routine.last_evaluated_at, + routine.next_fire_at, + latest_fire.fired_at, latest_fire.status, latest_fire.error, + latest_fire.session_id, latest_fire.work_item_id"; + +const ROUTINE_FROM: &str = "routine_definitions AS routine + LEFT JOIN routine_fires AS latest_fire ON latest_fire.id = ( + SELECT fire.id FROM routine_fires AS fire + WHERE fire.routine_id = routine.id + ORDER BY fire.fired_at DESC, fire.id DESC LIMIT 1 + )"; const FIRE_SELECT_COLUMNS: &str = "id, routine_id, fired_at, status, session_id, agent_org_run_id, work_item_id, coalesced_into_fire_id, idempotency_key, started_at, @@ -69,22 +89,7 @@ fn decode_output_policy(raw: &str) -> rusqlite::Result { fn row_to_fire(row: &rusqlite::Row<'_>) -> rusqlite::Result { let status_raw: String = row.get(3)?; - let status = match status_raw.as_str() { - "pending" => RoutineFireStatus::Pending, - "started" => RoutineFireStatus::Started, - "succeeded" => RoutineFireStatus::Succeeded, - "failed" => RoutineFireStatus::Failed, - "skipped" => RoutineFireStatus::Skipped, - "coalesced" => RoutineFireStatus::Coalesced, - "queued" => RoutineFireStatus::Queued, - other => { - return Err(rusqlite::Error::FromSqlConversionFailure( - 3, - rusqlite::types::Type::Text, - format!("unknown routine fire status: {other}").into(), - )); - } - }; + let status = parse_fire_status(&status_raw, 3)?; Ok(RoutineFire { id: row.get(0)?, @@ -102,6 +107,25 @@ fn row_to_fire(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } +fn parse_fire_status(raw: &str, column: usize) -> rusqlite::Result { + Ok(match raw { + "pending" => RoutineFireStatus::Pending, + "started" => RoutineFireStatus::Started, + "succeeded" => RoutineFireStatus::Succeeded, + "failed" => RoutineFireStatus::Failed, + "skipped" => RoutineFireStatus::Skipped, + "coalesced" => RoutineFireStatus::Coalesced, + "queued" => RoutineFireStatus::Queued, + other => { + return Err(rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + format!("unknown routine fire status: {other}").into(), + )); + } + }) +} + fn status_to_str(status: &RoutineFireStatus) -> &'static str { match status { RoutineFireStatus::Pending => "pending", @@ -118,8 +142,8 @@ pub fn list_routines() -> Result, String> { let connection = conn()?; let mut stmt = map_db(connection.prepare(&format!( "SELECT {ROUTINE_SELECT_COLUMNS} - FROM routine_definitions - ORDER BY updated_at DESC, created_at DESC", + FROM {ROUTINE_FROM} + ORDER BY routine.updated_at DESC, routine.created_at DESC", )))?; let rows = map_db(stmt.query_map([], row_to_routine))?; let mut routines = Vec::new(); @@ -149,9 +173,9 @@ pub fn list_enabled_routines() -> Result, String> { let connection = conn()?; let mut stmt = map_db(connection.prepare(&format!( "SELECT {ROUTINE_SELECT_COLUMNS} - FROM routine_definitions - WHERE enabled = 1 - ORDER BY created_at ASC", + FROM {ROUTINE_FROM} + WHERE routine.enabled = 1 + ORDER BY routine.created_at ASC", )))?; let rows = map_db(stmt.query_map([], row_to_routine))?; let mut routines = Vec::new(); @@ -168,8 +192,8 @@ pub fn read_routine(id: &str) -> Result { .query_row( &format!( "SELECT {ROUTINE_SELECT_COLUMNS} - FROM routine_definitions - WHERE id = ?1", + FROM {ROUTINE_FROM} + WHERE routine.id = ?1", ), params![id], row_to_routine, @@ -194,12 +218,20 @@ pub fn upsert_routine(mut routine: RoutineDefinition) -> Result Result Result Result, String> completed_at, error FROM routine_fires WHERE routine_id = ?1 - ORDER BY fired_at DESC", + ORDER BY fired_at DESC + LIMIT 100", ))?; let rows = map_db(stmt.query_map([routine_id], row_to_fire))?; let mut fires = Vec::new(); @@ -718,6 +758,11 @@ mod tests { output_policy: policy, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), } @@ -760,6 +805,46 @@ mod tests { assert_eq!(read.output_policy, saved.output_policy); } + #[test] + fn upsert_computes_next_fire_immediately_in_declared_timezone() { + use chrono::Timelike; + + let _sandbox = test_env::sandbox(); + let mut routine = routine_fixture( + "routine-next-fire", + policy(RoutineConcurrencyPolicy::AlwaysCreate), + ); + routine.trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "America/Vancouver".to_string(), + }; + + let saved = upsert_routine(routine).expect("upsert routine"); + let next = saved.next_fire_at.expect("next fire projected on save"); + let next = chrono::DateTime::parse_from_rfc3339(&next) + .expect("next fire is RFC3339") + .with_timezone(&"America/Vancouver".parse::().unwrap()); + assert_eq!(next.hour(), 9); + assert_eq!(next.minute(), 0); + } + + #[test] + fn routine_projects_the_latest_fire_result() { + let _sandbox = test_env::sandbox(); + upsert_routine(routine_fixture( + "routine-latest-result", + policy(RoutineConcurrencyPolicy::AlwaysCreate), + )) + .expect("upsert routine"); + let fire = create_routine_fire("routine-latest-result").expect("create fire"); + mark_routine_fire_work_item_created(&fire.id, "ABC-0001").expect("link work item"); + + let routine = read_routine("routine-latest-result").expect("read routine"); + assert_eq!(routine.last_fire_status, Some(RoutineFireStatus::Succeeded)); + assert_eq!(routine.last_fire_work_item_id.as_deref(), Some("ABC-0001")); + assert!(routine.last_fire_at.is_some()); + } + #[test] fn empty_output_policy_json_decodes_to_default_policy() { assert_eq!( diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs index 575a09cd30..80f18c7ebd 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs @@ -241,12 +241,48 @@ pub fn update_standalone_work_item_atomic( short_id: &str, mutator: F, ) -> Result +where + F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, +{ + update_standalone_work_item_atomic_by(org_id, None, short_id, mutator) +} + +pub fn update_standalone_work_item_atomic_by( + org_id: Option<&str>, + actor: Option<&crate::projects::types::WorkItemMutationActor>, + short_id: &str, + mutator: F, +) -> Result +where + F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, +{ + update_standalone_work_item_atomic_serviced( + org_id, + actor, + AtomicServiceOptions::default(), + short_id, + mutator, + ) +} + +/// Standalone counterpart of [`update_work_item_atomic_serviced`]: same +/// atomic RMW, but the caller stamps the canonical audit operation +/// (e.g. `work.note`) instead of the default `work.patch`. +pub fn update_standalone_work_item_atomic_serviced( + org_id: Option<&str>, + actor: Option<&crate::projects::types::WorkItemMutationActor>, + service: AtomicServiceOptions, + short_id: &str, + mutator: F, +) -> Result where F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, { let org_id = org_id.unwrap_or("personal-org"); let (value, changed_fields, payload_tail_changed) = - update_standalone_work_item_atomic_as(org_id, short_id, None, |fm, body| mutator(fm, body))?; + update_standalone_work_item_atomic_as(org_id, short_id, actor, service, |fm, body| { + mutator(fm, body) + })?; if !changed_fields.is_empty() || payload_tail_changed { let data = super::crud::read_standalone_work_item(Some(org_id), short_id)?; crate::sync::collab_bridge::record_work_item_write( @@ -263,6 +299,7 @@ pub(super) fn update_standalone_work_item_atomic_as( org_id: &str, short_id: &str, actor: Option<&crate::projects::types::WorkItemMutationActor>, + service: AtomicServiceOptions, mutator: F, ) -> Result<(T, Vec<&'static str>, bool), String> where @@ -273,7 +310,7 @@ where short_id, HashMap::new(), actor, - AtomicServiceOptions::default(), + service, mutator, ) } @@ -536,7 +573,8 @@ where project_id = ?13, created_at = ?14, updated_at = ?15, - local_version = ?16 + local_version = ?16, + deleted_at = ?18 WHERE id = ?17", params![ frontmatter.title, @@ -556,6 +594,10 @@ where now, next_version, &core.work_item_id, + frontmatter + .deleted_at + .as_deref() + .map(crate::projects::io::helpers::from_iso8601), ], ))?; @@ -672,6 +714,31 @@ where if scheduler_changed { crate::projects::events::notify_work_item_schedule_changed(); } + if status_changed { + use crate::work_service::state::{map_legacy_status, WorkItemState}; + let was_terminal = matches!( + map_legacy_status(&core.status), + Some(WorkItemState::Completed | WorkItemState::Failed | WorkItemState::Cancelled) + ); + let is_terminal = matches!( + map_legacy_status(&frontmatter.status), + Some(WorkItemState::Completed | WorkItemState::Failed | WorkItemState::Cancelled) + ); + if is_terminal && !was_terminal { + crate::projects::events::notify_work_item_terminal( + crate::projects::events::WorkItemTerminalEvent { + org_id: next_org_id.clone(), + project_slug: match scope { + AtomicWorkItemScope::Project(slug) => Some(slug.to_string()), + AtomicWorkItemScope::Standalone { .. } => None, + }, + short_id: core.short_id.clone(), + parent: frontmatter.parent.clone(), + status: frontmatter.status.clone(), + }, + ); + } + } Ok((result, changed_fields, payload_tail_changed)) } @@ -686,6 +753,7 @@ fn payload_tail_fingerprint(fm: &WorkItemFrontmatter) -> serde_json::Value { serde_json::json!({ "project": fm.project, "parent": fm.parent, + "stage": fm.stage, "assignee_type": fm.assignee_type, "starred": fm.starred, "created_by": fm.created_by, @@ -779,6 +847,7 @@ fn touches_payload_tail(updates: &WorkItemPartialUpdate) -> bool { || updates.assignee_type.is_some() || updates.project.is_some() || updates.created_by.is_some() + || updates.stage.is_some() } /// Build the JSON payload that gets persisted to @@ -918,6 +987,9 @@ fn update_work_item_partial_scoped( if let Some(milestone) = updates.milestone.as_ref() { fm.milestone = milestone.clone(); } + if let Some(stage) = updates.stage.as_ref() { + fm.stage = *stage; + } if let Some(start_date) = updates.start_date.as_ref() { fm.start_date = start_date.clone(); } @@ -1121,6 +1193,7 @@ fn build_frontmatter( labels, milestone: core.milestone.clone(), parent: core.parent.clone(), + stage: extras.stage, start_date: core.start_date.clone(), target_date: core.target_date.clone(), created_by: extras.created_by.clone(), diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs index ca10ad40ef..2e290961a4 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs @@ -56,6 +56,7 @@ fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatt labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs b/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs index fe86327987..28f15f6538 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/batch.rs @@ -140,6 +140,7 @@ mod tests { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs index d938c6d917..de97458690 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs @@ -416,6 +416,30 @@ fn write_work_item_with_scope( stamp_local_revisions: bool, ) -> Result<(), String> { let mut connection = conn()?; + let tx = map_db(connection.transaction())?; + write_work_item_in_tx( + &tx, + project_id, + org_id, + short_id, + frontmatter, + body, + stamp_local_revisions, + )?; + map_db(tx.commit())?; + crate::projects::events::notify_work_item_schedule_changed(); + Ok(()) +} + +pub(crate) fn write_work_item_in_tx( + tx: &rusqlite::Transaction, + project_id: Option, + org_id: &str, + short_id: &str, + frontmatter: &WorkItemFrontmatter, + body: &str, + stamp_local_revisions: bool, +) -> Result<(), String> { let now = now_ms(); let mut next_frontmatter = frontmatter.clone(); next_frontmatter.project = project_id.clone(); @@ -430,7 +454,6 @@ fn write_work_item_with_scope( from_iso8601(&next_frontmatter.updated_at) }; let deleted_at = next_frontmatter.deleted_at.as_deref().map(from_iso8601); - let tx = map_db(connection.transaction())?; let existing_item: Option = map_db( tx.query_row( "SELECT id, title, body, status, priority, assignee, milestone, @@ -593,27 +616,44 @@ fn write_work_item_with_scope( params![&next_frontmatter.id, extras_json], ))?; - map_db(tx.commit())?; - crate::projects::events::notify_work_item_schedule_changed(); Ok(()) } /// Move a work item to the recoverable delete bin. pub fn delete_work_item(project_slug: &str, short_id: &str) -> Result<(), String> { - let mut existing = read_work_item(project_slug, short_id)?; + let existing = read_work_item(project_slug, short_id)?; if existing.frontmatter.deleted_at.is_some() { return Ok(()); } - - let deleted_at = chrono::Utc::now().to_rfc3339(); - append_deleted_event(&mut existing.frontmatter, &deleted_at); - existing.frontmatter.deleted_at = Some(deleted_at.clone()); - existing.frontmatter.updated_at = deleted_at; - write_work_item( + super::atomic::update_work_item_atomic_serviced( project_slug, short_id, - &existing.frontmatter, - &existing.body, + None, + super::atomic::AtomicServiceOptions { + operation: Some("work.delete"), + ..Default::default() + }, + |frontmatter, _body| { + let deleted_at = chrono::Utc::now().to_rfc3339(); + append_deleted_event(frontmatter, &deleted_at); + frontmatter.deleted_at = Some(deleted_at.clone()); + frontmatter.updated_at = deleted_at; + Ok(()) + }, + )?; + let connection = conn()?; + let project_id = resolve_project_id(&connection, project_slug)?; + let org_id: String = map_db(connection.query_row( + "SELECT org_id FROM projects WHERE id = ?1", + params![&project_id], + |row| row.get(0), + ))?; + drop(connection); + crate::sync::collab_bridge::record_work_item_write( + &org_id, + Some(project_slug), + &existing.frontmatter.id, + true, ) } @@ -640,20 +680,39 @@ pub(crate) fn purge_work_item(project_slug: &str, short_id: &str) -> Result<(), } pub fn restore_work_item(project_slug: &str, short_id: &str) -> Result { - let mut existing = read_work_item(project_slug, short_id)?; + let existing = read_work_item(project_slug, short_id)?; if existing.frontmatter.deleted_at.is_none() { return Ok(existing); } - - let restored_at = chrono::Utc::now().to_rfc3339(); - append_restored_event(&mut existing.frontmatter, &restored_at); - existing.frontmatter.deleted_at = None; - existing.frontmatter.updated_at = restored_at; - write_work_item( + super::atomic::update_work_item_atomic_serviced( project_slug, short_id, - &existing.frontmatter, - &existing.body, + None, + super::atomic::AtomicServiceOptions { + operation: Some("work.restore"), + ..Default::default() + }, + |frontmatter, _body| { + let restored_at = chrono::Utc::now().to_rfc3339(); + append_restored_event(frontmatter, &restored_at); + frontmatter.deleted_at = None; + frontmatter.updated_at = restored_at; + Ok(()) + }, + )?; + let connection = conn()?; + let project_id = resolve_project_id(&connection, project_slug)?; + let org_id: String = map_db(connection.query_row( + "SELECT org_id FROM projects WHERE id = ?1", + params![&project_id], + |row| row.get(0), + ))?; + drop(connection); + crate::sync::collab_bridge::record_work_item_write( + &org_id, + Some(project_slug), + &existing.frontmatter.id, + false, )?; read_work_item(project_slug, short_id) } @@ -687,6 +746,15 @@ pub fn allocate_short_id(project_slug: &str) -> Result { let mut connection = conn()?; let tx = map_db(connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate))?; + let short_id = allocate_short_id_in_tx(&tx, project_slug)?; + map_db(tx.commit())?; + Ok(short_id) +} + +pub(crate) fn allocate_short_id_in_tx( + tx: &rusqlite::Transaction, + project_slug: &str, +) -> Result { let (project_id, org_id, prefix, mut next_id) = map_db( tx.query_row( @@ -705,7 +773,7 @@ pub fn allocate_short_id(project_slug: &str) -> Result { )? .ok_or_else(|| format!("Project '{}' not found", project_slug))?; - if let Some(max_existing) = max_existing_work_item_number(&tx, &org_id, &prefix)? { + if let Some(max_existing) = max_existing_work_item_number(tx, &org_id, &prefix)? { let min_next = (max_existing as i64).saturating_add(1); if next_id < min_next { next_id = min_next; @@ -720,7 +788,6 @@ pub fn allocate_short_id(project_slug: &str) -> Result { params![bumped, now_ms(), project_id], ))?; - map_db(tx.commit())?; Ok(short_id) } @@ -734,7 +801,26 @@ pub fn allocate_standalone_short_id(org_id: Option<&str>) -> Result Result<(String, String), String> { + let project_id = map_db( + tx.query_row( + "SELECT id FROM projects WHERE slug = ?1", + params![project_slug], + |row| row.get::<_, String>(0), + ) + .optional(), + )? + .ok_or_else(|| format!("Project '{}' not found", project_slug))?; + let org_id: String = map_db(tx.query_row( + "SELECT org_id FROM projects WHERE id = ?1", + params![&project_id], + |row| row.get(0), + ))?; + Ok((project_id, org_id)) +} + fn resolve_project_id(connection: &C, slug: &str) -> Result where C: ConnectionLike, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs index f948896057..f917667dd0 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs @@ -44,6 +44,7 @@ fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatt labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs b/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs index e70be3dedc..7970ea39ae 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/enrichment.rs @@ -333,6 +333,7 @@ mod tests { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs b/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs index 317ed39901..3bbc1f8674 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/execution_lock.rs @@ -26,6 +26,20 @@ pub fn acquire_execution_lock( ..AtomicServiceOptions::default() }; update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { + apply_execution_claim(frontmatter, short_id, session_id, agent_role, reason) + }) +} + +/// Pure frontmatter mutation shared by the standalone lock acquisition +/// and the single-transaction `work.claim` service handler. +pub(crate) fn apply_execution_claim( + frontmatter: &mut crate::projects::types::WorkItemFrontmatter, + short_id: &str, + session_id: &str, + agent_role: Option<&str>, + reason: WorkItemExecutionLockReason, +) -> Result<(), String> { + { if let Some(lock) = frontmatter.execution_lock.as_ref() { if let Some(active_session_id) = lock.active_session_id.as_deref() { if active_session_id != session_id { @@ -50,31 +64,49 @@ pub fn acquire_execution_lock( let now = chrono::Utc::now().to_rfc3339(); let role = parse_agent_role(agent_role); - match frontmatter.linked_sessions.iter_mut().rev().find(|linked| { - linked.session_id == PENDING_SESSION_PLACEHOLDER - && linked.status == LinkedSessionStatus::Running - }) { - Some(pending) => { - pending.session_id = session_id.to_string(); - pending.agent_role = role.clone(); - pending.session_type = LinkedSessionType::Native; - pending.started_at = now.clone(); + match frontmatter + .linked_sessions + .iter_mut() + .rev() + .find(|linked| linked.session_id == session_id) + { + Some(existing) => { + // A barrier wake resumes the same durable Session. Reuse its + // timeline row instead of appending a duplicate with the same + // ID; terminal mirroring keys by Session ID. + existing.agent_role = role.clone(); + existing.session_type = LinkedSessionType::Native; + existing.status = LinkedSessionStatus::Running; + existing.completed_at = None; } None => { - frontmatter.linked_sessions.push(LinkedSession { - session_id: session_id.to_string(), - session_type: LinkedSessionType::Native, - agent_role: role.clone(), - started_at: now.clone(), - completed_at: None, - status: LinkedSessionStatus::Running, - cost_usd: 0.0, - total_tokens: 0, - parent_session_id: None, - sub_agent_name: None, - sub_agent_instance: None, - result_preview: None, - }); + match frontmatter.linked_sessions.iter_mut().rev().find(|linked| { + linked.session_id == PENDING_SESSION_PLACEHOLDER + && linked.status == LinkedSessionStatus::Running + }) { + Some(pending) => { + pending.session_id = session_id.to_string(); + pending.agent_role = role.clone(); + pending.session_type = LinkedSessionType::Native; + pending.started_at = now.clone(); + } + None => { + frontmatter.linked_sessions.push(LinkedSession { + session_id: session_id.to_string(), + session_type: LinkedSessionType::Native, + agent_role: role.clone(), + started_at: now.clone(), + completed_at: None, + status: LinkedSessionStatus::Running, + cost_usd: 0.0, + total_tokens: 0, + parent_session_id: None, + sub_agent_name: None, + sub_agent_instance: None, + result_preview: None, + }); + } + } } } @@ -93,7 +125,7 @@ pub fn acquire_execution_lock( }); frontmatter.updated_at = now; Ok(()) - }) + } } pub fn release_execution_lock( diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/extras.rs b/src-tauri/crates/project-management/src/projects/io/work_items/extras.rs index be706c77e1..0a7aec8b51 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/extras.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/extras.rs @@ -63,6 +63,8 @@ pub(super) struct ExtrasPayload { pub delegations: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub handoff: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub linked_sessions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -111,6 +113,7 @@ impl ExtrasPayload { history: fm.history.clone(), delegations: fm.delegations.clone(), handoff: fm.handoff.clone(), + stage: fm.stage, linked_sessions: fm.linked_sessions.clone(), proof_of_work: fm.proof_of_work.clone(), orchestrator_config: fm.orchestrator_config.clone(), diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs b/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs index 46dafa65bb..03e0585159 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs @@ -134,6 +134,7 @@ pub fn transition_standalone_work_item_handoff( org_id, short_id, Some(&transition.actor), + crate::projects::io::work_items::atomic::AtomicServiceOptions::default(), |frontmatter, _body| { let handoff = frontmatter .handoff diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs b/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs index 5b562d5574..e3116df507 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/mapping.rs @@ -76,6 +76,7 @@ pub(super) fn assemble_work_item( labels, milestone: core.milestone, parent: core.parent, + stage: extras.stage, start_date: core.start_date, target_date: core.target_date, created_by: extras.created_by, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs b/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs index e7375eacc5..1a116abacc 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs @@ -55,7 +55,9 @@ mod workspace; pub(crate) use atomic::update_standalone_work_item_partial_with_revisions; pub use atomic::{ - update_standalone_work_item_atomic, update_standalone_work_item_partial, + update_standalone_work_item_atomic, update_standalone_work_item_atomic_by, + update_standalone_work_item_atomic_serviced, + update_standalone_work_item_partial, update_work_item_atomic, update_work_item_atomic_as, update_work_item_atomic_serviced, update_work_item_atomic_with_revisions, update_work_item_partial, update_work_item_partial_with_revisions, AtomicServiceOptions, @@ -63,6 +65,7 @@ pub use atomic::{ pub use batch::{batch_delete_work_items, batch_update_work_items}; pub(crate) use crud::purge_work_item; pub(crate) use crud::write_work_item_remote; +pub(crate) use crud::{allocate_short_id_in_tx, resolve_project_scope_in_tx, write_work_item_in_tx}; pub use crud::{ allocate_short_id, allocate_standalone_short_id, delete_work_item, move_work_item, purge_expired_deleted_work_items, read_all_work_items, read_all_work_items_scoped, @@ -77,6 +80,7 @@ pub use enrichment::{ read_work_item_enriched_scoped, update_work_item_partial_enriched, }; pub use execution_lock::{acquire_execution_lock, release_execution_lock}; +pub(crate) use execution_lock::apply_execution_claim; pub use handoff::{transition_standalone_work_item_handoff, transition_work_item_handoff}; pub(crate) use sync_metadata::read_standalone_sync_metadata; pub use sync_metadata::{ diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/orchestrator_view_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/orchestrator_view_tests.rs index 7c07e6d94d..989344f7bc 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/orchestrator_view_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/orchestrator_view_tests.rs @@ -60,6 +60,7 @@ fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatt labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs b/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs index 2dd73119c6..860b553f14 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs @@ -398,6 +398,7 @@ mod tests { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/views.rs b/src-tauri/crates/project-management/src/projects/io/work_items/views.rs index 6be9cb9f7e..b191ac1e47 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/views.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/views.rs @@ -358,6 +358,7 @@ mod tests { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs b/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs index 39cc968225..ee20baa583 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/workspace.rs @@ -81,6 +81,7 @@ mod tests { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/mod.rs b/src-tauri/crates/project-management/src/projects/mod.rs index d83be5e4fd..c184df0f40 100644 --- a/src-tauri/crates/project-management/src/projects/mod.rs +++ b/src-tauri/crates/project-management/src/projects/mod.rs @@ -28,6 +28,7 @@ pub mod commands; pub mod events; pub mod io; pub mod paths; +pub mod routine_schedule; pub mod schema; pub mod sync_export; pub mod types; diff --git a/src-tauri/crates/project-management/src/projects/routine_schedule.rs b/src-tauri/crates/project-management/src/projects/routine_schedule.rs new file mode 100644 index 0000000000..fa9dbb1d7f --- /dev/null +++ b/src-tauri/crates/project-management/src/projects/routine_schedule.rs @@ -0,0 +1,153 @@ +//! Timezone-aware schedule math shared by Routine persistence and dispatch. + +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; + +use super::types::RoutineTrigger; + +fn parse_timezone(raw: &str) -> Result { + if raw.trim().is_empty() || raw.eq_ignore_ascii_case("utc") { + return Ok(chrono_tz::UTC); + } + raw.parse::() + .map_err(|err| format!("invalid routine timezone '{raw}': {err}")) +} + +fn parse_trigger_time(raw: &str) -> Result, String> { + if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) { + return Ok(parsed.with_timezone(&Utc)); + } + if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") { + return Ok(parsed.and_utc()); + } + Err(format!("invalid one-time trigger timestamp: {raw}")) +} + +/// Return every trigger instant in `(window_start, now]`. +pub fn due_times( + trigger: &RoutineTrigger, + window_start: &DateTime, + now: &DateTime, +) -> Result>, String> { + match trigger { + RoutineTrigger::OneTime { at } => { + let at_time = parse_trigger_time(at)?; + if at_time > *window_start && at_time <= *now { + Ok(vec![at_time]) + } else if at_time <= *window_start { + // A missed one-time trigger remains due exactly once. The + // caller's catch-up policy decides whether it executes. + Ok(vec![at_time]) + } else { + Ok(Vec::new()) + } + } + RoutineTrigger::Cron { cron, timezone } => { + let parsed = croner::Cron::new(cron) + .parse() + .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; + let timezone = parse_timezone(timezone)?; + let now_local = now.with_timezone(&timezone); + let mut cursor = window_start.with_timezone(&timezone); + let mut due = Vec::new(); + // Bound replay after long downtime or pathological expressions. + const MAX_DUE: usize = 1000; + while due.len() < MAX_DUE { + match parsed.find_next_occurrence(&cursor, false) { + Ok(next) if next <= now_local => { + due.push(next.with_timezone(&Utc)); + cursor = next; + } + Ok(_) => break, + Err(err) => { + return Err(format!( + "compute next occurrence for '{cron}' in '{timezone}': {err}" + )); + } + } + } + Ok(due) + } + } +} + +/// Compute the next trigger instant after `now`. +pub fn next_occurrence( + trigger: &RoutineTrigger, + now: &DateTime, +) -> Result>, String> { + match trigger { + RoutineTrigger::OneTime { at } => { + let at_time = parse_trigger_time(at)?; + Ok((at_time > *now).then_some(at_time)) + } + RoutineTrigger::Cron { cron, timezone } => { + let parsed = croner::Cron::new(cron) + .parse() + .map_err(|err| format!("invalid cron expression '{cron}': {err}"))?; + let timezone = parse_timezone(timezone)?; + let local_now = now.with_timezone(&timezone); + let next = parsed + .find_next_occurrence(&local_now, false) + .map_err(|err| { + format!("compute next occurrence for '{cron}' in '{timezone}': {err}") + })?; + Ok(Some(next.with_timezone(&Utc))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime { + Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap() + } + + #[test] + fn cron_uses_the_declared_timezone() { + let trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "America/Vancouver".to_string(), + }; + let now = at(2026, 8, 8, 17, 0); + let next = next_occurrence(&trigger, &now).unwrap().unwrap(); + assert_eq!(next, at(2026, 8, 9, 16, 0)); + } + + #[test] + fn cron_due_window_is_compared_in_the_declared_timezone() { + let trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "Asia/Shanghai".to_string(), + }; + let due = due_times(&trigger, &at(2026, 8, 8, 0, 0), &at(2026, 8, 8, 2, 0)).unwrap(); + assert_eq!(due, vec![at(2026, 8, 8, 1, 0)]); + } + + #[test] + fn legacy_cron_without_timezone_defaults_to_utc() { + let trigger: RoutineTrigger = + serde_json::from_str(r#"{"kind":"cron","cron":"0 9 * * *"}"#).unwrap(); + assert_eq!( + trigger, + RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "UTC".to_string(), + } + ); + } + + #[test] + fn invalid_timezone_is_rejected() { + let trigger = RoutineTrigger::Cron { + cron: "0 9 * * *".to_string(), + timezone: "Mars/Olympus".to_string(), + }; + assert!(next_occurrence(&trigger, &Utc::now()) + .unwrap_err() + .contains("invalid routine timezone")); + } +} diff --git a/src-tauri/crates/project-management/src/projects/sync_export.rs b/src-tauri/crates/project-management/src/projects/sync_export.rs index b8adf9ce59..bedac484c3 100644 --- a/src-tauri/crates/project-management/src/projects/sync_export.rs +++ b/src-tauri/crates/project-management/src/projects/sync_export.rs @@ -218,6 +218,7 @@ mod tests { labels: Vec::new(), milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/projects/types/routines.rs b/src-tauri/crates/project-management/src/projects/types/routines.rs index be13aa347d..bb3cafed51 100644 --- a/src-tauri/crates/project-management/src/projects/types/routines.rs +++ b/src-tauri/crates/project-management/src/projects/types/routines.rs @@ -5,8 +5,18 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RoutineTrigger { - OneTime { at: String }, - Cron { cron: String }, + OneTime { + at: String, + }, + Cron { + cron: String, + #[serde(default = "default_routine_timezone")] + timezone: String, + }, +} + +pub fn default_routine_timezone() -> String { + "UTC".to_string() } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -171,6 +181,17 @@ pub struct RoutineDefinition { /// Next computed fire time (ISO 8601), for display only. #[serde(default, skip_serializing_if = "Option::is_none")] pub next_fire_at: Option, + /// Latest occurrence summary, projected from `routine_fires` for list UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_fire_work_item_id: Option, pub created_at: String, pub updated_at: String, } diff --git a/src-tauri/crates/project-management/src/projects/types/work_items.rs b/src-tauri/crates/project-management/src/projects/types/work_items.rs index 9aa04cc25e..f96334d17d 100644 --- a/src-tauri/crates/project-management/src/projects/types/work_items.rs +++ b/src-tauri/crates/project-management/src/projects/types/work_items.rs @@ -237,6 +237,8 @@ pub struct WorkItemFrontmatter { pub milestone: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start_date: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -261,19 +263,18 @@ pub struct WorkItemFrontmatter { pub handoff: Option, // --- Agent Workflow Fields --- // - // `linked_sessions` and `orchestrator_state` are persisted in SQLite - // (`orchestrator_runs` / `orchestrator_linked_sessions`), NOT in the - // `.md` frontmatter. We keep the in-memory fields so existing mutator - // closures work unchanged, but they are skipped during YAML - // serialization. Deserialization is preserved so legacy `.md` files - // can still be parsed during the one-time migration. - #[serde(default, skip_serializing)] + // `linked_sessions` and `orchestrator_state` are execution state. + // They serialize normally (IPC reads must carry them — the detail + // surfaces render the Execution Log from this struct), but the + // git-folder `.md` export strips them at its own boundary so the + // synced markdown stays free of run-time state. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub linked_sessions: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub proof_of_work: Option, #[serde(skip_serializing_if = "Option::is_none")] pub orchestrator_config: Option, - #[serde(default, skip_serializing)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub orchestrator_state: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub follow_up_items: Vec, @@ -482,6 +483,8 @@ pub struct WorkItemPartialUpdate { skip_serializing_if = "Option::is_none" )] pub milestone: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option>, #[serde( default, deserialize_with = "deserialize_optional_update", diff --git a/src-tauri/crates/project-management/src/routine_service/convert.rs b/src-tauri/crates/project-management/src/routine_service/convert.rs index 7041717f83..b147f8284a 100644 --- a/src-tauri/crates/project-management/src/routine_service/convert.rs +++ b/src-tauri/crates/project-management/src/routine_service/convert.rs @@ -142,7 +142,10 @@ pub fn convert_definition( .to_string(), ); } - if !matches!(definition.run_template.workspace, RoutineWorkspaceTarget::None) { + if !matches!( + definition.run_template.workspace, + RoutineWorkspaceTarget::None + ) { warnings.push( "workspace/worktree target dropped from the portable spec; re-express as an execution binding" .to_string(), @@ -161,9 +164,9 @@ pub fn convert_definition( } let activation = match &definition.trigger { - RoutineTrigger::Cron { cron } => Activation::Schedule { + RoutineTrigger::Cron { cron, timezone } => Activation::Schedule { cron: cron.clone(), - timezone: "UTC".to_string(), + timezone: timezone.clone(), policies, }, RoutineTrigger::OneTime { at } => { @@ -230,10 +233,13 @@ pub fn convert_definition( /// expressible ones into `pm_routines` and reporting the rest. /// /// With `disable_converted_legacy`, successfully converted legacy rows -/// are disabled in the same pass so the legacy scheduler can never fire -/// them again — the portable scheduler is their only driver from then -/// on (no double-fire window). Skipped definitions stay enabled on the -/// legacy path until they become expressible. +/// that carry a scope binding are disabled in the same pass so the legacy +/// scheduler can never fire them again — the portable scheduler is their +/// only driver from then on (no double-fire window). Conversions without +/// a scope binding keep their legacy row enabled: the portable pass +/// cannot fire them, so disabling would silently kill the routine. +/// Skipped definitions stay enabled on the legacy path until they become +/// expressible. pub fn convert_all(disable_converted_legacy: bool) -> Result { let definitions = crate::projects::io::list_routines()?; let mut report = ConversionReport::default(); @@ -245,14 +251,20 @@ pub fn convert_all(disable_converted_legacy: bool) -> Result, created_by: Option<&crate::projects::types::WorkItemMutationActor>, + invoke_key: Option<&str>, ) -> Result { - // 1. Load the current definition. let connection = project_io::helpers::conn()?; let (spec_json, spec_hash, revision): (String, String, i64) = connection .query_row( @@ -197,7 +198,6 @@ pub fn invoke( let snapshot: spec::RoutineSpecFile = serde_json::from_str(&spec_json).map_err(|err| format!("snapshot parse: {err}"))?; - // 2. Validate inputs against the snapshot's contract. for (name, decl) in &snapshot.spec.inputs { if decl.required && !inputs.contains_key(name) { return Err(format!("{}:missing required input '{}'", error::INPUTS_INVALID, name)); @@ -211,9 +211,86 @@ pub fn invoke( let now = chrono::Utc::now().timestamp_millis(); let run_id = format!("run_{}{:05}", now, std::process::id() % 100_000); + let actor_id = created_by.map(|actor| actor.id.as_str()).unwrap_or("system"); + let canonical_request = serde_json::json!({ + "routine": routine_name, + "scope": scope_project_slug, + "inputs": inputs, + }); + let canonical = serde_json::to_string(&canonical_request) + .map_err(|err| format!("routine invoke canonicalize: {err}"))?; + + // The whole graph — idempotency check, id allocation, every item, + // every relation, the run row, all audit rows and one watermark bump + // — commits or rolls back as a unit. + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("routine invoke tx: {err}"))?; + + if let Some(key) = invoke_key { + let existing: Option<(String, Option)> = tx + .query_row( + "SELECT request_hash, response_json FROM pm_idempotency + WHERE actor_id = ?1 AND operation = 'routine.invoke' AND scope_id = ?2 AND idem_key = ?3", + rusqlite::params![actor_id, scope_project_slug, key], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map(Some) + .or_else(|err| match err { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(format!("routine invoke idempotency: {other}")), + })?; + if let Some((stored_request, stored_response)) = existing { + if stored_request != canonical { + return Err(format!( + "{}:routine.invoke:{}", + work_service::error::IDEMPOTENCY_CONFLICT, + key + )); + } + let replayed: InvokedRun = stored_response + .as_deref() + .and_then(|raw| serde_json::from_str(raw).ok()) + .ok_or_else(|| "routine invoke replay: stored response unreadable".to_string())?; + return Ok(replayed); + } + } + + let (project_id, org_id) = project_io::resolve_project_scope_in_tx(&tx, scope_project_slug)?; + let seq = work_service::audit::bump_change_seq(&tx)?; + + let create_item = |short_id: &str, + request: &work_service::CreateWorkItemRequest| + -> Result<(), String> { + work_service::guard_new_work_item_id_in_tx(&tx, short_id)?; + let frontmatter = work_service::build_frontmatter_for_graph(short_id, request); + project_io::write_work_item_in_tx( + &tx, + Some(project_id.clone()), + &org_id, + short_id, + &frontmatter, + &request.body, + true, + )?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation: "work.create", + entity_type: "work_item", + entity_id: short_id, + project_slug: Some(scope_project_slug), + org_id: None, + actor: created_by, + revision: 0, + seq, + payload: serde_json::json!({}), + }, + ) + }; - // 3. Materialize the work graph through the canonical create handler. - let root_short_id = project_io::allocate_short_id(scope_project_slug)?; + let root_short_id = project_io::allocate_short_id_in_tx(&tx, scope_project_slug)?; let root_request = work_service::CreateWorkItemRequest { title: substitute_inputs(&snapshot.spec.root_work.title, inputs), body: snapshot @@ -228,11 +305,11 @@ pub fn invoke( created_by: created_by.map(|actor| actor.id.clone()), ..Default::default() }; - work_service::create_project_work_item(scope_project_slug, &root_short_id, &root_request, created_by)?; + create_item(&root_short_id, &root_request)?; let mut step_ids: Vec<(String, String)> = Vec::new(); for step in &snapshot.spec.steps { - let child_short_id = project_io::allocate_short_id(scope_project_slug)?; + let child_short_id = project_io::allocate_short_id_in_tx(&tx, scope_project_slug)?; let mut body = step .instruction .as_deref() @@ -258,45 +335,49 @@ pub fn invoke( created_by: created_by.map(|actor| actor.id.clone()), ..Default::default() }; - work_service::create_project_work_item( - scope_project_slug, - &child_short_id, - &child_request, - created_by, - )?; + create_item(&child_short_id, &child_request)?; step_ids.push((step.id.clone(), child_short_id)); } - // 4. Durable graph edges: dependencies + run provenance. let index: std::collections::HashMap<&str, &str> = step_ids .iter() .map(|(step_id, short_id)| (step_id.as_str(), short_id.as_str())) .collect(); + let relate_now = chrono::Utc::now().timestamp_millis(); + let insert_relation = |entity_id: &str, kind: &str, target_ref: &str| -> Result<(), String> { + tx.execute( + "INSERT INTO pm_relations (entity_type, entity_id, kind, target_ref, created_at, actor_id) + VALUES ('work_item', ?1, ?2, ?3, ?4, ?5)", + rusqlite::params![entity_id, kind, target_ref, relate_now, actor_id], + ) + .map_err(|err| format!("routine invoke relation: {err}"))?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation: "work.relate", + entity_type: "work_item", + entity_id, + project_slug: Some(scope_project_slug), + org_id: None, + actor: created_by, + revision: 0, + seq, + payload: serde_json::json!({ "kind": kind, "targetRef": target_ref }), + }, + ) + }; for step in &snapshot.spec.steps { let child = index[step.id.as_str()]; for need in &step.needs { - work_service::relate_project_work_item( - scope_project_slug, + insert_relation( child, "depends_on", &format!("work://{}/{}", scope_project_slug, index[need.as_str()]), - created_by, )?; } - work_service::relate_project_work_item( - scope_project_slug, - child, - "generated_by", - &format!("run://{}", run_id), - created_by, - )?; + insert_relation(child, "generated_by", &format!("run://{}", run_id))?; } - // 5. The run row + audit, one transaction. - let mut connection = project_io::helpers::conn()?; - let tx = connection - .transaction() - .map_err(|err| format!("routine invoke tx: {err}"))?; tx.execute( "INSERT INTO pm_routine_runs (id, routine_name, routine_revision, snapshot_json, snapshot_hash, @@ -317,7 +398,6 @@ pub fn invoke( ], ) .map_err(|err| format!("routine invoke: {err}"))?; - let seq = work_service::audit::bump_change_seq(&tx)?; work_service::audit::append_audit_event( &tx, &work_service::audit::AuditEventRow { @@ -336,14 +416,42 @@ pub fn invoke( }), }, )?; + + let invoked = InvokedRun { + run_id: run_id.clone(), + root_short_id: root_short_id.clone(), + steps: step_ids, + }; + if let Some(key) = invoke_key { + let response_raw = serde_json::to_string(&invoked) + .map_err(|err| format!("routine invoke serialize: {err}"))?; + tx.execute( + "INSERT INTO pm_idempotency + (actor_id, operation, scope_id, idem_key, request_hash, response_json, created_at) + VALUES (?1, 'routine.invoke', ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![actor_id, scope_project_slug, key, canonical, response_raw, now], + ) + .map_err(|err| format!("routine invoke idempotency record: {err}"))?; + } tx.commit() .map_err(|err| format!("routine invoke commit: {err}"))?; + crate::projects::events::notify_work_item_schedule_changed(); + let _ = crate::sync::collab_bridge::record_work_item_write( + &org_id, + Some(scope_project_slug), + &root_short_id, + false, + ); + for (_, child_id) in &invoked.steps { + let _ = crate::sync::collab_bridge::record_work_item_write( + &org_id, + Some(scope_project_slug), + child_id, + false, + ); + } - Ok(InvokedRun { - run_id, - root_short_id, - steps: step_ids, - }) + Ok(invoked) } /// Set the host-local default scope binding used by scheduled invokes. @@ -433,17 +541,95 @@ pub fn mark_evaluated(name: &str, evaluated_at: i64, next_fire_at: Option) } /// True when the routine has a non-terminal run (running or pending). +/// Stored 'running' runs whose generated items are all terminal get their +/// outcome written back so they stop suppressing the next scheduled fire. pub fn has_active_run(name: &str) -> Result { let connection = project_io::helpers::conn()?; - let count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM pm_routine_runs + let mut statement = connection + .prepare( + "SELECT id, status, scope_id FROM pm_routine_runs WHERE routine_name = ?1 AND status IN ('running', 'pending')", - rusqlite::params![name], - |row| row.get(0), ) .map_err(|err| format!("routine has_active_run: {err}"))?; - Ok(count > 0) + let candidates: Vec<(String, String, String)> = statement + .query_map(rusqlite::params![name], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .map_err(|err| format!("routine has_active_run: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine has_active_run: {err}"))?; + drop(statement); + drop(connection); + + for (run_id, status, scope_id) in candidates { + if status == "pending" { + return Ok(true); + } + if reconcile_running_run(&run_id, &scope_id)? { + return Ok(true); + } + } + Ok(false) +} + +fn reconcile_running_run(run_id: &str, scope_id: &str) -> Result { + use work_service::WorkItemState::{Cancelled, Completed, Failed}; + + let connection = project_io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT entity_id FROM pm_relations + WHERE kind = 'generated_by' AND target_ref = ?1 + ORDER BY id", + ) + .map_err(|err| format!("routine has_active_run: {err}"))?; + let child_ids: Vec = statement + .query_map(rusqlite::params![format!("run://{run_id}")], |row| { + row.get(0) + }) + .map_err(|err| format!("routine has_active_run: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine has_active_run: {err}"))?; + drop(statement); + drop(connection); + + if child_ids.is_empty() { + return Ok(true); + } + + let mut states = Vec::new(); + for child_id in &child_ids { + let Ok(item) = project_io::read_work_item(scope_id, child_id) else { + return Ok(true); + }; + states.push(work_service::state::map_legacy_status( + &item.frontmatter.status, + )); + } + + let all_terminal = states + .iter() + .all(|state| matches!(state, Some(Completed) | Some(Failed) | Some(Cancelled))); + if !all_terminal { + return Ok(true); + } + + let outcome = if states.contains(&Some(Failed)) { + "failed" + } else if states.iter().all(|state| *state == Some(Completed)) { + "succeeded" + } else { + "cancelled" + }; + + let connection = project_io::helpers::conn()?; + connection + .execute( + "UPDATE pm_routine_runs SET status = ?2 WHERE id = ?1 AND status = 'running'", + rusqlite::params![run_id, outcome], + ) + .map_err(|err| format!("routine has_active_run: {err}"))?; + Ok(false) } /// Audit a suppressed automatic fire (skip/coalesce/queue while active). diff --git a/src-tauri/crates/project-management/src/routine_service/spec.rs b/src-tauri/crates/project-management/src/routine_service/spec.rs index 42c7676fbb..4f2debd26d 100644 --- a/src-tauri/crates/project-management/src/routine_service/spec.rs +++ b/src-tauri/crates/project-management/src/routine_service/spec.rs @@ -233,7 +233,11 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { ); } if !step_ids.insert(step.id.clone()) { - push(&mut violations, &path, format!("duplicate step id '{}'", step.id)); + push( + &mut violations, + &path, + format!("duplicate step id '{}'", step.id), + ); } } @@ -249,15 +253,14 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { continue; } if !step_ids.contains(need) { - push( - &mut violations, - &path, - format!("unknown step '{need}'"), - ); + push(&mut violations, &path, format!("unknown step '{need}'")); continue; } *in_degree.entry(step.id.as_str()).or_insert(0) += 1; - dependents.entry(need.as_str()).or_default().push(step.id.as_str()); + dependents + .entry(need.as_str()) + .or_default() + .push(step.id.as_str()); } } let mut queue: Vec<&str> = in_degree @@ -304,7 +307,11 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { }; if let Some(name) = inner.strip_prefix("inputs.") { if !file.spec.inputs.contains_key(name) { - push(&mut violations, &path, format!("unknown routine input '{name}'")); + push( + &mut violations, + &path, + format!("unknown routine input '{name}'"), + ); } continue; } @@ -331,7 +338,9 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { push( &mut violations, &path, - format!("step '{source_id}' declares no output '{output_name}'"), + format!( + "step '{source_id}' declares no output '{output_name}'" + ), ); } } @@ -366,6 +375,12 @@ pub fn validate(file: &RoutineSpecFile) -> Vec { } if timezone.trim().is_empty() { push(&mut violations, &path, "timezone is required".into()); + } else if timezone.parse::().is_err() { + push( + &mut violations, + &path, + format!("timezone '{timezone}' must be a valid IANA timezone"), + ); } } } @@ -416,7 +431,9 @@ mod tests { file.spec.steps[1].needs = vec!["missing-step".to_string()]; let violations = validate(&file); assert!( - violations.iter().any(|v| v.message.contains("unknown step")), + violations + .iter() + .any(|v| v.message.contains("unknown step")), "{violations:?}" ); } diff --git a/src-tauri/crates/project-management/src/routine_service/tests.rs b/src-tauri/crates/project-management/src/routine_service/tests.rs index 2fe5693ebb..412e280774 100644 --- a/src-tauri/crates/project-management/src/routine_service/tests.rs +++ b/src-tauri/crates/project-management/src/routine_service/tests.rs @@ -41,6 +41,29 @@ fn apply_bumps_revision_when_the_body_changes() { assert_ne!(first.spec_hash, second.spec_hash); } +#[test] +fn schedule_activation_rejects_an_invalid_timezone() { + let mut file = fixture(); + let activation = file + .spec + .activations + .iter_mut() + .find_map(|activation| match activation { + spec::Activation::Schedule { timezone, .. } => Some(timezone), + _ => None, + }) + .expect("fixture has a schedule activation"); + *activation = "Mars/Olympus".to_string(); + + let violations = spec::validate(&file); + assert!( + violations + .iter() + .any(|violation| violation.message.contains("valid IANA timezone")), + "{violations:?}" + ); +} + #[test] fn invoke_materializes_the_work_graph_with_durable_edges() { let _sandbox = test_env::sandbox(); @@ -50,27 +73,43 @@ fn invoke_materializes_the_work_graph_with_durable_edges() { let mut inputs = std::collections::BTreeMap::new(); inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); - let run = invoke(&file.metadata.name, "demo", &inputs, None).expect("invoke"); + let run = invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke"); // Root carries the substituted template. let root = crate::projects::io::read_work_item("demo", &run.root_short_id).expect("root"); - assert!(root.frontmatter.title.contains("REQ-001"), "{}", root.frontmatter.title); + assert!( + root.frontmatter.title.contains("REQ-001"), + "{}", + root.frontmatter.title + ); // One generated child per step, parented to the root. assert_eq!(run.steps.len(), 3); for (_, child_id) in &run.steps { let child = crate::projects::io::read_work_item("demo", child_id).expect("child"); - assert_eq!(child.frontmatter.parent.as_deref(), Some(run.root_short_id.as_str())); + assert_eq!( + child.frontmatter.parent.as_deref(), + Some(run.root_short_id.as_str()) + ); } // Dependency edges are durable relations: review-impact depends_on // collect-deliverables; every child is generated_by the run. - let review_child = &run.steps.iter().find(|(id, _)| id == "review-impact").unwrap().1; - let collect_child = &run.steps.iter().find(|(id, _)| id == "collect-deliverables").unwrap().1; + let review_child = &run + .steps + .iter() + .find(|(id, _)| id == "review-impact") + .unwrap() + .1; + let collect_child = &run + .steps + .iter() + .find(|(id, _)| id == "collect-deliverables") + .unwrap() + .1; let relations = crate::work_service::list_work_item_relations(review_child).expect("relations"); let has_dep = relations.iter().any(|r| { - r["kind"] == "depends_on" - && r["targetRef"] == format!("work://demo/{}", collect_child) + r["kind"] == "depends_on" && r["targetRef"] == format!("work://demo/{}", collect_child) }); assert!(has_dep, "{relations:?}"); let has_run = relations @@ -99,15 +138,15 @@ fn invoke_validates_inputs_against_the_snapshot_contract() { let file = fixture(); apply(&file).expect("apply"); - let missing = invoke(&file.metadata.name, "demo", &Default::default(), None) + let missing = invoke(&file.metadata.name, "demo", &Default::default(), None, None) .expect_err("required input missing"); assert!(missing.starts_with(error::INPUTS_INVALID), "{missing}"); let mut inputs = std::collections::BTreeMap::new(); inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); inputs.insert("nonsense".to_string(), "x".to_string()); - let unknown = - invoke(&file.metadata.name, "demo", &inputs, None).expect_err("unknown input rejected"); + let unknown = invoke(&file.metadata.name, "demo", &inputs, None, None) + .expect_err("unknown input rejected"); assert!(unknown.starts_with(error::INPUTS_INVALID), "{unknown}"); } @@ -127,6 +166,7 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { enabled: true, trigger: RoutineTrigger::Cron { cron: "0 9 * * 1-5".to_string(), + timezone: "America/Vancouver".to_string(), }, run_template: RoutineRunTemplate { prompt: "Do the thing".to_string(), @@ -151,6 +191,11 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { }, last_evaluated_at: None, next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, created_at: String::new(), updated_at: String::new(), }; @@ -173,6 +218,148 @@ fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { assert!(reason.contains("Phase 5"), "{reason}"); } +fn set_child_status(scope: &str, short_id: &str, status: &str) { + let item = crate::projects::io::read_work_item(scope, short_id).expect("child readable"); + let mut frontmatter = item.frontmatter.clone(); + frontmatter.status = status.to_string(); + crate::projects::io::write_work_item(scope, short_id, &frontmatter, &item.body) + .expect("child status seeded"); +} + +fn stored_run_status(run_id: &str) -> String { + let connection = crate::projects::io::helpers::conn().expect("conn"); + connection + .query_row( + "SELECT status FROM pm_routine_runs WHERE id = ?1", + rusqlite::params![run_id], + |row| row.get(0), + ) + .expect("run row") +} + +#[test] +fn has_active_run_terminalizes_a_finished_run_and_unsuppresses() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); + let run = invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke"); + + assert!(has_active_run(&file.metadata.name).expect("active while children open")); + assert_eq!(stored_run_status(&run.run_id), "running"); + + for (_, child_id) in &run.steps { + set_child_status("demo", child_id, "done"); + } + + assert!(!has_active_run(&file.metadata.name).expect("inactive once children done")); + assert_eq!(stored_run_status(&run.run_id), "succeeded"); + + let second = invoke(&file.metadata.name, "demo", &inputs, None, None).expect("re-invoke"); + assert_ne!(second.run_id, run.run_id); +} + +#[test] +fn has_active_run_writes_back_failed_and_cancelled_outcomes() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); + + let failed_run = invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke"); + for (index, (_, child_id)) in failed_run.steps.iter().enumerate() { + let status = if index == 0 { "failed" } else { "done" }; + set_child_status("demo", child_id, status); + } + assert!(!has_active_run(&file.metadata.name).expect("failed run is not active")); + assert_eq!(stored_run_status(&failed_run.run_id), "failed"); + + let cancelled_run = + invoke(&file.metadata.name, "demo", &inputs, None, None).expect("invoke again"); + for (index, (_, child_id)) in cancelled_run.steps.iter().enumerate() { + let status = if index == 0 { "cancelled" } else { "done" }; + set_child_status("demo", child_id, status); + } + assert!(!has_active_run(&file.metadata.name).expect("cancelled run is not active")); + assert_eq!(stored_run_status(&cancelled_run.run_id), "cancelled"); +} + +#[test] +fn convert_all_keeps_the_legacy_row_enabled_without_a_scope_binding() { + use crate::projects::types::{ + RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineDefinition, RoutineOutputMode, + RoutineOutputPolicy, RoutineResourceSelection, RoutineRunTarget, RoutineRunTemplate, + RoutineTrigger, RoutineWorkspaceTarget, + }; + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + + let legacy = |name: &str, slug: Option<&str>| RoutineDefinition { + id: format!("legacy-{name}"), + name: name.to_string(), + description: "legacy description".to_string(), + enabled: true, + trigger: RoutineTrigger::Cron { + cron: "0 9 * * 1-5".to_string(), + timezone: "UTC".to_string(), + }, + run_template: RoutineRunTemplate { + prompt: "Do the thing".to_string(), + target: RoutineRunTarget::AgentDefinition { + agent_definition_id: Some("builtin:sde".to_string()), + }, + resources: RoutineResourceSelection { + key_source: None, + account_id: None, + model: None, + native_harness_type: None, + }, + workspace: RoutineWorkspaceTarget::None, + mode: None, + name: None, + }, + output_policy: RoutineOutputPolicy { + mode: RoutineOutputMode::CreateWorkItem, + concurrency_policy: RoutineConcurrencyPolicy::QueueIfActive, + catch_up_policy: RoutineCatchUpPolicy::RunOnce, + create_work_item_project_slug: slug.map(str::to_string), + ..RoutineOutputPolicy::default() + }, + last_evaluated_at: None, + next_fire_at: None, + last_fire_at: None, + last_fire_status: None, + last_fire_error: None, + last_fire_session_id: None, + last_fire_work_item_id: None, + created_at: String::new(), + updated_at: String::new(), + }; + + let unbound = crate::projects::io::upsert_routine(legacy("Unbound", None)).expect("seed"); + let bound = crate::projects::io::upsert_routine(legacy("Bound", Some("demo"))).expect("seed"); + + let report = convert::convert_all(true).expect("convert"); + assert_eq!(report.converted.len(), 2, "{report:?}"); + + let unbound_after = crate::projects::io::read_routine(&unbound.id).expect("read"); + assert!( + unbound_after.enabled, + "scope-less conversion must keep its legacy driver" + ); + let bound_after = crate::projects::io::read_routine(&bound.id).expect("read"); + assert!( + !bound_after.enabled, + "scope-bound conversion hands over to the portable pass" + ); +} + #[test] fn apply_rejects_invalid_specs_with_structured_violations() { let _sandbox = test_env::sandbox(); @@ -184,5 +371,112 @@ fn apply_rejects_invalid_specs_with_structured_violations() { err.starts_with(error::SPEC_INVALID), "typed sentinel expected: {err}" ); - assert!(err.contains("cycle"), "violation payload rides along: {err}"); + assert!( + err.contains("cycle"), + "violation payload rides along: {err}" + ); +} + +#[test] +fn invoke_with_key_replays_instead_of_reinvoking() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); + + let first = + invoke(&file.metadata.name, "demo", &inputs, None, Some("fire-1")).expect("first invoke"); + let second = + invoke(&file.metadata.name, "demo", &inputs, None, Some("fire-1")).expect("replay"); + assert_eq!(first.run_id, second.run_id); + assert_eq!(first.root_short_id, second.root_short_id); + + let connection = crate::projects::io::helpers::conn().expect("conn"); + let runs: i64 = connection + .query_row("SELECT COUNT(*) FROM pm_routine_runs", [], |row| row.get(0)) + .expect("count"); + assert_eq!(runs, 1, "replay must not mint a second graph"); + + let mut other_inputs = inputs.clone(); + other_inputs.insert("requirement_id".to_string(), "REQ-002".to_string()); + let conflict = invoke( + &file.metadata.name, + "demo", + &other_inputs, + None, + Some("fire-1"), + ) + .expect_err("different request on the same key"); + assert!( + conflict.starts_with(crate::work_service::error::IDEMPOTENCY_CONFLICT), + "{conflict}" + ); +} + +#[test] +fn invoke_rolls_back_the_whole_graph_when_a_node_collides() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + + let connection = crate::projects::io::helpers::conn().expect("conn"); + connection + .execute( + "INSERT OR IGNORE INTO project_orgs (id, name, slug, org_key, created_at, updated_at) + VALUES ('other-org', 'Other', 'other', 'other-key', 0, 0)", + [], + ) + .expect("seed org"); + connection + .execute( + "INSERT INTO workitems (id, org_id, short_id, title, status, created_at, updated_at) + VALUES ('AAA-0002', 'other-org', 'AAA-0002', 'cross-org landmine', 'backlog', 0, 0)", + [], + ) + .expect("seed landmine row"); + drop(connection); + + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert("requirement_id".to_string(), "REQ-001".to_string()); + let err = invoke(&file.metadata.name, "demo", &inputs, None, Some("fire-x")) + .expect_err("mid-graph collision must fail the invoke"); + assert!( + err.starts_with(crate::work_service::error::ALREADY_EXISTS), + "{err}" + ); + + let connection = crate::projects::io::helpers::conn().expect("conn"); + let (runs, items, relations, idem): (i64, i64, i64, i64) = ( + connection + .query_row("SELECT COUNT(*) FROM pm_routine_runs", [], |row| row.get(0)) + .expect("runs"), + connection + .query_row( + "SELECT COUNT(*) FROM workitems WHERE id != 'AAA-0002'", + [], + |row| row.get(0), + ) + .expect("items"), + connection + .query_row("SELECT COUNT(*) FROM pm_relations", [], |row| row.get(0)) + .expect("relations"), + connection + .query_row("SELECT COUNT(*) FROM pm_idempotency", [], |row| row.get(0)) + .expect("idem"), + ); + assert_eq!(runs, 0, "no run row survives the rollback"); + assert_eq!(items, 0, "no partial graph items survive"); + assert_eq!(relations, 0, "no relations survive"); + assert_eq!(idem, 0, "failed invoke records no idempotency row"); + + let landmine = + crate::projects::io::read_work_item_by_row_id("other-org", "AAA-0002").expect("read"); + assert_eq!( + landmine.expect("landmine survives").frontmatter.title, + "cross-org landmine" + ); } diff --git a/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs b/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs index 8427bfcef9..aa1f781ffb 100644 --- a/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs +++ b/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs @@ -656,6 +656,7 @@ fn frontmatter_from_wire( labels: tail(payload, "labels"), milestone: string_field(payload, "milestone"), parent: string_field(payload, "parentId"), + stage: payload.get("stage").and_then(|value| value.as_u64()).map(|value| value as u32), start_date: string_field(payload, "startDate"), target_date: string_field(payload, "targetDate"), created_by: string_field(payload, "createdBy"), diff --git a/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs b/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs index d714c61297..6e9f8f41d4 100644 --- a/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs +++ b/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs @@ -67,6 +67,7 @@ fn work_item_frontmatter(short_id: &str, title: &str) -> WorkItemFrontmatter { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/sync/worker/merge.rs b/src-tauri/crates/project-management/src/sync/worker/merge.rs index b3e7ada604..de84f4e0bb 100644 --- a/src-tauri/crates/project-management/src/sync/worker/merge.rs +++ b/src-tauri/crates/project-management/src/sync/worker/merge.rs @@ -546,6 +546,7 @@ fn build_inbound_create_frontmatter( labels, milestone: pick_string("milestone"), parent: None, + stage: None, start_date: pick_string("start_date"), target_date: pick_string("target_date"), created_by: pick_string("created_by"), diff --git a/src-tauri/crates/project-management/src/sync/worker/tests.rs b/src-tauri/crates/project-management/src/sync/worker/tests.rs index d5675d6da9..5165b9fb97 100644 --- a/src-tauri/crates/project-management/src/sync/worker/tests.rs +++ b/src-tauri/crates/project-management/src/sync/worker/tests.rs @@ -520,6 +520,7 @@ fn wi_fm(short_id: &str, title: &str, status: &str) -> WorkItemFrontmatter { labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/crates/project-management/src/work_service/audit.rs b/src-tauri/crates/project-management/src/work_service/audit.rs index fc9549e4f6..24635094f6 100644 --- a/src-tauri/crates/project-management/src/work_service/audit.rs +++ b/src-tauri/crates/project-management/src/work_service/audit.rs @@ -79,3 +79,64 @@ pub(crate) fn append_audit_event( ))?; Ok(()) } + +/// One work-item status crossing recorded in the audit stream. +#[derive(Debug, Clone)] +pub struct AuditStatusTransition { + pub seq: i64, + pub entity_id: String, + pub project_slug: Option, + pub org_id: Option, + pub status_from: String, + pub status_to: String, +} + +/// Read work-item status transitions committed after `after_seq`. +/// +/// Serves the desktop's cross-process bridge: CLI mutations audit their +/// `status_from`/`status_to` in the same transaction, so scanning the +/// stream is the reliable way to observe transitions made by other +/// processes (the in-process notifier cannot fire for them). +pub fn read_status_transitions_since( + after_seq: i64, +) -> Result, String> { + let connection = + database::db::get_projects_connection().map_err(|err| format!("pm audit: {}", err))?; + let mut stmt = map_db(connection.prepare( + "SELECT seq, entity_id, project_slug, org_id, payload_json + FROM pm_audit_events + WHERE entity_type = 'work_item' AND seq > ?1 AND payload_json LIKE '%status_to%' + ORDER BY seq ASC", + ))?; + let rows = map_db(stmt.query_map(params![after_seq], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + }))?; + let mut transitions = Vec::new(); + for row in rows { + let (seq, entity_id, project_slug, org_id, payload_json) = map_db(row)?; + let Ok(payload) = serde_json::from_str::(&payload_json) else { + continue; + }; + let (Some(status_from), Some(status_to)) = ( + payload.get("status_from").and_then(|value| value.as_str()), + payload.get("status_to").and_then(|value| value.as_str()), + ) else { + continue; + }; + transitions.push(AuditStatusTransition { + seq, + entity_id, + project_slug, + org_id, + status_from: status_from.to_string(), + status_to: status_to.to_string(), + }); + } + Ok(transitions) +} diff --git a/src-tauri/crates/project-management/src/work_service/mod.rs b/src-tauri/crates/project-management/src/work_service/mod.rs index e39b616c88..222cf7ed17 100644 --- a/src-tauri/crates/project-management/src/work_service/mod.rs +++ b/src-tauri/crates/project-management/src/work_service/mod.rs @@ -69,6 +69,7 @@ pub mod error { pub const REVISION_CONFLICT: &str = "PM_ERR:REVISION_CONFLICT"; pub const INVALID_TRANSITION: &str = "PM_ERR:INVALID_TRANSITION"; pub const IDEMPOTENCY_CONFLICT: &str = "PM_ERR:IDEMPOTENCY_CONFLICT"; + pub const ALREADY_EXISTS: &str = "PM_ERR:ALREADY_EXISTS"; pub fn revision_conflict(expected: i64, current: i64) -> String { format!("{}:{}:{}", REVISION_CONFLICT, expected, current) @@ -77,6 +78,10 @@ pub mod error { pub fn invalid_transition(from: &str, to: &str) -> String { format!("{}:{}:{}", INVALID_TRANSITION, from, to) } + + pub fn already_exists(short_id: &str) -> String { + format!("{}:{}", ALREADY_EXISTS, short_id) + } } /// Outcome of an idempotency-guarded operation. @@ -92,11 +97,15 @@ pub enum IdempotencyOutcome { /// wire contract §14.4. Same key + same canonical request replays the /// stored response; same key + different request is a conflict. /// -/// Residual (documented): the record is written after the operation -/// commits rather than inside its transaction, so a crash between the -/// two can re-run the operation on retry. The window closes when the -/// mutation handlers take in-tx hooks; local single-writer CLI usage is -/// unaffected in practice. +/// Reservation-first: the key row is inserted (response NULL) in its own +/// IMMEDIATE transaction BEFORE the operation runs, so a concurrent +/// duplicate waits briefly and replays instead of double-executing. A +/// crashed reservation older than the takeover window is reclaimed and +/// re-run. Narrowed residual: a crash between the operation's own commit +/// and the response write leaves a reservation whose takeover re-runs +/// the operation — the existence guards downstream turn that into a +/// structured error rather than a silent overwrite; full closure needs +/// in-tx execute hooks. pub fn run_idempotent( actor_id: &str, operation: &str, @@ -105,57 +114,502 @@ pub fn run_idempotent( canonical_request: &serde_json::Value, execute: impl FnOnce() -> Result, ) -> Result { + const TAKEOVER_AFTER_MS: i64 = 30_000; + const WAIT_STEP_MS: u64 = 100; + const WAIT_BUDGET_STEPS: u32 = 20; + let canonical = serde_json::to_string(canonical_request).map_err(|err| format!("canonicalize: {err}"))?; - let connection = project_io::helpers::conn()?; - let existing: Option<(String, Option)> = connection - .query_row( - "SELECT request_hash, response_json FROM pm_idempotency - WHERE actor_id = ?1 AND operation = ?2 AND scope_id = ?3 AND idem_key = ?4", - rusqlite::params![actor_id, operation, scope_id, key], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map(Some) - .or_else(|err| match err { - rusqlite::Error::QueryReturnedNoRows => Ok(None), - other => Err(format!("pm idempotency: {other}")), - })?; + let mut connection = project_io::helpers::conn()?; + + let mut waited: u32 = 0; + loop { + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("pm idempotency tx: {err}"))?; + let existing: Option<(String, Option, i64)> = tx + .query_row( + "SELECT request_hash, response_json, created_at FROM pm_idempotency + WHERE actor_id = ?1 AND operation = ?2 AND scope_id = ?3 AND idem_key = ?4", + rusqlite::params![actor_id, operation, scope_id, key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map(Some) + .or_else(|err| match err { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(format!("pm idempotency: {other}")), + })?; + + match existing { + Some((stored_request, _, _)) if stored_request != canonical => { + return Err(format!( + "{}:{}:{}", + error::IDEMPOTENCY_CONFLICT, + operation, + key + )); + } + Some((_, Some(stored_response), _)) => { + let response = serde_json::from_str(&stored_response) + .unwrap_or(serde_json::Value::Null); + return Ok(IdempotencyOutcome::Replayed(response)); + } + Some((_, None, reserved_at)) => { + let age = chrono::Utc::now().timestamp_millis() - reserved_at; + if age < TAKEOVER_AFTER_MS { + drop(tx); + if waited >= WAIT_BUDGET_STEPS { + return Err(format!( + "{}:{}:{}:in_progress", + error::IDEMPOTENCY_CONFLICT, + operation, + key + )); + } + waited += 1; + std::thread::sleep(std::time::Duration::from_millis(WAIT_STEP_MS)); + continue; + } + tx.execute( + "UPDATE pm_idempotency SET created_at = ?5 + WHERE actor_id = ?1 AND operation = ?2 AND scope_id = ?3 AND idem_key = ?4", + rusqlite::params![ + actor_id, + operation, + scope_id, + key, + chrono::Utc::now().timestamp_millis(), + ], + ) + .map_err(|err| format!("pm idempotency takeover: {err}"))?; + tx.commit() + .map_err(|err| format!("pm idempotency takeover commit: {err}"))?; + break; + } + None => { + tx.execute( + "INSERT INTO pm_idempotency + (actor_id, operation, scope_id, idem_key, request_hash, response_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)", + rusqlite::params![ + actor_id, + operation, + scope_id, + key, + canonical, + chrono::Utc::now().timestamp_millis(), + ], + ) + .map_err(|err| format!("pm idempotency reserve: {err}"))?; + tx.commit() + .map_err(|err| format!("pm idempotency reserve commit: {err}"))?; + break; + } + } + } - if let Some((stored_request, stored_response)) = existing { - if stored_request != canonical { + match execute() { + Ok(response) => { + let response_raw = serde_json::to_string(&response) + .map_err(|err| format!("serialize response: {err}"))?; + connection + .execute( + "UPDATE pm_idempotency SET response_json = ?5 + WHERE actor_id = ?1 AND operation = ?2 AND scope_id = ?3 AND idem_key = ?4", + rusqlite::params![actor_id, operation, scope_id, key, response_raw], + ) + .map_err(|err| format!("pm idempotency record: {err}"))?; + Ok(IdempotencyOutcome::Fresh(response)) + } + Err(err) => { + let _ = connection.execute( + "DELETE FROM pm_idempotency + WHERE actor_id = ?1 AND operation = ?2 AND scope_id = ?3 AND idem_key = ?4 + AND response_json IS NULL", + rusqlite::params![actor_id, operation, scope_id, key], + ); + Err(err) + } + } +} + +/// L3 ownership guard v1: a work item claimed by one session may not +/// have its lifecycle or content advanced by a different session. Human +/// direct operation passes no session and is exempt (actor/org policy +/// applies instead); agent-plane callers pass their session id. +fn guard_claim_holder( + frontmatter: &WorkItemFrontmatter, + caller_session: Option<&str>, +) -> Result<(), String> { + let Some(caller) = caller_session else { + return Ok(()); + }; + if let Some(holder) = frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_deref()) + { + if holder != caller { return Err(format!( - "{}:{}:{}", - error::IDEMPOTENCY_CONFLICT, - operation, - key + "Work item '{}' is claimed by another session: {}", + frontmatter.short_id, holder )); } - let response = stored_response - .and_then(|raw| serde_json::from_str(&raw).ok()) - .unwrap_or(serde_json::Value::Null); - return Ok(IdempotencyOutcome::Replayed(response)); } + Ok(()) +} - let response = execute()?; - let response_raw = - serde_json::to_string(&response).map_err(|err| format!("serialize response: {err}"))?; - connection - .execute( - "INSERT OR IGNORE INTO pm_idempotency - (actor_id, operation, scope_id, idem_key, request_hash, response_json, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![ - actor_id, - operation, - scope_id, - key, - canonical, - response_raw, - chrono::Utc::now().timestamp_millis(), - ], - ) - .map_err(|err| format!("pm idempotency record: {err}"))?; - Ok(IdempotencyOutcome::Fresh(response)) +/// OCC-capable non-lifecycle patch (`work.update` on the wire): title, +/// body and priority only — state changes stay with transition/claim. +#[allow(clippy::too_many_arguments)] +pub fn patch_project_work_item( + project_slug: &str, + short_id: &str, + title: Option<&str>, + body: Option<&str>, + priority: Option<&str>, + stage: Option>, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, + caller_session: Option<&str>, +) -> Result { + let title_owned = title.map(str::to_string); + let body_owned = body.map(str::to_string); + let priority_owned = priority.map(str::to_string); + let caller_owned = caller_session.map(str::to_string); + project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + expected_local_version: expected_revision, + operation: Some("work.update"), + ..Default::default() + }, + move |frontmatter, current_body| { + guard_claim_holder(frontmatter, caller_owned.as_deref())?; + if let Some(title) = title_owned { + frontmatter.title = title; + } + if let Some(body) = body_owned { + *current_body = body; + } + if let Some(priority) = priority_owned { + frontmatter.priority = priority; + } + if let Some(stage) = stage { + frontmatter.stage = stage; + } + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + Ok(()) + }, + )?; + project_io::read_work_item(project_slug, short_id) +} + +/// Standalone counterpart of [`patch_project_work_item`]: OCC-capable +/// non-lifecycle patch for an org-scoped item, used by the agent-plane +/// CLI to fill drafts that have no project. +#[allow(clippy::too_many_arguments)] +pub fn patch_standalone_work_item( + org_id: Option<&str>, + short_id: &str, + title: Option<&str>, + body: Option<&str>, + priority: Option<&str>, + stage: Option>, + actor: Option<&WorkItemMutationActor>, +) -> Result { + let title_owned = title.map(str::to_string); + let body_owned = body.map(str::to_string); + let priority_owned = priority.map(str::to_string); + project_io::update_standalone_work_item_atomic_by(org_id, actor, short_id, move |frontmatter, current_body| { + if let Some(title) = title_owned { + frontmatter.title = title; + } + if let Some(body) = body_owned { + *current_body = body; + } + if let Some(priority) = priority_owned { + frontmatter.priority = priority; + } + if let Some(stage) = stage { + frontmatter.stage = stage; + } + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + Ok(()) + })?; + project_io::read_standalone_work_item(org_id, short_id) +} + +/// Audited assignment (`work.assign`): ownership only, no run trigger — +/// dispatch semantics belong to the orchestration layer. +pub fn assign_project_work_item( + project_slug: &str, + short_id: &str, + assignee: &str, + assignee_type: Option<&str>, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, +) -> Result { + let assignee_owned = assignee.to_string(); + let type_owned = assignee_type.map(str::to_string); + project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + expected_local_version: expected_revision, + operation: Some("work.assign"), + ..Default::default() + }, + move |frontmatter, _body| { + frontmatter.assignee = Some(assignee_owned); + if let Some(kind) = type_owned { + frontmatter.assignee_type = Some(kind); + } + frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); + Ok(()) + }, + )?; + project_io::read_work_item(project_slug, short_id) +} + +/// Single-transaction `work.release`: only the claim holder may hand +/// back execution; the lock clears and the release edge returns the +/// item to open in the same audited mutation. +pub fn release_project_work_item( + project_slug: &str, + short_id: &str, + session_id: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result { + let session_owned = session_id.to_string(); + project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + operation: Some("work.release"), + reason: Some("released".to_string()), + ..Default::default() + }, + move |frontmatter, _body| { + let holder = frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.clone()); + match holder { + None => { + return Err(format!( + "Work item '{}' has no active claim to release", + frontmatter.short_id + )); + } + Some(active) if active != session_owned => { + return Err(format!( + "Work item '{}' is claimed by another session: {}", + frontmatter.short_id, active + )); + } + Some(_) => {} + } + frontmatter.execution_lock = None; + let now = chrono::Utc::now().to_rfc3339(); + for linked in frontmatter.linked_sessions.iter_mut() { + if linked.session_id == session_owned + && linked.status == crate::projects::types::LinkedSessionStatus::Running + { + linked.status = crate::projects::types::LinkedSessionStatus::Cancelled; + linked.completed_at = Some(now.clone()); + } + } + if matches!( + state::map_legacy_status(&frontmatter.status), + Some(WorkItemState::InProgress) + ) { + frontmatter.status = "open".to_string(); + } + frontmatter.updated_at = now; + Ok(()) + }, + )?; + project_io::read_work_item(project_slug, short_id) +} + +/// Audited whole-row overwrite for seed/E2E and any remaining +/// full-frontmatter writer: existing rows go through the serviced atomic +/// path (version bump + audit + watermark), missing rows take the +/// guarded single-transaction create. +pub fn overwrite_project_work_item( + project_slug: &str, + short_id: &str, + frontmatter: &WorkItemFrontmatter, + body: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + if project_io::read_work_item(project_slug, short_id).is_ok() { + let next_frontmatter = frontmatter.clone(); + let next_body = body.to_string(); + return project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + operation: Some("work.write"), + ..Default::default() + }, + move |current, current_body| { + *current = next_frontmatter; + *current_body = next_body; + Ok(()) + }, + ); + } + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("work.write tx: {err}"))?; + let (project_id, org_id) = project_io::resolve_project_scope_in_tx(&tx, project_slug)?; + guard_new_work_item_id_in_tx(&tx, short_id)?; + project_io::write_work_item_in_tx( + &tx, + Some(project_id), + &org_id, + short_id, + frontmatter, + body, + true, + )?; + append_create_audit_in_tx(&tx, short_id, Some(project_slug), None, actor)?; + tx.commit().map_err(|err| format!("work.write commit: {err}"))?; + crate::projects::events::notify_work_item_schedule_changed(); + crate::sync::collab_bridge::record_work_item_write( + &org_id, + Some(project_slug), + &frontmatter.id, + frontmatter.deleted_at.is_some(), + ) +} + +/// Standalone counterpart of [`overwrite_project_work_item`]. +pub fn overwrite_standalone_work_item( + org_id: Option<&str>, + short_id: &str, + frontmatter: &WorkItemFrontmatter, + body: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + let resolved_org = org_id.unwrap_or("personal-org").to_string(); + if project_io::read_standalone_work_item(org_id, short_id).is_ok() { + let next_frontmatter = frontmatter.clone(); + let next_body = body.to_string(); + project_io::update_standalone_work_item_atomic(org_id, short_id, move |current, current_body| { + *current = next_frontmatter; + *current_body = next_body; + Ok(()) + })?; + let _ = actor; + return Ok(()); + } + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("work.write tx: {err}"))?; + guard_new_work_item_id_in_tx(&tx, short_id)?; + project_io::write_work_item_in_tx(&tx, None, &resolved_org, short_id, frontmatter, body, true)?; + append_create_audit_in_tx(&tx, short_id, None, Some(&resolved_org), actor)?; + tx.commit().map_err(|err| format!("work.write commit: {err}"))?; + crate::projects::events::notify_work_item_schedule_changed(); + crate::sync::collab_bridge::record_work_item_write( + &resolved_org, + None, + &frontmatter.id, + frontmatter.deleted_at.is_some(), + ) +} + +/// Single-transaction `work.claim`: execution-lock acquisition and the +/// strict `open -> in_progress` transition commit together, with OCC +/// checked once at entry. Replaces the acquire-then-transition +/// composition whose first step bumped the revision the second step +/// then compared against. +pub fn claim_project_work_item( + project_slug: &str, + short_id: &str, + session_id: &str, + agent_role: Option<&str>, + reason: crate::projects::types::WorkItemExecutionLockReason, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, +) -> Result { + let short_id_owned = short_id.to_string(); + let session_owned = session_id.to_string(); + let role_owned = agent_role.map(|value| value.to_string()); + project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + expected_local_version: expected_revision, + operation: Some("work.claim"), + strict_fsm: true, + reason: Some("claimed".to_string()), + }, + move |frontmatter, _body| { + project_io::apply_execution_claim( + frontmatter, + &short_id_owned, + &session_owned, + role_owned.as_deref(), + reason, + )?; + frontmatter.status = "in_progress".to_string(); + Ok(()) + }, + )?; + project_io::read_work_item(project_slug, short_id) +} + +/// Standalone-org counterpart to [`claim_project_work_item`]: same +/// execution-claim semantics and `in_progress` entry for org-scoped +/// items that live outside any project. +#[allow(clippy::too_many_arguments)] +pub fn claim_standalone_work_item( + org_id: Option<&str>, + short_id: &str, + session_id: &str, + agent_role: Option<&str>, + reason: crate::projects::types::WorkItemExecutionLockReason, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, +) -> Result { + let short_id_owned = short_id.to_string(); + let session_owned = session_id.to_string(); + let role_owned = agent_role.map(|value| value.to_string()); + project_io::update_standalone_work_item_atomic_serviced( + org_id, + actor, + project_io::AtomicServiceOptions { + expected_local_version: expected_revision, + operation: Some("work.claim"), + strict_fsm: true, + reason: Some("claimed".to_string()), + }, + short_id, + move |frontmatter, _body| { + project_io::apply_execution_claim( + frontmatter, + &short_id_owned, + &session_owned, + role_owned.as_deref(), + reason, + )?; + frontmatter.status = "in_progress".to_string(); + Ok(()) + }, + )?; + project_io::read_standalone_work_item(org_id, short_id) } /// Strict, audited status transition for a project-scoped work item. @@ -173,9 +627,33 @@ pub fn transition_project_work_item( reason: Option<&str>, actor: Option<&WorkItemMutationActor>, expected_revision: Option, +) -> Result { + transition_project_work_item_scoped( + project_slug, + short_id, + to_status, + reason, + actor, + expected_revision, + None, + ) +} + +/// Session-scoped transition: the agent plane passes its session id and +/// the L3 claim-holder guard applies; the human plane passes None. +#[allow(clippy::too_many_arguments)] +pub fn transition_project_work_item_scoped( + project_slug: &str, + short_id: &str, + to_status: &str, + reason: Option<&str>, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, + caller_session: Option<&str>, ) -> Result { let to_status_owned = to_status.to_string(); let reason_owned = reason.map(|value| value.to_string()); + let caller_owned = caller_session.map(str::to_string); project_io::update_work_item_atomic_serviced( project_slug, short_id, @@ -187,6 +665,7 @@ pub fn transition_project_work_item( reason: reason_owned, }, move |frontmatter, _body| { + guard_claim_holder(frontmatter, caller_owned.as_deref())?; if frontmatter.status == to_status_owned { return Err(error::invalid_transition( &frontmatter.status, @@ -209,6 +688,52 @@ pub fn transition_project_work_item( project_io::read_work_item(project_slug, short_id) } +/// Standalone-org counterpart to [`transition_project_work_item_scoped`]: +/// same claim guard, FSM strictness, release edge, and audit label. +pub fn transition_standalone_work_item( + org_id: Option<&str>, + short_id: &str, + to_status: &str, + reason: Option<&str>, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, + caller_session: Option<&str>, +) -> Result { + let to_status_owned = to_status.to_string(); + let reason_owned = reason.map(|value| value.to_string()); + let caller_owned = caller_session.map(str::to_string); + project_io::update_standalone_work_item_atomic_serviced( + org_id, + actor, + project_io::AtomicServiceOptions { + expected_local_version: expected_revision, + operation: Some("work.transition"), + strict_fsm: true, + reason: reason_owned, + }, + short_id, + move |frontmatter, _body| { + guard_claim_holder(frontmatter, caller_owned.as_deref())?; + if frontmatter.status == to_status_owned { + return Err(error::invalid_transition( + &frontmatter.status, + &to_status_owned, + )); + } + let releases_to_open = matches!( + state::map_legacy_status(&to_status_owned), + Some(state::WorkItemState::Open) + ); + frontmatter.status = to_status_owned.clone(); + if releases_to_open { + frontmatter.execution_lock = None; + } + Ok(()) + }, + )?; + project_io::read_standalone_work_item(org_id, short_id) +} + /// Creation DTO for the canonical `work.create` application operation. /// /// Deliberately NOT the 32-field `WorkItemFrontmatter`: callers describe @@ -230,6 +755,8 @@ pub struct CreateWorkItemRequest { pub labels: Vec, pub milestone: Option, pub parent: Option, + #[serde(default)] + pub stage: Option, pub start_date: Option, pub target_date: Option, pub created_by: Option, @@ -247,6 +774,13 @@ pub struct CreateWorkItemRequest { pub linked_sessions: Vec, } +pub(crate) fn build_frontmatter_for_graph( + short_id: &str, + request: &CreateWorkItemRequest, +) -> WorkItemFrontmatter { + build_frontmatter(short_id, request) +} + fn build_frontmatter(short_id: &str, request: &CreateWorkItemRequest) -> WorkItemFrontmatter { let now = chrono::Utc::now().to_rfc3339(); WorkItemFrontmatter { @@ -267,6 +801,7 @@ fn build_frontmatter(short_id: &str, request: &CreateWorkItemRequest) -> WorkIte labels: request.labels.clone(), milestone: request.milestone.clone(), parent: request.parent.clone(), + stage: request.stage, start_date: request.start_date.clone(), target_date: request.target_date.clone(), created_by: request.created_by.clone(), @@ -292,23 +827,40 @@ fn build_frontmatter(short_id: &str, request: &CreateWorkItemRequest) -> WorkIte } } -/// Audit a creation. Residual: the audit row commits in its own small -/// transaction right after the insert (the crud write path doesn't take -/// in-tx hooks yet); the crash window between the two is the documented -/// gap that closes when crud converges onto the serviced choke point. -fn audit_create( +// Audit residual: the audit row commits in its own small transaction right +// after the insert (the crud write path doesn't take in-tx hooks yet); the +// crash window closes when crud converges onto the serviced choke point. + +/// The `workitems` PK is a single global column, so a same-id row in any +/// other scope would be silently reassigned by the upsert. Creation must +/// refuse instead of clobbering. +pub(crate) fn guard_new_work_item_id_in_tx( + tx: &rusqlite::Transaction, + short_id: &str, +) -> Result<(), String> { + let count: i64 = tx + .query_row( + "SELECT COUNT(*) FROM workitems WHERE id = ?1", + rusqlite::params![short_id], + |row| row.get(0), + ) + .map_err(|err| format!("work.create existence guard: {err}"))?; + if count > 0 { + return Err(error::already_exists(short_id)); + } + Ok(()) +} + +fn append_create_audit_in_tx( + tx: &rusqlite::Transaction, entity_id: &str, project_slug: Option<&str>, org_id: Option<&str>, actor: Option<&WorkItemMutationActor>, ) -> Result<(), String> { - let mut connection = project_io::helpers::conn()?; - let tx = connection - .transaction() - .map_err(|err| format!("pm audit tx: {}", err))?; - let seq = audit::bump_change_seq(&tx)?; + let seq = audit::bump_change_seq(tx)?; audit::append_audit_event( - &tx, + tx, &audit::AuditEventRow { operation: "work.create", entity_type: "work_item", @@ -320,12 +872,12 @@ fn audit_create( seq, payload: serde_json::json!({}), }, - )?; - tx.commit().map_err(|err| format!("pm audit commit: {}", err)) + ) } /// Canonical `work.create` for a project-scoped item. The single Rust /// construction site replacing per-caller `WorkItemFrontmatter` literals. +/// Guard, row write, audit and watermark commit in one transaction. pub fn create_project_work_item( project_slug: &str, short_id: &str, @@ -333,8 +885,30 @@ pub fn create_project_work_item( actor: Option<&WorkItemMutationActor>, ) -> Result { let frontmatter = build_frontmatter(short_id, request); - project_io::write_work_item(project_slug, short_id, &frontmatter, &request.body)?; - audit_create(short_id, Some(project_slug), None, actor)?; + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("work.create tx: {err}"))?; + let (project_id, org_id) = project_io::resolve_project_scope_in_tx(&tx, project_slug)?; + guard_new_work_item_id_in_tx(&tx, short_id)?; + project_io::write_work_item_in_tx( + &tx, + Some(project_id), + &org_id, + short_id, + &frontmatter, + &request.body, + true, + )?; + append_create_audit_in_tx(&tx, short_id, Some(project_slug), None, actor)?; + tx.commit().map_err(|err| format!("work.create commit: {err}"))?; + crate::projects::events::notify_work_item_schedule_changed(); + crate::sync::collab_bridge::record_work_item_write( + &org_id, + Some(project_slug), + &frontmatter.id, + false, + )?; project_io::read_work_item(project_slug, short_id) } @@ -407,6 +981,44 @@ pub fn note_project_work_item( ) } +pub fn note_standalone_work_item( + org_id: Option<&str>, + short_id: &str, + kind: &str, + body: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + let author = actor + .map(|a| a.name.clone()) + .unwrap_or_else(|| "agent".to_string()); + let note_body = if kind == "comment" { + body.to_string() + } else { + format!("[{}] {}", kind, body) + }; + project_io::update_standalone_work_item_atomic_serviced( + org_id, + actor, + project_io::AtomicServiceOptions { + operation: Some("work.note"), + reason: Some(kind.to_string()), + ..Default::default() + }, + short_id, + move |frontmatter, _item_body| { + let now = chrono::Utc::now().to_rfc3339(); + frontmatter.comments.push(crate::projects::types::CommentEntry { + id: format!("note-{}", chrono::Utc::now().timestamp_millis()), + author, + content: note_body, + created_at: now, + mentioned_user_ids: vec![], + }); + Ok(()) + }, + ) +} + const PORTABLE_RELATION_KINDS: &[&str] = &[ "depends_on", "relates_to", @@ -493,15 +1105,133 @@ pub fn list_work_item_relations(short_id: &str) -> Result Ok(rows) } +const ROOT_BOOTSTRAP_OPERATION: &str = "work.bootstrap"; +const ROOT_BOOTSTRAP_TITLE_MAX_CHARS: usize = 80; + +fn derive_root_bootstrap_title(content: &str) -> String { + let first_line = content.trim().lines().next().unwrap_or("").trim(); + let title: String = first_line.chars().take(ROOT_BOOTSTRAP_TITLE_MAX_CHARS).collect(); + if title.is_empty() { + "Untitled project".to_string() + } else { + title + } +} + +/// Idempotent root-WorkItem creation for a Project session (orgtrack/v1 +/// §7.2), shared by the native message-accept path and the CLI session +/// follow-up path. `raw_org_scope` may carry looser session-plane scopes +/// (`cloud:`, `personal-org`); anything without a local org row +/// falls back to the personal standalone scope. Returns the root's short +/// id; the caller owns linking it back onto its own session row. +pub fn bootstrap_root_standalone_item( + session_id: &str, + raw_org_scope: Option<&str>, + content: &str, +) -> Result { + let org_id = crate::projects::io::resolve_local_org_scope(raw_org_scope); + let session_ref = format!("org2:{session_id}"); + let actor = WorkItemMutationActor { + id: session_ref.clone(), + name: "ORG2 host".to_string(), + }; + let scope_id = org_id.clone().unwrap_or_else(|| "standalone".to_string()); + + // Canonical request deliberately excludes the message content: the + // key is "this session's root", and a retry after a create-then- + // link-failure may arrive with different content but must replay + // the SAME stored root instead of conflicting or duplicating. + let canonical = serde_json::json!({ "sessionRef": session_ref }); + let org_for_execute = org_id.clone(); + let title = derive_root_bootstrap_title(content); + let body = content.to_string(); + let actor_for_execute = actor.clone(); + let outcome = run_idempotent( + &session_ref, + ROOT_BOOTSTRAP_OPERATION, + &scope_id, + session_id, + &canonical, + move || { + let short_id = crate::projects::io::allocate_standalone_short_id( + org_for_execute.as_deref(), + )?; + let request = CreateWorkItemRequest { + title, + body, + created_by: Some(actor_for_execute.id.clone()), + ..Default::default() + }; + create_standalone_work_item( + org_for_execute.as_deref(), + &short_id, + &request, + Some(&actor_for_execute), + )?; + Ok(serde_json::json!({ "shortId": short_id })) + }, + )?; + + let response = match outcome { + IdempotencyOutcome::Fresh(value) | IdempotencyOutcome::Replayed(value) => value, + }; + response + .get("shortId") + .and_then(|value| value.as_str()) + .map(str::to_string) + .ok_or_else(|| format!("bootstrap response missing shortId: {response}")) +} + +/// True when `actor_id` appended a `work.note` audit row on the item at or +/// after `since_unix_ms`. The turn-end receipt fallback uses this to decide +/// whether the agent already delivered a Discussion receipt this turn. +pub fn work_item_noted_by_actor_since( + short_id: &str, + actor_id: &str, + since_unix_ms: i64, +) -> Result { + let connection = project_io::helpers::conn()?; + connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM pm_audit_events + WHERE entity_type = 'work_item' AND entity_id = ?1 + AND actor_id = ?2 AND operation = 'work.note' + AND occurred_at >= ?3)", + rusqlite::params![short_id, actor_id, since_unix_ms], + |row| row.get::<_, i64>(0), + ) + .map(|value| value != 0) + .map_err(|err| format!("pm audit read: {err}")) +} + /// Canonical `work.create` for an org-scoped standalone item. +/// Guard, row write, audit and watermark commit in one transaction. pub fn create_standalone_work_item( org_id: Option<&str>, short_id: &str, request: &CreateWorkItemRequest, actor: Option<&WorkItemMutationActor>, ) -> Result { + let resolved_org = org_id.unwrap_or("personal-org").to_string(); let frontmatter = build_frontmatter(short_id, request); - project_io::write_standalone_work_item(org_id, short_id, &frontmatter, &request.body)?; - audit_create(short_id, None, org_id, actor)?; + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|err| format!("work.create tx: {err}"))?; + guard_new_work_item_id_in_tx(&tx, short_id)?; + project_io::write_work_item_in_tx( + &tx, + None, + &resolved_org, + short_id, + &frontmatter, + &request.body, + true, + )?; + append_create_audit_in_tx(&tx, short_id, None, Some(&resolved_org), actor)?; + tx.commit().map_err(|err| format!("work.create commit: {err}"))?; + crate::projects::events::notify_work_item_schedule_changed(); + crate::sync::collab_bridge::record_work_item_write(&resolved_org, None, &frontmatter.id, false)?; project_io::read_standalone_work_item(org_id, short_id) } diff --git a/src-tauri/crates/project-management/src/work_service/tests.rs b/src-tauri/crates/project-management/src/work_service/tests.rs index 86e9bd5cf7..b88a548fd7 100644 --- a/src-tauri/crates/project-management/src/work_service/tests.rs +++ b/src-tauri/crates/project-management/src/work_service/tests.rs @@ -49,6 +49,7 @@ fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatt labels: vec![], milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, @@ -221,3 +222,231 @@ fn legacy_paths_flag_violations_without_blocking() { "violation must be visible in the audit stream: {payload}" ); } + +#[test] +fn create_refuses_to_overwrite_an_existing_id_in_any_scope() { + let _sandbox = test_env::sandbox(); + tests_support::seed_project("demo", "p1"); + + let first = CreateWorkItemRequest { + title: "First".to_string(), + ..Default::default() + }; + create_project_work_item("demo", "AAA-0001", &first, None).expect("first create"); + + let clobber = CreateWorkItemRequest { + title: "Second".to_string(), + ..Default::default() + }; + let same_scope = + create_project_work_item("demo", "AAA-0001", &clobber, None).expect_err("must refuse"); + assert!(same_scope.starts_with(error::ALREADY_EXISTS), "{same_scope}"); + + let cross_scope = create_standalone_work_item(None, "AAA-0001", &clobber, None) + .expect_err("cross-scope must refuse"); + assert!(cross_scope.starts_with(error::ALREADY_EXISTS), "{cross_scope}"); + + let survivor = read_work_item("demo", "AAA-0001").expect("survivor"); + assert_eq!(survivor.frontmatter.title, "First"); +} + +#[test] +fn claim_with_expected_revision_succeeds_in_one_transaction() { + let _sandbox = test_env::sandbox(); + tests_support::seed_project("demo", "p1"); + let request = CreateWorkItemRequest { + title: "Claimable".to_string(), + status: Some("open".to_string()), + ..Default::default() + }; + create_project_work_item("demo", "AAA-0001", &request, None).expect("create"); + let revision = read_project_work_item_revision("demo", "AAA-0001").expect("revision"); + + let claimed = claim_project_work_item( + "demo", + "AAA-0001", + "session-1", + Some("custom"), + crate::projects::types::WorkItemExecutionLockReason::ManualStart, + None, + Some(revision), + ) + .expect("claim with the observed revision must succeed"); + assert_eq!(claimed.frontmatter.status, "in_progress"); + assert_eq!( + claimed + .frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_deref()), + Some("session-1") + ); + + let reclaimed = claim_project_work_item( + "demo", + "AAA-0001", + "session-1", + Some("custom"), + crate::projects::types::WorkItemExecutionLockReason::FollowUp, + None, + None, + ) + .expect("the active session may resume its own claim"); + assert_eq!( + reclaimed + .frontmatter + .linked_sessions + .iter() + .filter(|linked| linked.session_id == "session-1") + .count(), + 1, + "resuming one durable session must not append a duplicate run" + ); + + let contended = claim_project_work_item( + "demo", + "AAA-0001", + "session-2", + Some("custom"), + crate::projects::types::WorkItemExecutionLockReason::ManualStart, + None, + None, + ) + .expect_err("second session must lose the claim"); + assert!( + contended.contains("active execution session"), + "{contended}" + ); + + let stale = claim_project_work_item( + "demo", + "AAA-0001", + "session-1", + Some("custom"), + crate::projects::types::WorkItemExecutionLockReason::ManualStart, + None, + Some(revision), + ) + .expect_err("stale revision must conflict"); + assert!(stale.starts_with(error::REVISION_CONFLICT), "{stale}"); +} + +#[test] +fn run_idempotent_concurrent_same_key_executes_exactly_once() { + let _sandbox = test_env::sandbox(); + tests_support::seed_project("demo", "p1"); + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let executions = Arc::new(AtomicUsize::new(0)); + let request = serde_json::json!({"op": "probe"}); + + let spawn_call = |executions: Arc, request: serde_json::Value| { + std::thread::spawn(move || { + run_idempotent("actor", "work.probe", "demo", "k1", &request, move || { + executions.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(300)); + Ok(serde_json::json!({"made": true})) + }) + }) + }; + + let a = spawn_call(executions.clone(), request.clone()); + let b = spawn_call(executions.clone(), request.clone()); + let ra = a.join().expect("thread a").expect("outcome a"); + let rb = b.join().expect("thread b").expect("outcome b"); + + assert_eq!(executions.load(Ordering::SeqCst), 1, "exactly one execution"); + let fresh = matches!(ra, IdempotencyOutcome::Fresh(_)) as u8 + + matches!(rb, IdempotencyOutcome::Fresh(_)) as u8; + assert_eq!(fresh, 1, "one fresh, one replayed"); + for outcome in [ra, rb] { + let value = match outcome { + IdempotencyOutcome::Fresh(v) | IdempotencyOutcome::Replayed(v) => v, + }; + assert_eq!(value["made"], true); + } +} + +#[test] +fn noted_by_actor_since_sees_only_matching_note_rows() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + // Production items carry id == short_id; audit rows key entity_id on + // the item id, which the receipt-fallback check queries by short id. + let fm = work_item_fixture("AAA-0002", "AAA-0002", "Receipt probe"); + write_work_item("demo", "AAA-0002", &fm, "body").expect("seed probe item"); + let actor = crate::projects::types::WorkItemMutationActor { + id: "agent:os".to_string(), + name: "os".to_string(), + }; + let before_ms = chrono::Utc::now().timestamp_millis() - 1; + + assert!(!work_item_noted_by_actor_since("AAA-0002", "agent:os", before_ms).expect("query")); + + // A non-note mutation by the same actor must not satisfy the check. + transition_project_work_item("demo", "AAA-0002", "in_progress", None, Some(&actor), None) + .expect("transition"); + assert!(!work_item_noted_by_actor_since("AAA-0002", "agent:os", before_ms).expect("query")); + + note_project_work_item("demo", "AAA-0002", "progress", "half way", Some(&actor)) + .expect("note"); + assert!(work_item_noted_by_actor_since("AAA-0002", "agent:os", before_ms).expect("query")); + + // Different actor and a window after the write both miss. + assert!( + !work_item_noted_by_actor_since("AAA-0002", "agent:sde", before_ms).expect("query") + ); + let after_ms = chrono::Utc::now().timestamp_millis() + 1; + assert!(!work_item_noted_by_actor_since("AAA-0002", "agent:os", after_ms).expect("query")); +} + +#[test] +fn root_bootstrap_is_idempotent_and_falls_back_to_personal_scope() { + let _sandbox = test_env::sandbox(); + + let first = bootstrap_root_standalone_item( + "cliagent-boot-1", + Some("cloud:no-such-org"), + "Ship the export flow\nwith full history", + ) + .expect("bootstrap"); + let replay = bootstrap_root_standalone_item( + "cliagent-boot-1", + Some("cloud:no-such-org"), + "different retry content", + ) + .expect("replayed bootstrap"); + assert_eq!(first, replay, "same session must replay the same root"); + + let item = crate::projects::io::read_standalone_work_item(None, &first).expect("read root"); + assert_eq!(item.frontmatter.title, "Ship the export flow"); + assert!(item.body.contains("with full history")); + + let other = bootstrap_root_standalone_item("cliagent-boot-2", None, "Second session root") + .expect("bootstrap 2"); + assert_ne!(first, other, "distinct sessions get distinct roots"); +} + +#[test] +fn standalone_note_audits_as_work_note() { + let _sandbox = test_env::sandbox(); + let fm = work_item_fixture("SA-0001", "SA-0001", "Standalone receipt probe"); + crate::projects::io::write_standalone_work_item(None, "SA-0001", &fm, "body") + .expect("seed standalone item"); + let actor = crate::projects::types::WorkItemMutationActor { + id: "agent:os".to_string(), + name: "os".to_string(), + }; + let before_ms = chrono::Utc::now().timestamp_millis() - 1; + + note_standalone_work_item(None, "SA-0001", "progress", "receipt", Some(&actor)) + .expect("note"); + + // Standalone notes must stamp the canonical `work.note` operation — + // the receipt-fallback dedup query depends on it (a `work.patch` + // label made the fallback double-post on standalone items). + let (operation, _, _) = last_audit_row(); + assert_eq!(operation, "work.note"); + assert!(work_item_noted_by_actor_since("SA-0001", "agent:os", before_ms).expect("query")); +} diff --git a/src-tauri/crates/types/src/cli_alias.rs b/src-tauri/crates/types/src/cli_alias.rs index ffacc1478f..6fa864c921 100644 --- a/src-tauri/crates/types/src/cli_alias.rs +++ b/src-tauri/crates/types/src/cli_alias.rs @@ -956,7 +956,7 @@ static CLI_ALIAS_MAP: LazyLock> = LazyLock::ne // NOTE: Built-in tool names (manage_workspace, query_lsp, manage_lsp, // setup_repo, manage_nodes, control_orgii, - // query_knowledge, manage_project, manage_work_item) are intentionally + // query_knowledge) are intentionally // NOT aliased here. The old entries collapsed their identity to // `UiCanonical::ToolCall` ("tool_call"), which masked the per-tool // `label_running/done/failed` and `chat_block` declared in diff --git a/src-tauri/crates/types/src/tool_names.rs b/src-tauri/crates/types/src/tool_names.rs index c2a806f214..5e58bd0c21 100644 --- a/src-tauri/crates/types/src/tool_names.rs +++ b/src-tauri/crates/types/src/tool_names.rs @@ -74,7 +74,10 @@ pub const MANAGE_SECRETS: &str = "manage_secrets"; /// and sets `0o600` on Unix. pub const WRITE_ENV_FILE: &str = "write_env_file"; -// ── Project ───────────────────────────────────────────────────────── +// ── Project (retired) ─────────────────────────────────────────────── +// The typed PM tools were retired in favour of the `org2-pm` CLI; the +// names remain for retired-alias denial and historical transcript +// rendering. pub const MANAGE_PROJECT: &str = "manage_project"; pub const MANAGE_WORK_ITEM: &str = "manage_work_item"; diff --git a/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs b/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs index 4dc612d7de..130eb21b51 100644 --- a/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs +++ b/src-tauri/src/agent_sessions/cli/agent_core_bridge.rs @@ -55,6 +55,7 @@ fn run( project_slug: params.project_slug, work_item_id: params.work_item_id, agent_role: params.agent_role, + product_mode: params.product_mode, }; let session = cli_agent_create(create_params).await?; diff --git a/src-tauri/src/agent_sessions/cli/commands/run.rs b/src-tauri/src/agent_sessions/cli/commands/run.rs index 8629f9b754..5c2a3908ed 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -308,6 +308,43 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result(short_id) + }) + .await + .map_err(|e| format!("Task error: {}", e))?; + match bootstrapped { + Ok(short_id) => { + tracing::info!( + session_id = %session_id, + short_id, + "[project-bootstrap] created and linked root work item (cli)" + ); + } + Err(err) => { + tracing::warn!(session_id = %session_id, error = %err, "[project-bootstrap] cli bootstrap failed"); + } + } + } + let target_account_id = account_id.as_deref().or(session.account_id.as_deref()); // If the user switched model/account, persist the change so run_session picks it up. diff --git a/src-tauri/src/agent_sessions/cli/mod.rs b/src-tauri/src/agent_sessions/cli/mod.rs index e5165e42a6..417d449c03 100644 --- a/src-tauri/src/agent_sessions/cli/mod.rs +++ b/src-tauri/src/agent_sessions/cli/mod.rs @@ -130,6 +130,10 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { .ok(); conn.execute("ALTER TABLE code_sessions ADD COLUMN agent_role TEXT", []) .ok(); + // Product-mode axis (orgtrack/v1 §5.2) for CLI sessions: parity with + // agent_sessions so external CLIs can enter Project mode. + conn.execute("ALTER TABLE code_sessions ADD COLUMN product_mode TEXT", []) + .ok(); // Schema update: add hosted_token column for proxy token release on session cleanup conn.execute("ALTER TABLE code_sessions ADD COLUMN hosted_token TEXT", []) diff --git a/src-tauri/src/agent_sessions/cli/persistence/mod.rs b/src-tauri/src/agent_sessions/cli/persistence/mod.rs index f2e38d216a..a3667ca426 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/mod.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/mod.rs @@ -48,6 +48,7 @@ mod resume_state_tests { project_slug: None, work_item_id: None, agent_role: None, + product_mode: None, }, ) .expect("create test CLI session"); diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs index e3b53ed784..19bff48e3d 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs @@ -104,8 +104,8 @@ pub fn create_session( proxy_session_id, background, key_source, additional_directories, parent_session_id, org_member_id, org_id, project_id, project_name, project_slug, work_item_id, agent_role, created_at, updated_at, - transcript_source) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29)", + transcript_source, product_mode) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30)", params![ session_id, name, SessionStatus::Pending.as_ref(), flow, runner, params.cli_agent_type, params.model, params.tier, params.account_id, @@ -114,6 +114,7 @@ pub fn create_session( additional_dirs_json, params.parent_session_id, params.org_member_id, org_id, params.project_id, params.project_name, params.project_slug, params.work_item_id, params.agent_role, ts, ts, transcript_source, + params.product_mode, ], )?; @@ -139,7 +140,7 @@ const SESSION_COLUMNS: &str = COALESCE(cs.org_id, 'personal-org'), cs.project_id, cs.project_name, cs.project_slug, cs.work_item_id, cs.agent_role, cs.created_at, cs.updated_at, - COALESCE(cs.transcript_source, 'chunks')"; + COALESCE(cs.transcript_source, 'chunks'), cs.product_mode"; /// Get a session by ID. pub fn get_session(session_id: &str) -> SqliteResult> { @@ -777,6 +778,37 @@ pub fn update_model_and_account( /// Update the per-session execution mode on a CLI session row. /// Mirrors `agent_core::session::persistence::update_agent_exec_mode`. /// Does not bump `updated_at`; this is composer control state, not activity. +/// Link the Project root Work Item created by the bootstrap flow. +/// Guarded on `work_item_id IS NULL` so a concurrent duplicate submit +/// can never repoint an already-linked session (same contract as the +/// agent-side `link_bootstrap_work_item`). +pub fn link_bootstrap_work_item(session_id: &str, work_item_id: &str) -> SqliteResult { + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE code_sessions + SET work_item_id = ?2, updated_at = ?3 + WHERE session_id = ?1 AND work_item_id IS NULL", + params![session_id, work_item_id, now_iso()], + )?; + if affected > 0 { + sync_orgtrack_mirror(session_id); + } + Ok(affected > 0) +} + +/// Set the product-mode axis (validated upstream by `session_patch`). +pub fn update_product_mode(session_id: &str, product_mode: &str) -> SqliteResult { + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE code_sessions SET product_mode = ?2, updated_at = ?3 WHERE session_id = ?1", + params![session_id, product_mode, now_iso()], + )?; + if affected > 0 { + sync_orgtrack_mirror(session_id); + } + Ok(affected > 0) +} + pub fn update_agent_exec_mode(session_id: &str, mode: &str) -> SqliteResult { let parsed = AgentExecMode::parse(mode).ok_or_else(|| { rusqlite::Error::ToSqlConversionFailure( @@ -1039,5 +1071,6 @@ fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result { created_at: row.get(39)?, updated_at: row.get(40)?, transcript_source: row.get(41)?, + product_mode: row.get(42)?, }) } diff --git a/src-tauri/src/agent_sessions/cli/persistence/types.rs b/src-tauri/src/agent_sessions/cli/persistence/types.rs index edd87831b0..352fac16e4 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/types.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/types.rs @@ -80,6 +80,9 @@ pub struct CodeSession { /// `code_session_chunks`) or `native` (the CLI's own store via the /// imported-history loaders). Frozen at creation time. pub transcript_source: String, + /// Product-mode axis (orgtrack/v1 §5.2); `Some("project")` gates the + /// WorkItem/Routine mutation surface, mirroring `agent_sessions`. + pub product_mode: Option, pub created_at: String, pub updated_at: String, } @@ -139,4 +142,7 @@ pub struct CreateCodeSessionParams { pub project_slug: Option, pub work_item_id: Option, pub agent_role: Option, + /// Product-mode axis (orgtrack/v1 §5.2); `Some("project")` gates the + /// WorkItem/Routine mutation surface, mirroring `agent_sessions`. + pub product_mode: Option, } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs index 5605e066f1..68d849aa89 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs @@ -632,6 +632,75 @@ pub(super) fn configure_agent_profile( Ok(()) } +/// Inject the orgtrack identity for agent-plane CLI calls (design M6), +/// mirroring the native run_shell injection: `org2-pm` resolves +/// actor/session/scope/mode from these env vars instead of trusting +/// model-typed flags, the fail-closed workspace marker binds the identity +/// to this session, and the host binary directory rides the front of PATH +/// so the bundled `org2-pm` always matches the app version. External CLIs +/// (Claude Code, Codex, …) go through the same org2-pm surface as native +/// agents because of this injection. +pub(super) fn inject_orgtrack_environment( + session: &CodeSession, + session_id: &str, + working_dir: &str, + env_vars: &mut HashMap, +) { + let agent = session.cli_agent_type.as_deref().unwrap_or("cli"); + // The persisted product-mode axis wins; sessions from before the + // column (or launched by flows that never set it) fall back to the + // linkage-derived mode so work-item runs keep their mutation surface. + let derived_mode = (session.work_item_id.is_some() || session.project_slug.is_some()) + .then_some("project"); + let product_mode = session.product_mode.as_deref().or(derived_mode); + + env_vars.insert( + "ORGII_SESSION_REF".to_string(), + format!("org2:{session_id}"), + ); + env_vars.insert("ORGII_ACTOR".to_string(), format!("agent:{agent}")); + if let Some(mode) = product_mode { + env_vars.insert("ORGII_MODE".to_string(), mode.to_string()); + } + if let Some(slug) = session.project_slug.as_deref() { + env_vars.insert("ORGII_SCOPE".to_string(), slug.to_string()); + } + let org_scope = project_management::projects::io::resolve_local_org_scope(Some( + session.org_id.as_str(), + )); + if let Some(org) = org_scope.as_deref() { + env_vars.insert("ORGII_ORG".to_string(), org.to_string()); + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let base_path = env_vars + .get("PATH") + .map(std::ffi::OsString::from) + .or_else(|| std::env::var_os("PATH")); + let mut paths = vec![dir.to_path_buf()]; + if let Some(existing_path) = base_path { + paths.extend(std::env::split_paths(&existing_path)); + } + if let Ok(joined_path) = std::env::join_paths(paths) { + env_vars.insert( + "PATH".to_string(), + joined_path.to_string_lossy().to_string(), + ); + } + } + } + + agent_core::session::launch::write_agent_session_marker( + working_dir, + session_id, + Some(agent), + product_mode, + session.project_slug.as_deref(), + Some(session.org_id.as_str()), + ); +} + /// Forward the host's system proxy env vars to the child and ensure localhost /// bypasses the proxy. pub(super) fn apply_system_proxy_passthrough(env_vars: &mut HashMap) { diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session.rs b/src-tauri/src/agent_sessions/cli/session_runner/session.rs index c3e6a2216e..d0207eae95 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session.rs @@ -522,6 +522,13 @@ pub async fn run_session( super::env_setup::apply_system_proxy_passthrough(&mut env_vars); + super::env_setup::inject_orgtrack_environment( + &session, + &session_id, + working_dir, + &mut env_vars, + ); + sanitize_cli_oauth_env_for_child(&agent, &mut env_vars); // Log environment variables for debugging (redact token values) diff --git a/src-tauri/src/agent_sessions/session_directory/conversion.rs b/src-tauri/src/agent_sessions/session_directory/conversion.rs index 82d5ebd4e7..b27caee45e 100644 --- a/src-tauri/src/agent_sessions/session_directory/conversion.rs +++ b/src-tauri/src/agent_sessions/session_directory/conversion.rs @@ -164,9 +164,7 @@ pub fn cli_session_to_aggregate_record( agent_icon_id: None, agent_display_name: None, agent_exec_mode: session.agent_exec_mode, - // CLI sessions carry no product mode yet (code_sessions has no - // column); None = build, the safe non-mutating default. - product_mode: None, + product_mode: session.product_mode, draft_text: session.draft_text, reply_target_event_id: session.reply_target_event_id, pinned: session.pinned, diff --git a/src-tauri/src/agent_sessions/session_directory/patch.rs b/src-tauri/src/agent_sessions/session_directory/patch.rs index 3609e77ead..3d89b1e6ac 100644 --- a/src-tauri/src/agent_sessions/session_directory/patch.rs +++ b/src-tauri/src/agent_sessions/session_directory/patch.rs @@ -317,9 +317,13 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), session_persistence::update_product_mode(session_id, product_mode) .map_err(|err| format!("session_patch update product_mode (agent): {err}"))?; } - SessionLocation::Cli | SessionLocation::Imported => { + SessionLocation::Cli => { + cli_persistence::update_product_mode(session_id, product_mode) + .map_err(|err| format!("session_patch update product_mode (cli): {err}"))?; + } + SessionLocation::Imported => { return Err( - "session_patch: only agent sessions carry a product_mode".to_string() + "session_patch: imported sessions do not carry a product_mode".to_string() ); } } diff --git a/src-tauri/src/api/agent/test/cli.rs b/src-tauri/src/api/agent/test/cli.rs index 9fc15e91c1..6921cc9e9c 100644 --- a/src-tauri/src/api/agent/test/cli.rs +++ b/src-tauri/src/api/agent/test/cli.rs @@ -180,6 +180,7 @@ pub async fn test_cursor_cli_runtime( project_slug: None, work_item_id: None, agent_role: None, + product_mode: None, }; let created = match cli_agent_create(create_params).await { @@ -291,6 +292,7 @@ pub async fn test_cursor_cli_account_switch( project_slug: None, work_item_id: None, agent_role: None, + product_mode: None, }; let created = match cli_agent_create(create_params).await { @@ -401,6 +403,7 @@ pub async fn test_claude_code_cli_account_switch( project_slug: None, work_item_id: None, agent_role: None, + product_mode: None, }; let created = match cli_agent_create(create_params).await { @@ -550,6 +553,7 @@ pub async fn test_codex_cli_account_switch( project_slug: None, work_item_id: None, agent_role: None, + product_mode: None, }; let created = match cli_agent_create(create_params).await { @@ -714,6 +718,7 @@ pub async fn test_cli_resume_lock_isolation() -> Json { project_slug: None, work_item_id: None, agent_role: None, + product_mode: None, }; let stale_session = match cli_agent_create(create_params("E2E stale resume lock probe")).await { diff --git a/src-tauri/src/api/agent/test/core.rs b/src-tauri/src/api/agent/test/core.rs index 2013f114e2..d5980f75c3 100644 --- a/src-tauri/src/api/agent/test/core.rs +++ b/src-tauri/src/api/agent/test/core.rs @@ -1120,6 +1120,7 @@ async fn debug_work_item_runtime_launch_impl( labels: Vec::new(), milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: Some("e2e".to_string()), diff --git a/src-tauri/src/api/agent/test/sync.rs b/src-tauri/src/api/agent/test/sync.rs index 2c2be94a3c..d0ba0ddd67 100644 --- a/src-tauri/src/api/agent/test/sync.rs +++ b/src-tauri/src/api/agent/test/sync.rs @@ -833,6 +833,7 @@ pub async fn test_sync_conflict_seed_work_item( labels: Vec::new(), milestone: None, parent: None, + stage: None, start_date: None, target_date: None, created_by: None, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 544b863989..8f8737623c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -832,6 +832,19 @@ pub fn run() { project_management::projects::events::DATA_CHANGED_EVENT, serde_json::json!({ "source": "pm-watermark" }), ); + // Fold CLI-committed status transitions into the + // child-done wake pipeline; in-process writes were + // already delivered by the terminal notifier and + // dedupe at the barrier level. + let bridge_handle = watermark_handle.clone(); + let after_seq = last_seq; + let _ = tokio::task::spawn_blocking(move || { + agent_core::coordination::child_done_wake::process_audit_window( + &bridge_handle, + after_seq, + ) + }) + .await; } if seq >= 0 { last_seq = seq; @@ -859,6 +872,11 @@ pub fn run() { }, )); + // Child-done parent wake: when the last open + // sub-item settles, note the parent's Discussion and resume its + // linked session with the barrier summary. + agent_core::coordination::child_done_wake::register(app.handle().clone()); + // Restore previously-enabled channels (e.g. feishu was toggled on last run) let app_handle_for_restore = app.handle().clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index efdc9c27b3..e8927ba299 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -42,7 +42,7 @@ } }, "bundle": { - "externalBin": [], + "externalBin": ["binaries/org2-pm"], "resources": { "resources/drag-icons/": "resources/drag-icons/" }, diff --git a/src/api/http/project/client.ts b/src/api/http/project/client.ts index 9cadddbaa8..cef81cce36 100644 --- a/src/api/http/project/client.ts +++ b/src/api/http/project/client.ts @@ -489,6 +489,7 @@ export interface WorkItemCreateRequest { labels?: string[]; milestone?: string; parent?: string; + stage?: number; startDate?: string; targetDate?: string; createdBy?: string; diff --git a/src/api/http/project/types/routines.ts b/src/api/http/project/types/routines.ts index bef3b0ad01..bbb15f8fc6 100644 --- a/src/api/http/project/types/routines.ts +++ b/src/api/http/project/types/routines.ts @@ -10,7 +10,7 @@ export interface WorkItemSchedule { export type RoutineTrigger = | { kind: "one_time"; at: string } - | { kind: "cron"; cron: string }; + | { kind: "cron"; cron: string; timezone: string }; export const ROUTINE_FIRE_STATUS = { PENDING: "pending", @@ -120,6 +120,12 @@ export interface RoutineDefinition { lastEvaluatedAt?: string; /** Next computed fire time (ISO 8601), backend-managed, display only. */ nextFireAt?: string; + /** Latest durable occurrence summary, projected by the backend. */ + lastFireAt?: string; + lastFireStatus?: RoutineFireStatus; + lastFireError?: string; + lastFireSessionId?: string; + lastFireWorkItemId?: string; createdAt: string; updatedAt: string; } diff --git a/src/api/http/project/types/workItems.ts b/src/api/http/project/types/workItems.ts index bad46892c9..c9f53326c4 100644 --- a/src/api/http/project/types/workItems.ts +++ b/src/api/http/project/types/workItems.ts @@ -189,6 +189,7 @@ export interface WorkItemFrontmatter { labels: string[]; milestone?: string; parent?: string; + stage?: number; start_date?: string; target_date?: string; created_by?: string; @@ -236,6 +237,7 @@ export interface WorkItemPartialUpdate { assigneeType?: string | null; labels?: string[]; milestone?: string | null; + stage?: number | null; startDate?: string | null; targetDate?: string | null; todos?: TodoEntry[]; diff --git a/src/api/realtime/codeEditorWebSocket.ts b/src/api/realtime/codeEditorWebSocket.ts index 4ad251b8a1..49eff4f572 100644 --- a/src/api/realtime/codeEditorWebSocket.ts +++ b/src/api/realtime/codeEditorWebSocket.ts @@ -187,8 +187,10 @@ export function getCodeEditorWebSocket(): CodeEditorWebSocketClient | null { return wsClientInstance; } -// Initialize on module load -if (typeof window !== "undefined") { +// Initialize on module load in the app. Unit tests import broad UI graphs in +// jsdom; opening a real socket there leaks asynchronous undici events across +// test files and can fail after the owning test has already completed. +if (typeof window !== "undefined" && process.env.NODE_ENV !== "test") { // Auto-connect when app loads wsClientInstance = new CodeEditorWebSocketClient(); wsClientInstance.connect().catch((err) => { diff --git a/src/api/tauri/agent/session.ts b/src/api/tauri/agent/session.ts index 843828ff25..5cc9606b39 100644 --- a/src/api/tauri/agent/session.ts +++ b/src/api/tauri/agent/session.ts @@ -509,6 +509,7 @@ export interface SessionLaunchResult { projectSlug?: string | null; workItemId?: string | null; agentRole?: string | null; + productMode?: string | null; worktreePath?: string | null; /** Actual branch checked out in the worktree (for example `agent/`). */ worktreeBranch?: string | null; diff --git a/src/api/tauri/rpc/schemas/agentSession.ts b/src/api/tauri/rpc/schemas/agentSession.ts index 6d39e25a54..f80149e382 100644 --- a/src/api/tauri/rpc/schemas/agentSession.ts +++ b/src/api/tauri/rpc/schemas/agentSession.ts @@ -432,6 +432,7 @@ export const SessionLaunchResultSchema = z projectSlug: z.string().nullable().optional(), workItemId: z.string().nullable().optional(), agentRole: z.string().nullable().optional(), + productMode: z.string().nullable().optional(), worktreePath: z.string().nullable().optional(), worktreeBranch: z.string().nullable().optional(), baseRef: z.string().nullable().optional(), diff --git a/src/app/root/E2EBootstrap.tsx b/src/app/root/E2EBootstrap.tsx index d6563a45bd..778e21054b 100644 --- a/src/app/root/E2EBootstrap.tsx +++ b/src/app/root/E2EBootstrap.tsx @@ -171,6 +171,7 @@ export const E2EBootstrap: FC = () => { writeWorkItem, allocateStandaloneWorkItemId, readStandaloneWorkItems, + readProjectOrgs, readStandaloneWorkItem, writeStandaloneWorkItem, deleteWorkItem, @@ -399,6 +400,7 @@ export const E2EBootstrap: FC = () => { writeWorkItem, allocateStandaloneWorkItemId, readStandaloneWorkItems, + readProjectOrgs, readStandaloneWorkItem, writeStandaloneWorkItem, deleteWorkItem, diff --git a/src/app/root/e2e/helpers/navigation.ts b/src/app/root/e2e/helpers/navigation.ts index ff4a83156a..f3bba03a42 100644 --- a/src/app/root/e2e/helpers/navigation.ts +++ b/src/app/root/e2e/helpers/navigation.ts @@ -6,6 +6,7 @@ import { AppType } from "@src/engines/Simulator/types/appTypes"; import { agentOrgsActiveTabAtom } from "@src/modules/MainApp/AgentOrgs/store/agentOrgsActiveTabAtom"; import { allAgentDefsAtom } from "@src/modules/MainApp/AgentOrgs/store/builtInAgentsAtom"; import { router } from "@src/router"; +import { openWorkItemInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { reposAtom, selectedRepoIdAtom } from "@src/store/repo/atoms"; import { CHAT_PANEL_CONTENT_MODE, @@ -239,13 +240,17 @@ export function createNavigationHelpers(store: E2EStore) { store.set(chatPanelMaximizedAtom, true); store.set(chatWidthAtom, 560); store.set(chatPanelContentModeAtom, CHAT_PANEL_CONTENT_MODE.NON_SESSION); - store.set(chatPanelSelectedWorkItemAtom, { + const selection = { workItem: enrichedWorkItemToUI(workItem), projectId: project?.slug ?? projectSlug, projectName: project?.meta?.name ?? projectSlug, projectSlug, shortId, - }); + }; + store.set(chatPanelSelectedWorkItemAtom, selection); + // The active tab owns the visible surface since the launchpad rework; + // a bare selection write no longer switches away from the start page. + store.set(openWorkItemInChatPanelTabAtom, selection); await new Promise((resolve) => window.setTimeout(resolve, 100)); return { ok: true }; } catch (err) { diff --git a/src/app/root/e2e/helpers/projects.ts b/src/app/root/e2e/helpers/projects.ts index 74b9463266..6472530a39 100644 --- a/src/app/root/e2e/helpers/projects.ts +++ b/src/app/root/e2e/helpers/projects.ts @@ -148,18 +148,28 @@ export function createProjectHelpers(store: E2EStore) { } }; - const readStandaloneWorkItems = async (): Promise< - Result<{ items: Json[] }> - > => { + const readStandaloneWorkItems = async ( + orgId?: string + ): Promise> => { try { - const items = - (await projectApi.readStandaloneWorkItems()) as unknown as Json[]; + const items = (await projectApi.readStandaloneWorkItems( + orgId ? { orgId } : undefined + )) as unknown as Json[]; return { ok: true, items }; } catch (err) { return asError(err); } }; + const readProjectOrgs = async (): Promise> => { + try { + const orgs = (await projectApi.readOrgs()) as unknown as Json[]; + return { ok: true, orgs }; + } catch (err) { + return asError(err); + } + }; + const readStandaloneWorkItem = async ( shortId: string ): Promise> => { @@ -309,6 +319,7 @@ export function createProjectHelpers(store: E2EStore) { writeWorkItem, allocateStandaloneWorkItemId, readStandaloneWorkItems, + readProjectOrgs, readStandaloneWorkItem, writeStandaloneWorkItem, deleteWorkItem, diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index 7a1d43dacf..f13851c6b9 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -258,7 +258,10 @@ export interface E2EHelpers { body: string ) => Promise<{ ok: true } | Err>; allocateStandaloneWorkItemId: () => Promise>; - readStandaloneWorkItems: () => Promise>; + readStandaloneWorkItems: ( + orgId?: string + ) => Promise>; + readProjectOrgs: () => Promise>; readStandaloneWorkItem: (shortId: string) => Promise>; writeStandaloneWorkItem: ( shortId: string, diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index c92906763f..7af62a0947 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -221,6 +221,7 @@ export function TableBody({ setHoverSuppressedRowKey(rowKey); toggleRowExpand(rowKey); }} + aria-label={isExpanded ? "Collapse row" : "Expand row"} aria-expanded={isExpanded} > {isExpanded ? ( diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index e3a007163e..5963b6f9b7 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -424,9 +424,20 @@ const Tooltip = forwardRef( const enterTimerRef = useRef(undefined); const leaveTimerRef = useRef(undefined); - // Callback ref for trigger element + // Stable composed ref. Recreating the callback ref every time the child + // element's identity changes makes React detach (call with `null`) and + // reattach (call with the node) on every parent render, so + // `setTriggerElement` fires null→node churn each render. A parent that + // re-renders in a loop then escalates into React error #185 + // ("maximum update depth"). Holding the composed ref stable and reading + // the latest child ref from a ref means React only invokes it when the + // DOM node actually changes — mount and unmount, not every render. + const childRefHolder = useRef | undefined>( + undefined + ); const triggerRef = useCallback((node: HTMLElement | null) => { - setTriggerElement(node); + setTriggerElement((prev) => (prev === node ? prev : node)); + applyRef(childRefHolder.current, node); }, []); const isControlled = popupVisible !== undefined; @@ -644,15 +655,16 @@ const Tooltip = forwardRef( // used for dropdown positioning). Without this, wrapping an element // in Tooltip would silently break refs like useDropdownEngine's // triggerRef, causing click-to-open dropdowns to never position. - const childRef = originalProps.ref; - const composedRef = (node: HTMLElement | null) => { - triggerRef(node); - applyRef(childRef, node); - }; + // The value is read through `childRefHolder` inside the STABLE + // `triggerRef`, so a changing child ref never re-thrashes the DOM ref. + // Writing the holder here is idempotent and only read post-commit from + // the ref callback — never during render — so it cannot cause tearing. + // eslint-disable-next-line react-hooks/refs + childRefHolder.current = originalProps.ref; // eslint-disable-next-line react-hooks/refs return cloneElement(children as React.ReactElement, { - ref: composedRef, + ref: triggerRef, onMouseEnter: (e: React.MouseEvent) => { handleMouseEnter(); originalProps.onMouseEnter?.(e); diff --git a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/TerminalActivityGroup.test.ts b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/TerminalActivityGroup.test.ts index 78b78d8211..01102bc90b 100644 --- a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/TerminalActivityGroup.test.ts +++ b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/TerminalActivityGroup.test.ts @@ -1,8 +1,23 @@ -import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; import { makeSessionEvent } from "@src/engines/SessionCore/rendering/props/__tests__/fixtures"; -import { buildGroupSummary } from "."; +import TerminalActivityGroup, { buildGroupSummary } from "."; + +vi.mock("@src/engines/ChatPanel/hooks/useChatEventReplay", () => ({ + useChatEventReplay: () => ({ + replayEventById: vi.fn(), + canReplay: false, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); const translateSummary = ( key: string, @@ -55,4 +70,62 @@ describe("buildGroupSummary", () => { "2 commands, 4 MCP calls" ); }); + + it("keeps a created Work Item card visible when the command stack is collapsed", () => { + const stdout = JSON.stringify({ + apiVersion: "orgtrack/v1", + ok: true, + data: { + body: "", + filename: "WI-0100", + frontmatter: { + id: "WI-0100", + short_id: "WI-0100", + title: "Visible create result", + status: "backlog", + priority: "none", + labels: [], + todos: [], + starred: false, + created_at: "2026-08-09T00:00:00Z", + updated_at: "2026-08-09T00:00:00Z", + }, + }, + }); + const baseEvent = makeSessionEvent({ + action_type: "tool_call", + function: "run_shell", + uiCanonical: "run_shell", + args: { + command: + "org2-pm work create --standalone --title 'Visible create result'", + }, + result: { shellReplayBacked: true }, + shellExitCode: 0, + }); + const event = { + ...baseEvent, + shellReplay: { + ref: { + sessionId: baseEvent.sessionId, + callId: "call-create-work-item", + formatVersion: 1, + }, + bookmark: { visibleThroughSequence: 1, visibleBytes: stdout.length }, + terminalPreview: stdout, + status: "complete" as const, + }, + }; + + const markup = renderToStaticMarkup( + createElement(TerminalActivityGroup, { + events: [event], + closedByBoundary: true, + }) + ); + + expect(markup).toContain('data-testid="created-work-item-card"'); + expect(markup).toContain('data-work-item-id="WI-0100"'); + expect(markup).toContain("Visible create result"); + }); }); diff --git a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx index 7ba280da09..a23bc6caa7 100644 --- a/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx +++ b/src/engines/ChatPanel/ChatItems/TerminalActivityGroup/index.tsx @@ -5,12 +5,15 @@ * the same collapsible stack used by exploration summaries. Every item still * renders through the registry, preserving its specialized behavior. */ +import { useAtomValue } from "jotai"; import React, { Suspense, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { getToolIcon } from "@src/config/toolIcons"; import { isMcpToolEvent } from "@src/engines/ChatPanel/ChatHistory/chatItemPipeline/classifiers"; import ToolUsageBadge from "@src/engines/ChatPanel/blocks/ToolCallBlock/ToolUsageBadge"; +import OrgtrackEnvelopeCard from "@src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard"; +import { parseOrgtrackEnvelope } from "@src/engines/ChatPanel/blocks/ToolCallBlock/helpers"; import { ChatLoadingBlock, StackedBlock, @@ -22,6 +25,7 @@ import { } from "@src/engines/SessionCore/core/types"; import { getChatLazyComponent } from "@src/engines/SessionCore/rendering/registry/events"; import { getRegistryEventType } from "@src/lib/activityData/activityNormalizers"; +import { sessionByIdAtom } from "@src/store/session/sessionAtom"; interface TerminalActivityGroupProps { events: SessionEvent[]; @@ -33,6 +37,29 @@ interface TerminalEventItem { isLastItem: boolean; } +function parseTerminalOrgtrackEnvelope( + event: SessionEvent, + context: { + projectSlug?: string; + projectName?: string; + projectId?: string; + orgId?: string; + } +) { + const extractedOutput = + event.extracted?.kind === "shell" ? event.extracted.output : undefined; + const durableOutput = + event.shellReplay?.terminalPreview || extractedOutput || undefined; + const result = durableOutput + ? { + ...(event.result ?? {}), + stdout: durableOutput, + exit_code: event.shellExitCode, + } + : (event.result ?? {}); + return parseOrgtrackEnvelope(event.args ?? {}, result, context); +} + export function buildGroupSummary( events: readonly SessionEvent[], t: (key: string, opts?: Record) => string @@ -157,6 +184,7 @@ const TerminalActivityGroup: React.FC = ({ closedByBoundary = true, }) => { const { t } = useTranslation("sessions"); + const session = useAtomValue(sessionByIdAtom(events[0]?.sessionId ?? "")); const items = useMemo( () => events.map((event, index) => ({ @@ -165,6 +193,27 @@ const TerminalActivityGroup: React.FC = ({ })), [events] ); + const createdWorkItems = useMemo( + () => + events.flatMap((event) => { + const card = parseTerminalOrgtrackEnvelope(event, { + projectSlug: session?.projectSlug, + projectName: session?.projectName, + projectId: session?.projectId, + orgId: session?.orgId, + }); + return card?.ok && card.operationId === "work.create" && card.workItem + ? [card] + : []; + }), + [ + events, + session?.orgId, + session?.projectId, + session?.projectName, + session?.projectSlug, + ] + ); if (items.length === 0) return null; @@ -173,31 +222,41 @@ const TerminalActivityGroup: React.FC = ({ const groupSummary = buildGroupSummary(events, t); return ( -
- : undefined + <> +
-
+ > + + ) : undefined + } + renderItem={renderTerminalEvent} + /> +
+ {createdWorkItems.map((card, index) => ( + + ))} + ); }; diff --git a/src/engines/ChatPanel/ChatPanelEmptyContent.tsx b/src/engines/ChatPanel/ChatPanelEmptyContent.tsx index 1f6a2b4bb1..3cfe5e7364 100644 --- a/src/engines/ChatPanel/ChatPanelEmptyContent.tsx +++ b/src/engines/ChatPanel/ChatPanelEmptyContent.tsx @@ -290,10 +290,13 @@ export function ChatPanelEmptyContent({ projectDraftOrgId ?? createProjectContext?.orgId ?? STORY_PERSONAL_ORG_FILTER_ID, - // The whole flow is "create a project via manage_project" — + // The whole flow is "create a project via org2-pm" — // without a workItemId the resolver would default to build - // and the PM tools would be policy-denied (§5.2 deny-delta). + // and org2-pm would refuse project.mutate (§5.2). The + // exec-mode pin keeps run_shell available: read-only + // modes deny the shell the CLI rides on. productMode: PRODUCT_MODE_PROJECT, + agentExecMode: "build", }} /> ) diff --git a/src/engines/ChatPanel/InputArea/components/InputAreaPortals.tsx b/src/engines/ChatPanel/InputArea/components/InputAreaPortals.tsx index c3f7131a99..80fd052445 100644 --- a/src/engines/ChatPanel/InputArea/components/InputAreaPortals.tsx +++ b/src/engines/ChatPanel/InputArea/components/InputAreaPortals.tsx @@ -1,6 +1,6 @@ import React from "react"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import type { CustomMentionOption } from "@src/engines/ChatPanel/hooks/useInputArea/types"; import type { AddressCommentsFlyoutData } from "@src/engines/ChatPanel/hooks/useInputArea/useSlashCommand"; import type { MenuItemId } from "@src/scaffold/ContextMenu/config"; @@ -27,11 +27,12 @@ interface InputAreaPortalsProps { showSlashMenu: boolean; filteredSlashItems: SlashItem[]; slashLoading: boolean; - currentMode: AgentExecMode; + currentMode: ComposerModeEntry["id"]; + includeProjectMode?: boolean; slashQuery: string; onSlashCommandClose: () => void; onSlashSelect: (item: SlashItem) => void; - onModeSelect: (mode: AgentExecMode) => void; + onModeSelect: (mode: ComposerModeEntry["id"]) => void; slashCommandKeyboardHandlerRef: React.MutableRefObject< ((event: KeyboardEvent) => boolean) | null >; @@ -73,6 +74,7 @@ export const InputAreaPortals: React.FC = ({ filteredSlashItems, slashLoading, currentMode, + includeProjectMode, slashQuery, onSlashCommandClose, onSlashSelect, @@ -122,6 +124,7 @@ export const InputAreaPortals: React.FC = ({ items={filteredSlashItems} loading={slashLoading} currentMode={currentMode} + includeProjectMode={includeProjectMode} searchQuery={slashQuery} onClose={onSlashCommandClose} onSelect={onSlashSelect} @@ -141,6 +144,7 @@ export const InputAreaPortals: React.FC = ({ items={filteredSlashItems} loading={slashLoading} currentMode={currentMode} + includeProjectMode={includeProjectMode} searchQuery={plusSlashQuery} onClose={onPlusSlashClose} onSelect={(item) => { diff --git a/src/engines/ChatPanel/InputArea/components/ModePill.tsx b/src/engines/ChatPanel/InputArea/components/ModePill.tsx index ae87b88de4..d44a64bd64 100644 --- a/src/engines/ChatPanel/InputArea/components/ModePill.tsx +++ b/src/engines/ChatPanel/InputArea/components/ModePill.tsx @@ -47,6 +47,7 @@ import { useSessionProductModeField, } from "@src/hooks/session/useSessionPatch"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; +import { creatorDefaultProductModeAtom } from "@src/store/session/creatorDefaultProductModeAtom"; import { isAgentSession, isCliSession, @@ -87,6 +88,8 @@ const ModePill: React.FC = memo( // below based on the current usage mode. const creatorDefault = useAtomValue(creatorDefaultExecModeAtom); const setCreatorDefault = useSetAtom(creatorDefaultExecModeAtom); + const creatorProductDefault = useAtomValue(creatorDefaultProductModeAtom); + const setCreatorProductDefault = useSetAtom(creatorDefaultProductModeAtom); const { agentExecMode: sessionMode, setMode: setSessionMode } = useSessionExecModeField(sessionId ?? ""); const { productMode, setProductMode } = useSessionProductModeField( @@ -103,14 +106,28 @@ const ModePill: React.FC = memo( // Product-mode axis (orgtrack/v1 §5.2): when the session is in // Project mode the pill displays Project regardless of the derived - // exec mode. Only agent sessions carry a product mode, and the - // creator/controlled variants stay exec-only until the Project - // bootstrap flow lands there. + // exec mode. Agent and CLI sessions both carry the product-mode axis + // (code_sessions grew a product_mode column for external-CLI Project + // parity); imported rows stay exec-only — the Rust side still + // hard-rejects product-mode patches there. The uncontrolled creator + // offers Project too: its selection persists in the creator default + // atoms and launch stamps `productMode` on the new session. + const isCreatorMode = !isControlled && !isInSessionMode; const isProjectSession = - isInSessionMode && productMode === PRODUCT_MODE_PROJECT; + (isInSessionMode && productMode === PRODUCT_MODE_PROJECT) || + (isCreatorMode && creatorProductDefault === PRODUCT_MODE_PROJECT); + const carriesProductMode = + isInSessionMode && + Boolean( + sessionId && (isAgentSession(sessionId) || isCliSession(sessionId)) + ); const pickerModes: ComposerModeEntry[] = isInSessionMode - ? COMPOSER_MODES - : AGENT_EXEC_MODES; + ? carriesProductMode + ? COMPOSER_MODES + : AGENT_EXEC_MODES + : isCreatorMode + ? COMPOSER_MODES + : AGENT_EXEC_MODES; const currentOption = isProjectSession ? (COMPOSER_MODES.find((opt) => opt.id === PRODUCT_MODE_PROJECT) ?? @@ -153,11 +170,18 @@ const ModePill: React.FC = memo( // otherwise). Both patches are fire-and-forget — the hooks // do optimistic store writes before awaiting the RPC, so // the pill repaints with the new value on the same frame. - // Errors are surfaced via the hooks' own state. - void setProductMode(selected); - void setSessionMode(derivedExecMode); + // Swallow the rejection here: usePatchSession rethrows after + // rolling back its optimistic write, and an uncaught RPC + // error would escalate to the full-screen ErrorBoundary. + if (carriesProductMode) { + setProductMode(selected).catch(() => {}); + } + setSessionMode(derivedExecMode).catch(() => {}); } else { setCreatorDefault(derivedExecMode); + setCreatorProductDefault( + selected === PRODUCT_MODE_PROJECT ? PRODUCT_MODE_PROJECT : null + ); } } onModeChange?.(derivedExecMode); @@ -165,9 +189,11 @@ const ModePill: React.FC = memo( [ isControlled, isInSessionMode, + carriesProductMode, setSessionMode, setProductMode, setCreatorDefault, + setCreatorProductDefault, onModeChange, ] ); diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/MenuRows.tsx b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/MenuRows.tsx index 7d89154ffb..1a5be5c730 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/MenuRows.tsx +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/MenuRows.tsx @@ -13,7 +13,7 @@ import { } from "@src/components/Dropdown/tokens"; import type { AgentExecMode, - AgentExecModeEntry, + ComposerModeEntry, } from "@src/config/sessionCreatorConfig"; import { MenuItemRow } from "@src/scaffold/ContextMenu/ResultItems"; import type { SlashItem, SlashItemCategory } from "@src/types/extensions"; @@ -89,7 +89,7 @@ ImageRow.displayName = "ImageRow"; // ── ModeRow ─────────────────────────────────────────────────────────────────── interface ModeRowProps { - mode: AgentExecModeEntry; + mode: ComposerModeEntry; isActive: boolean; isCurrent: boolean; onMouseEnter: () => void; diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/SlashCommandMenu.tsx b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/SlashCommandMenu.tsx index 16509537b4..4d21d57416 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/SlashCommandMenu.tsx +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/SlashCommandMenu.tsx @@ -63,6 +63,7 @@ const SlashCommandMenu: React.FC = ({ showActionFlyouts = false, onImageUpload, showModeRows = true, + includeProjectMode = false, addressComments, }) => { const { t } = useTranslation("sessions"); @@ -81,6 +82,7 @@ const SlashCommandMenu: React.FC = ({ showActionFlyouts, hasImageUpload: Boolean(onImageUpload), showModeRows, + includeProjectMode, }); const [highlightIndex, setHighlightIndex] = useState(0); diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/types.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/types.ts index bc2f88b87f..44430384c3 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/types.ts +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/types.ts @@ -1,9 +1,6 @@ import type React from "react"; -import type { - AgentExecMode, - AgentExecModeEntry, -} from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import type { AddressCommentsThreadOption } from "@src/features/Org2Cloud/useAddressCommentsSlashCommand"; import type { SlashItem, SlashItemCategory } from "@src/types/extensions"; @@ -26,12 +23,12 @@ export interface SlashCommandPortalProps { placement?: FloatingPlacementStrategy; items: SlashItem[]; loading: boolean; - currentMode: AgentExecMode; + currentMode: ComposerModeEntry["id"]; /** Pre-filtered query (slash items already filtered upstream). */ searchQuery?: string; onClose: () => void; onSelect: (item: SlashItem) => void; - onModeSelect: (mode: AgentExecMode) => void; + onModeSelect: (mode: ComposerModeEntry["id"]) => void; keyboardHandlerRef: React.MutableRefObject< ((e: KeyboardEvent) => boolean) | null >; @@ -49,6 +46,8 @@ export interface SlashCommandPortalProps { onImageUpload?: () => void; /** When false, hides mode rows in inline search results for non-session editors. */ showModeRows?: boolean; + /** Offer the Project product mode alongside the exec modes (§5.2). */ + includeProjectMode?: boolean; /** Multi-select flyout data for the Address-comments entry. */ addressComments?: { threads: AddressCommentsThreadOption[]; @@ -60,7 +59,7 @@ export interface SlashCommandPortalProps { export interface ModeEntry { kind: "mode"; - mode: AgentExecModeEntry; + mode: ComposerModeEntry; flatIndex: number; } diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useEntries.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useEntries.ts index 8b060ab1c0..9b0bbd57aa 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useEntries.ts +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useEntries.ts @@ -1,6 +1,9 @@ import { useMemo } from "react"; -import { AGENT_EXEC_MODES } from "@src/config/sessionCreatorConfig"; +import { + AGENT_EXEC_MODES, + COMPOSER_MODES, +} from "@src/config/sessionCreatorConfig"; import type { SlashItem } from "@src/types/extensions"; import { fuzzyMatch } from "@src/util/search/fuzzy"; @@ -19,6 +22,8 @@ interface UseEntriesOptions { hasImageUpload: boolean; /** When false, hides mode rows in inline search results. */ showModeRows?: boolean; + /** Offer the Project product mode alongside the exec modes (§5.2). */ + includeProjectMode?: boolean; } interface UseEntriesResult { @@ -45,6 +50,7 @@ export function useEntries({ showActionFlyouts, hasImageUpload, showModeRows = true, + includeProjectMode = false, }: UseEntriesOptions): UseEntriesResult { return useMemo(() => { const result: ListEntry[] = []; @@ -57,8 +63,11 @@ export function useEntries({ !query || fuzzyMatch(query, "Upload Image") || fuzzyMatch(query, "Image"); + const modeEntries = includeProjectMode + ? COMPOSER_MODES + : AGENT_EXEC_MODES; const matchedModes = showModeRows - ? AGENT_EXEC_MODES.filter( + ? modeEntries.filter( (mode) => !query || fuzzyMatch(query, mode.name) || @@ -189,7 +198,9 @@ export function useEntries({ // Mode rows when showActionFlyouts=false and searching (filter by query) if (showModeRows && !showActionFlyouts && isSearching) { - const matchedModes = AGENT_EXEC_MODES.filter( + const matchedModes = ( + includeProjectMode ? COMPOSER_MODES : AGENT_EXEC_MODES + ).filter( (m) => fuzzyMatch(searchQuery, m.name) || fuzzyMatch(searchQuery, m.id) ); if (matchedModes.length > 0) { @@ -227,5 +238,12 @@ export function useEntries({ } return { entries: result, totalFlat: idx }; - }, [items, searchQuery, showActionFlyouts, hasImageUpload, showModeRows]); + }, [ + items, + searchQuery, + showActionFlyouts, + hasImageUpload, + showModeRows, + includeProjectMode, + ]); } diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useKeyboard.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useKeyboard.ts index 8bf53f4300..c13b9171c6 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useKeyboard.ts +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/useKeyboard.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import type React from "react"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import type { SlashItem } from "@src/types/extensions"; import type { ListEntry, OpenFlyoutState } from "./types"; @@ -17,7 +17,7 @@ interface UseKeyboardOptions { setKeyboardNavigated: (navigated: boolean) => void; setOpenFlyout: (state: OpenFlyoutState | null) => void; onSelect: (item: SlashItem) => void; - onModeSelect: (mode: AgentExecMode) => void; + onModeSelect: (mode: ComposerModeEntry["id"]) => void; onImageUpload?: () => void; onClose: () => void; keyboardHandlerRef: React.MutableRefObject< diff --git a/src/engines/ChatPanel/InputArea/index.tsx b/src/engines/ChatPanel/InputArea/index.tsx index 836602a41e..b2b8d6ea52 100644 --- a/src/engines/ChatPanel/InputArea/index.tsx +++ b/src/engines/ChatPanel/InputArea/index.tsx @@ -192,6 +192,7 @@ const InputAreaInteractive: React.FC = memo( handleSlashAppendSelect, handleModeSelect, currentMode, + includeProjectMode, filteredSlashItems, slashLoading, slashQuery, @@ -607,6 +608,7 @@ const InputAreaInteractive: React.FC = memo( slashLoading={slashLoading} addressCommentsFlyout={addressCommentsFlyout} currentMode={currentMode} + includeProjectMode={includeProjectMode} slashQuery={slashQuery} onSlashCommandClose={handleSlashCommandClose} onSlashSelect={handleSlashSelect} diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx index f6520f1c23..4d13f9f7d2 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/OutputContent.tsx @@ -27,6 +27,7 @@ import { AgentMessageCard, CommandResultCard, FileCard, + OrgtrackEnvelopeCard, ProjectCard, WebsiteCard, WorkItemCard, @@ -335,6 +336,9 @@ const OutputContent: React.FC = ({ {styledOutput?.type === "commandResult" && ( )} + {styledOutput?.type === "orgtrackEnvelope" && ( + + )} {styledOutput?.type === "agentMessageCard" && ( )} diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard.test.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard.test.ts new file mode 100644 index 0000000000..2440c410a2 --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { chatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { stationChatVisibilityAtom } from "@src/store/ui/chatPanelAtom"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; + +import type { OrgtrackEnvelopeData } from "../types"; +import OrgtrackEnvelopeCard from "./OrgtrackEnvelopeCard"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const NOW = "2026-08-09T00:00:00.000Z"; +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +function createdCard(): OrgtrackEnvelopeData { + return { + command: "org2-pm work create --standalone --title Card", + ok: true, + operationId: "work.create", + operation: "Created work item", + exitCode: 0, + shortId: "WI-0101", + title: "Card", + status: "backlog", + isStandalone: true, + workItem: { + body: "Open this item", + filename: "WI-0101", + frontmatter: { + id: "WI-0101", + short_id: "WI-0101", + title: "Card", + status: "backlog", + priority: "none", + labels: [], + todos: [], + starred: false, + created_at: NOW, + updated_at: NOW, + }, + }, + }; +} + +describe("OrgtrackEnvelopeCard", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + act(() => root?.unmount()); + container.remove(); + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("switches to My Station and opens the created item detail", () => { + const store = createStore(); + store.set(stationModeAtom, "agent-station"); + store.set(stationChatVisibilityAtom, { + "my-station": false, + "agent-station": true, + }); + + act(() => { + root = createRoot(container); + root.render( + createElement( + Provider, + { store }, + createElement(OrgtrackEnvelopeCard, { card: createdCard() }) + ) + ); + }); + const button = container.querySelector( + '[data-testid="created-work-item-card"]' + ); + expect(button).not.toBeNull(); + + act(() => button?.click()); + + expect(store.get(stationModeAtom)).toBe("my-station"); + expect(store.get(stationChatVisibilityAtom)["my-station"]).toBe(true); + const tabs = store.get(chatPanelTabsAtom); + const activeTab = tabs.tabs.find((tab) => tab.id === tabs.activeTabId); + expect(activeTab).toMatchObject({ + type: "work-item", + title: "Card", + workItem: { + shortId: "WI-0101", + projectSlug: "", + }, + }); + }); +}); diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard.tsx new file mode 100644 index 0000000000..1e6dc80145 --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/OrgtrackEnvelopeCard.tsx @@ -0,0 +1,137 @@ +import { useSetAtom } from "jotai"; +import { CheckCircle2, ChevronRight, XCircle } from "lucide-react"; +import React, { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { workItemDataToUI } from "@src/api/http/project"; +import { openWorkItemInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { + type ChatPanelSelectedWorkItem, + activeStationChatVisibleAtom, +} from "@src/store/ui/chatPanelAtom"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; + +import type { OrgtrackEnvelopeData } from "../types"; +import { + ToolResultCardFrame, + ToolResultCardFrameButton, +} from "./ToolResultCardFrame"; + +interface OrgtrackEnvelopeCardProps { + card: OrgtrackEnvelopeData; +} + +export function buildCreatedWorkItemSelection( + card: OrgtrackEnvelopeData +): ChatPanelSelectedWorkItem | null { + if ( + !card.ok || + card.operationId !== "work.create" || + !card.workItem || + (!card.isStandalone && !card.projectSlug) + ) { + return null; + } + + const workItem = workItemDataToUI(card.workItem, { + labelMap: new Map(), + memberMap: new Map(), + }); + return { + workItem, + shortId: card.workItem.frontmatter.short_id, + projectId: card.isStandalone + ? "" + : (card.projectId ?? card.workItem.frontmatter.project ?? ""), + projectSlug: card.isStandalone ? "" : (card.projectSlug ?? ""), + projectName: card.isStandalone + ? "" + : (card.projectName ?? card.projectSlug ?? ""), + orgId: card.orgId, + }; +} + +const OrgtrackEnvelopeCard: React.FC = ({ + card, +}) => { + const { t } = useTranslation("common"); + const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + const setStationMode = useSetAtom(stationModeAtom); + const setStationChatVisible = useSetAtom(activeStationChatVisibleAtom); + const selection = useMemo(() => buildCreatedWorkItemSelection(card), [card]); + const handleOpen = useCallback(() => { + if (!selection) return; + setStationMode("my-station"); + setStationChatVisible("my-station", true); + openWorkItem(selection); + }, [openWorkItem, selection, setStationChatVisible, setStationMode]); + const detail = card.ok + ? card.itemCount !== undefined + ? `${card.itemCount} item${card.itemCount === 1 ? "" : "s"}` + : [card.shortId, card.title, card.status ? `→ ${card.status}` : null] + .filter(Boolean) + .join(" · ") + : (card.errorMessage ?? card.errorCode ?? "error"); + + const content = ( + <> +
+ {card.ok ? ( + + ) : ( + + )} + + {card.operation} + + {!card.ok && card.errorCode ? ( + + {card.errorCode} + {card.retryable ? " · retryable" : ""} + + ) : null} + {selection ? ( + + ) : null} +
+ {detail ? ( +
+

{detail}

+
+ ) : null} + + ); + + if (selection) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; + +OrgtrackEnvelopeCard.displayName = "OrgtrackEnvelopeCard"; + +export default OrgtrackEnvelopeCard; diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ToolResultCardFrame.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ToolResultCardFrame.tsx index 1e03010fa8..52f5076c73 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ToolResultCardFrame.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/ToolResultCardFrame.tsx @@ -1,4 +1,9 @@ -import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; +import type { + AnchorHTMLAttributes, + ButtonHTMLAttributes, + HTMLAttributes, + ReactNode, +} from "react"; import { classNames } from "@src/util/ui/classNames"; @@ -19,6 +24,9 @@ type ToolResultCardFrameProps = ToolResultCardFrameBaseProps & type ToolResultCardFrameLinkProps = ToolResultCardFrameBaseProps & AnchorHTMLAttributes; +type ToolResultCardFrameButtonProps = ToolResultCardFrameBaseProps & + ButtonHTMLAttributes; + export function ToolResultCardFrame({ children, className, @@ -62,3 +70,28 @@ export function ToolResultCardFrameLink({ ); } + +export function ToolResultCardFrameButton({ + children, + className, + padded = true, + hoverable = true, + type = "button", + ...props +}: ToolResultCardFrameButtonProps) { + return ( + + ); +} diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts index b015f25fea..dfe688225a 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/cards/index.ts @@ -1,5 +1,6 @@ export { default as AgentMessageCard } from "./AgentMessageCard"; export { default as CommandResultCard } from "./CommandResultCard"; +export { default as OrgtrackEnvelopeCard } from "./OrgtrackEnvelopeCard"; export { default as FileCard } from "./FileCard"; export { default as SessionLinkCard } from "./SessionLinkCard"; export type { SessionLinkCardData } from "./SessionLinkCard"; diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts index 3948dc44e9..5a5219b9d4 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers.ts @@ -37,6 +37,7 @@ export { export { parseAgentMessageCard, parseCommandResult, + parseOrgtrackEnvelope, parseFileCardResult, parseProjectCardResult, parseWebsiteCardResult, diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/parseOrgtrackEnvelope.test.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/parseOrgtrackEnvelope.test.ts new file mode 100644 index 0000000000..53e7cce1d8 --- /dev/null +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/__tests__/parseOrgtrackEnvelope.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import { parseOrgtrackEnvelope } from "../cardParsers"; + +const shell = (command: string) => ({ command }); + +describe("parseOrgtrackEnvelope", () => { + it("renders a successful work.create envelope as a card", () => { + const card = parseOrgtrackEnvelope(shell("org2-pm work create --title x"), { + exit_code: 0, + stdout: JSON.stringify({ + apiVersion: "orgtrack/v1", + ok: true, + data: { + frontmatter: { short_id: "AAA-0001", title: "x", status: "backlog" }, + }, + }), + }); + expect(card).not.toBeNull(); + expect(card?.ok).toBe(true); + expect(card?.operationId).toBe("work.create"); + expect(card?.operation).toBe("Created work item"); + expect(card?.shortId).toBe("AAA-0001"); + }); + + it("keeps the canonical created item and injected project context", () => { + const stdout = JSON.stringify({ + apiVersion: "orgtrack/v1", + ok: true, + data: { + body: "Deliver the card", + filename: "AAA-0002", + frontmatter: { + id: "AAA-0002", + short_id: "AAA-0002", + title: "Clickable result", + project: "project-row-id", + status: "planned", + priority: "high", + labels: [], + todos: [], + starred: false, + created_at: "2026-08-09T00:00:00Z", + updated_at: "2026-08-09T00:00:00Z", + }, + }, + }); + const card = parseOrgtrackEnvelope( + shell("org2-pm work create --title x"), + { output: { success: { stdout, exitCode: 0 } } }, + { + projectSlug: "app-shell", + projectName: "App Shell", + projectId: "project-row-id", + orgId: "org-1", + } + ); + + expect(card).toMatchObject({ + operationId: "work.create", + projectSlug: "app-shell", + projectName: "App Shell", + projectId: "project-row-id", + orgId: "org-1", + isStandalone: false, + }); + expect(card?.workItem?.frontmatter.short_id).toBe("AAA-0002"); + }); + + it("marks standalone creates without borrowing session project context", () => { + const card = parseOrgtrackEnvelope( + shell("org2-pm work create --standalone --title x"), + { + stdout: JSON.stringify({ + apiVersion: "orgtrack/v1", + ok: true, + data: { + body: "", + filename: "WI-0099", + frontmatter: { + id: "WI-0099", + short_id: "WI-0099", + title: "Standalone card", + status: "backlog", + priority: "none", + labels: [], + todos: [], + starred: false, + created_at: "2026-08-09T00:00:00Z", + updated_at: "2026-08-09T00:00:00Z", + }, + }, + }), + }, + { projectSlug: "must-not-leak" } + ); + + expect(card?.isStandalone).toBe(true); + expect(card?.projectSlug).toBeUndefined(); + expect(card?.workItem?.frontmatter.short_id).toBe("WI-0099"); + }); + + it("renders an error envelope with the wire code", () => { + const card = parseOrgtrackEnvelope(shell("org2-pm work claim AAA-0001"), { + exit_code: 4, + stdout: JSON.stringify({ + apiVersion: "orgtrack/v1", + ok: false, + error: { code: "ALREADY_CLAIMED", message: "taken", retryable: false }, + }), + }); + expect(card?.ok).toBe(false); + expect(card?.errorCode).toBe("ALREADY_CLAIMED"); + }); + + it("counts items for a list envelope", () => { + const card = parseOrgtrackEnvelope(shell("org2-pm work list"), { + exit_code: 0, + stdout: JSON.stringify({ + apiVersion: "orgtrack/v1", + ok: true, + data: { items: [{}, {}, {}] }, + }), + }); + expect(card?.itemCount).toBe(3); + expect(card?.workItem).toBeUndefined(); + }); + + it("ignores non-org2-pm commands and non-envelope output", () => { + expect(parseOrgtrackEnvelope(shell("ls -la"), { stdout: "{}" })).toBeNull(); + expect( + parseOrgtrackEnvelope(shell("org2-pm work list"), { stdout: "not json" }) + ).toBeNull(); + expect( + parseOrgtrackEnvelope(shell("org2-pm work list"), { + stdout: JSON.stringify({ apiVersion: "other/v1", ok: true }), + }) + ).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts index a67a5c7cd8..6a39fbe564 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/cardParsers.ts @@ -2,6 +2,7 @@ * Rich card parsers — derive structured card data from tool args + results * for file, website, work-item, and project tool calls. */ +import type { WorkItemData } from "@src/api/http/project"; import { normalizeHttpUrlCandidate } from "@src/util/url/validation"; import type { @@ -10,6 +11,7 @@ import type { CommandArtifact, CommandResultData, FileCardData, + OrgtrackEnvelopeData, ProjectCardData, WebsiteCardData, WorkItemCardData, @@ -429,3 +431,194 @@ export function parseAgentMessageCard( deliveries, }; } + +const ORGTRACK_OP_LABELS: Record = { + "work.create": "Created work item", + "work.update": "Updated work item", + "work.transition": "Transitioned work item", + "work.claim": "Claimed work item", + "work.release": "Released work item", + "work.assign": "Assigned work item", + "work.note": "Noted work item", + "project.create": "Created project", + "project.update": "Updated project", +}; + +function inferOrgtrackOperation(command: string): string { + const tokens = command.trim().split(/\s+/); + const idx = tokens.findIndex((t) => t.endsWith("org2-pm") || t === "org2"); + if (idx < 0) return ""; + const noun = tokens[idx + 1]; + const verb = tokens[idx + 2]; + if (!noun || !verb) return noun ?? ""; + return `${noun}.${verb}`; +} + +export interface OrgtrackEnvelopeContext { + projectSlug?: string; + projectName?: string; + projectId?: string; + orgId?: string; +} + +function extractShellResult( + args: Record, + result: Record +): { + command: string | null; + stdout: string; + exitCode: number | undefined; +} { + const output = asRecord(result.output); + const success = asRecord(output?.success) ?? asRecord(result.success); + const failure = asRecord(output?.failure) ?? asRecord(result.failure); + const payload = success ?? failure ?? result; + const command = + getString(args.command) ?? + getString(args.cmd) ?? + getString(payload.command) ?? + null; + const stdout = + getString(payload.stdout) ?? + getString(result.stdout) ?? + (typeof result.output === "string" ? result.output : null) ?? + getString(result.content) ?? + ""; + const rawExit = + payload.exit_code ?? + payload.exitCode ?? + result.exit_code ?? + result.exitCode ?? + result.code; + return { + command, + stdout, + exitCode: + typeof rawExit === "number" + ? rawExit + : success + ? 0 + : failure + ? -1 + : undefined, + }; +} + +function parseScopeFlag(command: string): string | undefined { + const match = command.match( + /(?:^|\s)--scope(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/ + ); + return match?.[1] ?? match?.[2] ?? match?.[3]; +} + +function hasStandaloneFlag(command: string): boolean { + return /(?:^|\s)--standalone(?:\s|$)/.test(command); +} + +function parseCreatedWorkItem(data: unknown): WorkItemData | undefined { + const item = asRecord(data); + const frontmatter = asRecord(item?.frontmatter); + if ( + !item || + !frontmatter || + typeof item.body !== "string" || + !getString(item.filename) || + !getString(frontmatter.id) || + !getString(frontmatter.short_id) || + !getString(frontmatter.title) || + !getString(frontmatter.status) || + !getString(frontmatter.priority) || + !getString(frontmatter.created_at) || + !getString(frontmatter.updated_at) + ) { + return undefined; + } + return item as unknown as WorkItemData; +} + +export function parseOrgtrackEnvelope( + args: Record, + result: Record, + context: OrgtrackEnvelopeContext = {} +): OrgtrackEnvelopeData | null { + const shell = extractShellResult(args, result); + const { command } = shell; + if (!command || !/\borg2-pm\b|\borg2\b/.test(command)) return null; + + const trimmed = shell.stdout.trim(); + if (!trimmed.startsWith("{")) return null; + + let envelope: Record; + try { + const parsed: unknown = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object") return null; + envelope = parsed as Record; + } catch { + return null; + } + if (envelope.apiVersion !== "orgtrack/v1") return null; + + const exitCode = shell.exitCode ?? (envelope.ok === true ? 0 : -1); + const operationId = inferOrgtrackOperation(command); + const operation = ORGTRACK_OP_LABELS[operationId] ?? operationId ?? "org2-pm"; + + if (envelope.ok === true) { + const data = (envelope.data ?? {}) as Record; + const fm = (data.frontmatter ?? {}) as Record; + const items = Array.isArray(data.items) ? data.items : null; + const workItem = + operationId === "work.create" + ? parseCreatedWorkItem(envelope.data) + : undefined; + const explicitProjectSlug = parseScopeFlag(command); + const projectSlug = explicitProjectSlug ?? context.projectSlug; + const projectContextMatches = + !explicitProjectSlug || explicitProjectSlug === context.projectSlug; + const projectId = + getString(fm.project) ?? + (projectContextMatches ? context.projectId : undefined); + const isStandalone = + operationId === "work.create" + ? hasStandaloneFlag(command) || (!projectSlug && !projectId) + : undefined; + return { + command, + ok: true, + operationId, + operation, + exitCode, + shortId: + (typeof fm.short_id === "string" ? fm.short_id : undefined) ?? + (typeof data.slug === "string" ? data.slug : undefined), + title: + (typeof fm.title === "string" ? fm.title : undefined) ?? + (typeof data.name === "string" ? data.name : undefined), + status: + (typeof fm.status === "string" ? fm.status : undefined) ?? + (typeof data.status === "string" ? data.status : undefined), + itemCount: items ? items.length : undefined, + workItem, + projectSlug: isStandalone ? undefined : projectSlug, + projectName: + isStandalone || !projectContextMatches + ? undefined + : context.projectName, + projectId: isStandalone ? undefined : projectId, + orgId: context.orgId, + isStandalone, + }; + } + + const error = (envelope.error ?? {}) as Record; + return { + command, + ok: false, + operationId, + operation, + exitCode, + errorCode: typeof error.code === "string" ? error.code : undefined, + errorMessage: typeof error.message === "string" ? error.message : undefined, + retryable: + typeof error.retryable === "boolean" ? error.retryable : undefined, + }; +} diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts index ecc6ceb689..db3d846436 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/helpers/index.ts @@ -22,6 +22,7 @@ export { export { parseAgentMessageCard, parseCommandResult, + parseOrgtrackEnvelope, parseFileCardResult, parseProjectCardResult, parseWebsiteCardResult, diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx b/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx index c4f690e195..8ad18a78c4 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/index.tsx @@ -53,6 +53,7 @@ import { parseFileCardResult, parseManageLspResult, parseManageWorkspaceResult, + parseOrgtrackEnvelope, parseProjectCardResult, parseProjectToolListResult, parseSearchFilesResult, @@ -248,6 +249,9 @@ const ToolCallBlock: React.FC = React.memo( return { type: "agentMessageCard" as const, card }; } if (isShellTool(toolName) && hasResult) { + const envelope = parseOrgtrackEnvelope(args, result); + if (envelope) + return { type: "orgtrackEnvelope" as const, card: envelope }; const card = parseCommandResult(args, result); if (card) return { type: "commandResult" as const, card }; } diff --git a/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts b/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts index 8ddf598ff6..81d78c6d9c 100644 --- a/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts +++ b/src/engines/ChatPanel/blocks/ToolCallBlock/types.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import type { WorkItemData } from "@src/api/http/project"; import type { PayloadRef, ToolUsageMetadata, @@ -184,6 +185,34 @@ export interface CommandResultData { artifacts?: CommandArtifact[]; } +/** + * Parsed `org2-pm` CLI envelope surfaced from a shell tool call, so an + * agent-plane work-system write renders as a work card instead of a raw + * terminal block (design S3 envelope renderer). + */ +export interface OrgtrackEnvelopeData { + command: string; + ok: boolean; + operationId: string; + operation: string; + exitCode: number; + shortId?: string; + title?: string; + status?: string; + errorCode?: string; + errorMessage?: string; + retryable?: boolean; + itemCount?: number; + /** Canonical create result used to open the real Work Item detail surface. */ + workItem?: WorkItemData; + /** Resolved navigation context; empty for standalone items. */ + projectSlug?: string; + projectName?: string; + projectId?: string; + orgId?: string; + isStandalone?: boolean; +} + /** * Inter-agent message sent via the Agent Team messaging tool. * Rendered as a compact speech-bubble card with recipient, kind, and summary. @@ -242,6 +271,7 @@ export type StyledOutput = | { type: "workItemCard"; card: WorkItemCardData } | { type: "projectCard"; card: ProjectCardData } | { type: "commandResult"; card: CommandResultData } + | { type: "orgtrackEnvelope"; card: OrgtrackEnvelopeData } | { type: "agentMessageCard"; card: AgentMessageCardData }; export interface OutputContentProps { diff --git a/src/engines/ChatPanel/header/usePublishChatPanelHeader.ts b/src/engines/ChatPanel/header/usePublishChatPanelHeader.ts index dd4972901a..a7732c507f 100644 --- a/src/engines/ChatPanel/header/usePublishChatPanelHeader.ts +++ b/src/engines/ChatPanel/header/usePublishChatPanelHeader.ts @@ -12,6 +12,20 @@ interface UsePublishChatPanelHeaderOptions { enabled?: boolean; } +function sameHeaderSlots( + a: ChatPanelHeaderSlots | null, + b: ChatPanelHeaderSlots | null +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.leading === b.leading && + a.content === b.content && + a.trailing === b.trailing && + (a.joinWithFollowingRow ?? false) === (b.joinWithFollowingRow ?? false) + ); +} + export function usePublishChatPanelHeader({ content, enabled = true, @@ -28,8 +42,21 @@ export function usePublishChatPanelHeader({ return; } - ownedContentRef.current = content; - setHeader(content); + // Defensive dedupe: a caller that rebuilds the `content` wrapper object + // every render (without memoizing) would otherwise re-publish on every + // commit, and because the header atom's subscriber re-render can cascade + // back into the publisher this becomes an unbounded synchronous update + // loop. Compare the meaningful slot references — the slot elements are + // memoized even when the wrapper object is not — and only publish when + // one actually changes. + setHeader((previous) => { + if (sameHeaderSlots(previous, content)) { + ownedContentRef.current = previous; + return previous; + } + ownedContentRef.current = content; + return content; + }); }, [content, enabled, setHeader]); useLayoutEffect(() => { diff --git a/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts b/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts index 797a159903..d0654aeb96 100644 --- a/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts +++ b/src/engines/ChatPanel/hooks/useAiWorkItemCreator.ts @@ -6,7 +6,6 @@ import { type LinkedSession, type WorkItemData, projectApi, - workItemDataToUI, } from "@src/api/http/project"; import Message from "@src/components/Message"; import type { SessionLaunchSuccessInfo } from "@src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/types"; @@ -14,21 +13,23 @@ import { allocateCloudAwareStandaloneWorkItemId, allocateCloudAwareWorkItemId, } from "@src/features/Org2Cloud/cloudShortId"; +import { useWorkStationTabs } from "@src/hooks/workStation"; import i18n from "@src/i18n"; import type { AgentDefinition } from "@src/modules/MainApp/AgentOrgs/types"; -import { openOrFocusSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { openSessionInNewChatTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { SESSION_TARGET_KIND } from "@src/store/session"; import type { SessionCreatorState } from "@src/store/session/creatorStateAtom"; import { type ChatPanelCreateProjectContext, type ChatPanelSelectedProject, - type ChatPanelSelectedWorkItem, } from "@src/store/ui/chatPanelAtom"; +import { STATION_MODE, stationModeAtom } from "@src/store/ui/simulatorAtom"; import type { WorkItemDraft } from "@src/store/workstation/projectManager"; +import { createWorkItemDetailTab } from "@src/store/workstation/tabs"; import { getDispatchCategory } from "@src/util/session/sessionDispatch"; -// Work Item Manager persona was retired; the generic OS Agent carries -// manage_work_item/manage_project as ordinary built-in tools. +// Work Item Manager persona was retired; the generic OS Agent fills +// the draft through the injected `org2-pm` CLI from its shell. const WORK_ITEM_DEFAULT_AGENT_DEF_ID = "builtin:os"; const AI_WORK_ITEM_DEFAULT_TITLE = "AI Work Item Draft"; @@ -76,7 +77,6 @@ interface UseAiWorkItemCreatorOptions { creatorState: SessionCreatorState; setActiveSessionId: (sessionId: string | null) => void; setSelectedProject: (project: ChatPanelSelectedProject | null) => void; - setSelectedWorkItem: (workItem: ChatPanelSelectedWorkItem | null) => void; setWorkItemCreateDraft: (draft: WorkItemDraft | null) => void; setWorkstationActiveSessionId: (sessionId: string | null) => void; workItemCreateDraft: WorkItemDraft | null; @@ -88,14 +88,13 @@ export function useAiWorkItemCreator({ creatorState, setActiveSessionId, setSelectedProject, - setSelectedWorkItem, setWorkItemCreateDraft, setWorkstationActiveSessionId, workItemCreateDraft, }: UseAiWorkItemCreatorOptions) { - const openOrFocusSessionTab = useSetAtom( - openOrFocusSessionInChatPanelTabAtom - ); + const openLaunchedSessionTab = useSetAtom(openSessionInNewChatTabAtom); + const setStationMode = useSetAtom(stationModeAtom); + const { openTab: openStationTab } = useWorkStationTabs(); const resolveAiWorkItemAssignee = useCallback( (draft: WorkItemDraft): ResolvedAiWorkItemAssignee | null => { if (draft.assigneeType === "agent" && draft.assigneeId) { @@ -244,11 +243,12 @@ export function useAiWorkItemCreator({ return { workItemId: shortId, projectSlug: selectedProjectSlug || undefined, + orgId: standaloneOrgId, agentRole: "custom" as const, - // The draft-fill session must run an agent that registers - // manage_work_item; the composer's selected agent (usually SDE) - // does not carry the PM tools and would silently fail to fill - // the draft it was launched for. The item's assignee is + agentExecMode: "build", + // The draft-fill session runs OS Agent: it always carries + // run_shell, and the launch injects the org2-pm identity so the + // linked-work-item brief can be acted on. The item's assignee is // unaffected — it stays whatever was resolved above. agentDefinitionId: WORK_ITEM_DEFAULT_AGENT_DEF_ID, metadata: { @@ -311,39 +311,36 @@ export function useAiWorkItemCreator({ ); } - const workItem = workItemDataToUI(updatedItem, { - labelMap: new Map(), - memberMap: new Map(), - }); setSelectedProject(null); - setSelectedWorkItem({ - shortId: metadata.shortId, - projectSlug: metadata.projectSlug, - projectId: metadata.projectId, - projectName: metadata.projectName, - orgId: metadata.orgId, - workItem, - }); setWorkItemCreateDraft(null); - // Land the user IN the launched session instead of bouncing them - // back to the start page's Session tab: after "create a work item - // with AI" the only honest answer to "what is happening now?" is - // the agent session doing the work — it also surfaces the item - // via the active-WorkItem pill. The old reset left a blank - // composer and a toast minutes later. + // Land the launched session in the LEFT chat panel as a normal session + // tab (the existing chat UX), and open the Work Item detail in the + // RIGHT station pane. The item filling in live stays visible beside + // the conversation instead of competing with it for the chat surface. setActiveSessionId(info.sessionId); setWorkstationActiveSessionId(info.sessionId); - openOrFocusSessionTab({ - sessionId: info.sessionId, - sessionName: metadata.item.frontmatter.title, - }); + openLaunchedSessionTab({ sessionId: info.sessionId }); + setStationMode(STATION_MODE.MY_STATION); + openStationTab( + createWorkItemDetailTab( + metadata.projectId || undefined, + metadata.projectName || undefined, + metadata.shortId, + updatedItem.frontmatter.title || AI_WORK_ITEM_DEFAULT_TITLE, + metadata.projectSlug || undefined, + undefined, + undefined, + updatedItem.frontmatter.status + ) + ); await emit("orgii-data-changed"); }, [ - openOrFocusSessionTab, + openLaunchedSessionTab, + openStationTab, setActiveSessionId, setSelectedProject, - setSelectedWorkItem, + setStationMode, setWorkItemCreateDraft, setWorkstationActiveSessionId, ] diff --git a/src/engines/ChatPanel/hooks/useInputArea/index.ts b/src/engines/ChatPanel/hooks/useInputArea/index.ts index fd9597bc05..2303dde8fd 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/index.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/index.ts @@ -698,6 +698,7 @@ export function useInputArea( handleSlashAppendSelect: slashCommand.handleSlashAppendSelect, handleModeSelect: slashCommand.handleModeSelect, currentMode: slashCommand.currentMode, + includeProjectMode: slashCommand.includeProjectMode, filteredSlashItems: slashCommand.filteredItems, slashLoading: slashCommand.slashLoading, prefetchSlashItems: slashCommand.prefetchItems, diff --git a/src/engines/ChatPanel/hooks/useInputArea/types.ts b/src/engines/ChatPanel/hooks/useInputArea/types.ts index ee04c21e26..26ded7f216 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/types.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/types.ts @@ -10,7 +10,7 @@ import type { } from "react"; import type { ComposerInputRef } from "@src/components/ComposerInput"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import type { MenuItemId } from "@src/scaffold/ContextMenu/config"; import type { ChatImageAttachment } from "@src/store/ui/chatImageAtom"; import type { SlashItem } from "@src/types/extensions/types"; @@ -208,8 +208,9 @@ export interface UseInputAreaReturn { handleSlashCommandClose: () => void; handleSlashSelect: (item: SlashItem) => void; handleSlashAppendSelect: (item: SlashItem) => void; - handleModeSelect: (mode: AgentExecMode) => void; - currentMode: AgentExecMode; + handleModeSelect: (mode: ComposerModeEntry["id"]) => void; + currentMode: ComposerModeEntry["id"]; + includeProjectMode: boolean; filteredSlashItems: SlashItem[]; slashLoading: boolean; prefetchSlashItems: (query: string) => void; diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts b/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts index 0886620127..3e60ca52ae 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSlashCommand.ts @@ -10,7 +10,12 @@ import { type RefObject, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { ComposerInputRef } from "@src/components/ComposerInput"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import { + type AgentExecMode, + type ComposerModeEntry, + PRODUCT_MODE_PROJECT, + execModeForComposerSelection, +} from "@src/config/sessionCreatorConfig"; import { buildMcpToolCommand } from "@src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils"; import { buildAddressCommentsPillPath } from "@src/features/Org2Cloud/addressCommentsSlashToken"; import { @@ -18,9 +23,17 @@ import { type AddressCommentsThreadOption, useAddressCommentsSlashCommand, } from "@src/features/Org2Cloud/useAddressCommentsSlashCommand"; -import { useSessionExecModeField } from "@src/hooks/session/useSessionPatch"; +import { + useSessionExecModeField, + useSessionProductModeField, +} from "@src/hooks/session/useSessionPatch"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; +import { creatorDefaultProductModeAtom } from "@src/store/session/creatorDefaultProductModeAtom"; import { SLASH_ACTIONS, type SlashItem } from "@src/types/extensions"; +import { + isAgentSession, + isCliSession, +} from "@src/util/session/sessionDispatch"; import { useSlashItemsCache } from "./useSlashItemsCache"; @@ -60,8 +73,10 @@ export interface SlashCommandHandlers { handleSlashCommandClose: () => void; handleSlashSelect: (item: SlashItem) => void; handleSlashAppendSelect: (item: SlashItem) => void; - handleModeSelect: (mode: AgentExecMode) => void; - currentMode: AgentExecMode; + handleModeSelect: (mode: ComposerModeEntry["id"]) => void; + currentMode: ComposerModeEntry["id"]; + /** Whether the `/` mode picker should offer the Project product mode. */ + includeProjectMode: boolean; filteredItems: SlashItem[]; slashLoading: boolean; /** @@ -95,20 +110,59 @@ export function useSlashCommand( const isInSession = !forceCreatorDefault && Boolean(sessionId); const creatorDefaultMode = useAtomValue(creatorDefaultExecModeAtom); const setCreatorDefaultMode = useSetAtom(creatorDefaultExecModeAtom); + const creatorProductDefault = useAtomValue(creatorDefaultProductModeAtom); + const setCreatorProductDefault = useSetAtom(creatorDefaultProductModeAtom); const { agentExecMode: sessionMode, setMode: setSessionMode } = useSessionExecModeField(sessionId ?? ""); - const currentMode: AgentExecMode = isInSession + const { productMode, setProductMode } = useSessionProductModeField( + sessionId ?? "" + ); + // §5.2: only agent and CLI sessions carry the product-mode axis; the + // creator always offers it because those are the kinds it launches. + const carriesProductMode = isInSession + ? Boolean( + sessionId && (isAgentSession(sessionId) || isCliSession(sessionId)) + ) + : true; + const currentExecMode: AgentExecMode = isInSession ? ((sessionMode as AgentExecMode | undefined) ?? creatorDefaultMode) : creatorDefaultMode; + const currentMode: ComposerModeEntry["id"] = carriesProductMode + ? isInSession + ? productMode === PRODUCT_MODE_PROJECT + ? PRODUCT_MODE_PROJECT + : currentExecMode + : creatorProductDefault === PRODUCT_MODE_PROJECT + ? PRODUCT_MODE_PROJECT + : currentExecMode + : currentExecMode; const setMode = useCallback( - (mode: AgentExecMode) => { + (selected: ComposerModeEntry["id"]) => { + const derivedExecMode = execModeForComposerSelection(selected); if (isInSession) { - void setSessionMode(mode); + // Mirror ModePill's §5.2 dispatch: write the product axis where + // the session carries it, always write the derived exec mode. + // Swallow rejections — the patch hooks roll back optimistic + // writes and rethrow, which would otherwise hit the boundary. + if (carriesProductMode) { + void setProductMode(selected).catch(() => {}); + } + void setSessionMode(derivedExecMode).catch(() => {}); } else { - setCreatorDefaultMode(mode); + setCreatorDefaultMode(derivedExecMode); + setCreatorProductDefault( + selected === PRODUCT_MODE_PROJECT ? PRODUCT_MODE_PROJECT : null + ); } }, - [isInSession, setSessionMode, setCreatorDefaultMode] + [ + isInSession, + carriesProductMode, + setProductMode, + setSessionMode, + setCreatorDefaultMode, + setCreatorProductDefault, + ] ); const queryRef = useRef(""); @@ -289,7 +343,7 @@ export function useSlashCommand( ); const handleModeSelect = useCallback( - (mode: AgentExecMode) => { + (mode: ComposerModeEntry["id"]) => { setMode(mode); setShowSlashMenu(false); setSlashQuery(""); @@ -308,6 +362,7 @@ export function useSlashCommand( handleSlashAppendSelect, handleModeSelect, currentMode, + includeProjectMode: carriesProductMode, filteredItems, slashLoading, prefetchItems, diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index 0841521c3a..89407a49b9 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -460,7 +460,6 @@ const ChatPanel: React.FC = memo( creatorState, setActiveSessionId, setSelectedProject, - setSelectedWorkItem, setWorkItemCreateDraft, setWorkstationActiveSessionId, workItemCreateDraft, diff --git a/src/engines/ChatPanel/panels/ProjectPanelView.tsx b/src/engines/ChatPanel/panels/ProjectPanelView.tsx index 9c7a246cd3..5b3e8b07ea 100644 --- a/src/engines/ChatPanel/panels/ProjectPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectPanelView.tsx @@ -176,9 +176,14 @@ export const ProjectPanelView: React.FC = ({ ] ); - usePublishChatPanelHeader({ - content: { content: headerContent }, - }); + // Memoize the published-header payload — a fresh object literal every + // render re-publishes on every commit and can drive an unbounded update + // loop through the header atom's subscriber (see WorkItemPanelView). + const publishedHeader = useMemo( + () => ({ content: headerContent }), + [headerContent] + ); + usePublishChatPanelHeader({ content: publishedHeader }); useEffect(() => { if (!projectSlug) return; diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx index 69c7d54982..7b09aaa2c7 100644 --- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx +++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx @@ -1,6 +1,6 @@ import { emit } from "@tauri-apps/api/event"; import { useAtomValue, useSetAtom } from "jotai"; -import { ListChecks, SquareArrowOutUpRight, Trash2, X } from "lucide-react"; +import { ListChecks, Trash2 } from "lucide-react"; import React, { useCallback, useEffect, @@ -12,9 +12,11 @@ import { useTranslation } from "react-i18next"; import { STORY_SYNC_ADAPTER } from "@src/api/http/integrations/syncConnections"; import { + type WorkItemData, enrichedWorkItemToUI, projectApi, standaloneWorkItemDataToEnriched, + workItemDataToUI, } from "@src/api/http/project"; import { projectSyncApi } from "@src/api/http/project/sync"; import Button from "@src/components/Button"; @@ -26,11 +28,13 @@ import { useProjectDataChanged } from "@src/hooks/project"; import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; import { WorkItemThreadSurface } from "@src/modules/ProjectManager/WorkItems/components"; import { WorkItemDetailHeaderBreadcrumb } from "@src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailHeader"; -import { useWorkItemOrchestrator } from "@src/modules/ProjectManager/WorkItems/hooks"; import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate"; import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared"; import { closeWorkItemChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; -import { activeSessionIdAtom } from "@src/store/session"; +import { + openSessionInNewChatTabAtom, + openWorkItemInChatPanelTabAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; import { type ChatPanelSelectedWorkItem, chatPanelSelectedWorkItemAtom, @@ -39,12 +43,9 @@ import { activeWorkspaceRootPathAtom } from "@src/store/workspace"; import { WORK_ITEM_STATUS, type WorkItem } from "@src/types/core/workItem"; import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; -import SessionContentView from "../SessionContentView"; -import { usePendingWorkItemAction } from "./usePendingWorkItemAction"; import { useWorkItemGitHubIssueState } from "./useWorkItemGitHubIssueState"; const logger = createLogger("WorkItemPanelView"); -const saveNoPendingWorkItemChanges = async (): Promise => undefined; interface WorkItemPanelViewProps { selectedWorkItem: ChatPanelSelectedWorkItem; @@ -71,11 +72,8 @@ export const WorkItemPanelView: React.FC = ({ const { t } = useTranslation(["projects", "common"]); const closeWorkItemTab = useSetAtom(closeWorkItemChatPanelTabAtom); const setSelectedWorkItem = useSetAtom(chatPanelSelectedWorkItemAtom); - const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const openSessionTab = useSetAtom(openSessionInNewChatTabAtom); const activeWorkspaceRootPath = useAtomValue(activeWorkspaceRootPathAtom); - const [floatingSessionId, setFloatingSessionId] = useState( - null - ); const [projectSyncAdapter, setProjectSyncAdapter] = useState<{ projectSlug: string; adapterId: string | null; @@ -180,6 +178,12 @@ export const WorkItemPanelView: React.FC = ({ [currentUser, onUpdateWorkItem, selectedWorkItem, setSelectedWorkItem] ); + // The owning work-item tab's stored payload is mirrored from + // `chatPanelSelectedWorkItemAtom` by ChatPanel's patch effect. Refresh must + // therefore write only the selection atom: writing the tab here as well + // seeds a second, content-equal object into the tab slot, and the + // selection<->tab mirror then shuffles the two distinct references forever + // (React "maximum update depth"). One writer, one reference. const refreshSelectedWorkItemOnce = useCallback(async () => { try { if (selectedWorkItem.projectSlug) { @@ -196,11 +200,12 @@ export const WorkItemPanelView: React.FC = ({ closeWorkItemTab(selectedWorkItem.shortId); return; } + const refreshedProjectItem = enrichedWorkItemToUI(fresh); setSelectedWorkItem((current) => current?.projectSlug === selectedWorkItem.projectSlug && current.shortId === selectedWorkItem.shortId && current.orgId === selectedWorkItem.orgId - ? { ...current, workItem: enrichedWorkItemToUI(fresh) } + ? { ...current, workItem: refreshedProjectItem } : current ); return; @@ -210,15 +215,13 @@ export const WorkItemPanelView: React.FC = ({ selectedWorkItem.shortId, selectedWorkItem.orgId ? { orgId: selectedWorkItem.orgId } : undefined ); + const refreshedStandaloneItem = enrichedWorkItemToUI( + standaloneWorkItemDataToEnriched(data) + ); setSelectedWorkItem((current) => current?.shortId === selectedWorkItem.shortId && current.orgId === selectedWorkItem.orgId - ? { - ...current, - workItem: enrichedWorkItemToUI( - standaloneWorkItemDataToEnriched(data) - ), - } + ? { ...current, workItem: refreshedStandaloneItem } : current ); } catch (error) { @@ -282,66 +285,43 @@ export const WorkItemPanelView: React.FC = ({ { fireOnMount: true } ); - // The chat-panel detail is a second presentation of the same Work Item, - // not a read-only workflow mock. Reuse the canonical orchestrator hook so - // agent actions, cloud execution locks, and lock-holder labels behave the - // same here as they do in the full Project Manager detail. const repoPath = selectedWorkItem.sourceProject?.project.linkedRepos?.[0]?.id ?? activeWorkspaceRootPath ?? null; - const { - isStartingAgent, - activeAgentSessionId, - activeAgentRole, - handleStartAgent, - handleRetry, - handleCancelAgent, - handleAcceptAsIs, - handleCreateFollowUp, - isLockedByOther, - lockHolderName, - } = useWorkItemOrchestrator({ - workItem: selectedWorkItem.workItem, - displayWorkItem: selectedWorkItem.workItem, - repoPath, - projectSlug: selectedWorkItem.projectSlug, - shortId: selectedWorkItem.shortId, - onRefreshWorkItem: refreshSelectedWorkItem, - onUpdateWorkItem: handleUpdateWorkItem, - hasPendingChanges: false, - handleSave: saveNoPendingWorkItemChanges, - }); - - usePendingWorkItemAction({ - workItemShortId: selectedWorkItem.shortId, - onStartAgent: handleStartAgent, - }); const handleOpenSession = useCallback( (sessionId: string) => { - setFloatingSessionId(sessionId); - setActiveSessionId(sessionId); + openSessionTab({ sessionId }); }, - [setActiveSessionId] + [openSessionTab] ); - const handleCloseFloatingSession = useCallback(() => { - setFloatingSessionId(null); - }, []); - - const linkedSessions = useMemo( - () => selectedWorkItem.workItem.linkedSessions ?? [], - [selectedWorkItem.workItem.linkedSessions] - ); - const floatingSession = useMemo( - () => - floatingSessionId - ? linkedSessions.find( - (session) => session.session_id === floatingSessionId - ) - : undefined, - [floatingSessionId, linkedSessions] + const openWorkItemTab = useSetAtom(openWorkItemInChatPanelTabAtom); + const handleOpenFamilyItem = useCallback( + (item: WorkItemData) => { + const selection = { + workItem: workItemDataToUI(item, { + labelMap: new Map(), + memberMap: new Map(), + }), + projectId: selectedWorkItem.projectId, + projectName: selectedWorkItem.projectName, + projectSlug: selectedWorkItem.projectSlug, + shortId: item.frontmatter.short_id, + orgId: selectedWorkItem.orgId, + }; + setSelectedWorkItem(selection); + openWorkItemTab(selection); + }, + [ + openWorkItemTab, + selectedWorkItem.orgId, + selectedWorkItem.projectId, + selectedWorkItem.projectName, + selectedWorkItem.projectSlug, + setSelectedWorkItem, + ] ); const workItemContentKey = `${selectedWorkItem.projectSlug}:${ @@ -473,9 +453,16 @@ export const WorkItemPanelView: React.FC = ({ ] ); - usePublishChatPanelHeader({ - content: { content: headerContent, trailing: headerActions }, - }); + // Memoize the published-header payload. A fresh `{ content, trailing }` + // object literal every render makes `usePublishChatPanelHeader`'s + // layout effect re-publish on every commit; because the header atom's + // subscriber re-render cascades back into this panel, that becomes an + // unbounded synchronous update loop (React "maximum update depth"). + const publishedHeader = useMemo( + () => ({ content: headerContent, trailing: headerActions }), + [headerContent, headerActions] + ); + usePublishChatPanelHeader({ content: publishedHeader }); return (
= ({ repoPath={repoPath} projectSlug={selectedWorkItem.projectSlug} shortId={selectedWorkItem.shortId} + orgId={selectedWorkItem.orgId} githubIssueTimeline={githubIssueState.timeline} githubIssueInteraction={githubIssueState.interaction} - onStartAgent={handleStartAgent} - isStartingAgent={isStartingAgent} - onCancelAgent={handleCancelAgent} - onRetry={handleRetry} - onAcceptAsIs={handleAcceptAsIs} - onCreateFollowUp={handleCreateFollowUp} onOpenSession={handleOpenSession} + onOpenSubItem={handleOpenFamilyItem} onRefreshWorkflow={refreshSelectedWorkItem} - activeAgentSessionId={activeAgentSessionId} - activeAgentRole={activeAgentRole} - isLockedByOther={isLockedByOther} - lockHolderName={lockHolderName} />
- {floatingSessionId && ( -
-
-
- -
-
- {floatingSession?.result_preview || - floatingSession?.sub_agent_name || - floatingSession?.agent_role || - t("common:terminology.session")} -
-
- {floatingSession?.status - ? `${floatingSession.status} · ${floatingSession.session_type}` - : floatingSessionId} -
-
-
-
-
- -
-
- )} ); }; diff --git a/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts b/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts deleted file mode 100644 index 72e64d372e..0000000000 --- a/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// @vitest-environment jsdom -import { Provider, createStore } from "jotai"; -import { act, createElement } from "react"; -import { type Root, createRoot } from "react-dom/client"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import { - pendingChatPanelWorkItemActionAtom, - requestChatPanelWorkItemActionAtom, -} from "@src/store/chatPanel/chatPanelTabsAtom"; - -import { usePendingWorkItemAction } from "../usePendingWorkItemAction"; - -function Harness({ - workItemShortId, - onStartAgent, -}: { - workItemShortId: string; - onStartAgent: () => void; -}) { - usePendingWorkItemAction({ workItemShortId, onStartAgent }); - return null; -} - -describe("usePendingWorkItemAction", () => { - let container: HTMLDivElement; - let root: Root; - const actEnvironment = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - }; - - beforeAll(() => { - actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; - }); - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - afterAll(() => { - Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); - }); - - it("starts once after the canonical Work Item surface claims the request", () => { - const store = createStore(); - const onStartAgent = vi.fn(); - store.set(requestChatPanelWorkItemActionAtom, { - workItemShortId: "ORG-42", - action: "start_agent", - }); - - act(() => { - root.render( - createElement( - Provider, - { store }, - createElement(Harness, { - workItemShortId: "ORG-42", - onStartAgent, - }) - ) - ); - }); - - expect(onStartAgent).toHaveBeenCalledTimes(1); - expect(store.get(pendingChatPanelWorkItemActionAtom)).toBeNull(); - - act(() => { - root.render( - createElement( - Provider, - { store }, - createElement(Harness, { - workItemShortId: "ORG-42", - onStartAgent, - }) - ) - ); - }); - expect(onStartAgent).toHaveBeenCalledTimes(1); - }); - - it("leaves a request pending for its owning Work Item", () => { - const store = createStore(); - const onStartAgent = vi.fn(); - const request = store.set(requestChatPanelWorkItemActionAtom, { - workItemShortId: "ORG-42", - action: "start_agent", - }); - - act(() => { - root.render( - createElement( - Provider, - { store }, - createElement(Harness, { - workItemShortId: "ORG-43", - onStartAgent, - }) - ) - ); - }); - - expect(onStartAgent).not.toHaveBeenCalled(); - expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request); - }); -}); diff --git a/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts b/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts deleted file mode 100644 index c7c548ce92..0000000000 --- a/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { useEffect } from "react"; - -import { - consumeChatPanelWorkItemActionAtom, - pendingChatPanelWorkItemActionAtom, -} from "@src/store/chatPanel/chatPanelTabsAtom"; - -interface UsePendingWorkItemActionOptions { - workItemShortId: string; - onStartAgent: () => void | Promise; -} - -export function usePendingWorkItemAction({ - workItemShortId, - onStartAgent, -}: UsePendingWorkItemActionOptions): void { - const pendingWorkItemAction = useAtomValue( - pendingChatPanelWorkItemActionAtom - ); - const consumeWorkItemAction = useSetAtom(consumeChatPanelWorkItemActionAtom); - - useEffect(() => { - if ( - pendingWorkItemAction?.workItemShortId !== workItemShortId || - pendingWorkItemAction.action !== "start_agent" - ) { - return; - } - - const consumedRequest = consumeWorkItemAction(pendingWorkItemAction); - if (!consumedRequest) return; - void onStartAgent(); - }, [ - consumeWorkItemAction, - onStartAgent, - pendingWorkItemAction, - workItemShortId, - ]); -} diff --git a/src/engines/SessionCore/__tests__/rustTsContract.test.ts b/src/engines/SessionCore/__tests__/rustTsContract.test.ts index d611fef65a..aa94517a71 100644 --- a/src/engines/SessionCore/__tests__/rustTsContract.test.ts +++ b/src/engines/SessionCore/__tests__/rustTsContract.test.ts @@ -45,7 +45,7 @@ const RUST_CANONICAL_TOOLS = [ "manage_todo", "setup_repo", "ask_user_questions", - // Project + // Project (retired tools — kept for historical transcript rendering) "manage_story", "manage_work_item", // Web diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/types.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/types.ts index c589f6d071..1e8cd966c3 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/types.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/types.ts @@ -5,7 +5,7 @@ import type { ChangeEvent, MutableRefObject, RefObject } from "react"; import type { AgentInfo, ProviderInfo } from "@src/api/http/config"; import type { ComposerInputRef } from "@src/components/ComposerInput"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import type { AdvancedConfig, UploadedFile, @@ -65,8 +65,9 @@ export interface UseSessionCreatorReturn { handleSlashCommand: (query: string) => void; handleSlashCommandClose: () => void; handleSlashSelect: (item: SlashItem) => void; - handleModeSelect: (mode: AgentExecMode) => void; - currentMode: AgentExecMode; + handleModeSelect: (mode: ComposerModeEntry["id"]) => void; + currentMode: ComposerModeEntry["id"]; + includeProjectMode: boolean; filteredSlashItems: SlashItem[]; slashLoading: boolean; prefetchSlashItems: (query: string) => void; diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts index 8e9b0e6e25..590f9c846b 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionCreator.ts @@ -207,6 +207,7 @@ export function useSessionCreator( handleSlashSelect: baseHandleSlashSelect, handleModeSelect, currentMode, + includeProjectMode, filteredSlashItems: baseFilteredSlashItems, slashLoading, prefetchSlashItems, @@ -490,6 +491,7 @@ export function useSessionCreator( handleSlashSelect, handleModeSelect, currentMode, + includeProjectMode, filteredSlashItems, slashLoading, prefetchSlashItems, diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx index 0264aab8ce..69eaeea14b 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx @@ -36,6 +36,7 @@ import { } from "@src/store/session"; import { lastUserMessageAtom } from "@src/store/session/cliSessionStatusAtom"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; +import { creatorDefaultProductModeAtom } from "@src/store/session/creatorDefaultProductModeAtom"; import { runningLocationAtom } from "@src/store/session/runningLocationAtom"; import { worktreeLaunchSelectionAtom } from "@src/store/session/worktreeLaunchSourceAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; @@ -101,6 +102,7 @@ export function useSessionLaunch( const selectedAgentDefId = useAtomValue(selectedAgentDefinitionIdAtom); const selectedAgentOrgId = useAtomValue(selectedAgentOrgIdAtom); const agentExecMode = useAtomValue(creatorDefaultExecModeAtom); + const creatorProductMode = useAtomValue(creatorDefaultProductModeAtom); const runningLocation = useAtomValue(runningLocationAtom); const worktreeLaunchSelection = useAtomValue(worktreeLaunchSelectionAtom); const workspaceFolders = useAtomValue(workspaceFoldersAtom); @@ -212,6 +214,13 @@ export function useSessionLaunch( const result = await sessionLaunch({ ...launchParams, + // Creator-selected Project mode (§5.2): stamp the product axis on + // kinds that carry it. An explicit work-item context wins below. + ...(creatorProductMode && + !resolvedWorkItemContext?.productMode && + (dispatchCategory === "rust_agent" || dispatchCategory === "cli_agent") + ? { productMode: creatorProductMode } + : {}), ...(resolvedWorkItemContext ? { orgId: resolvedWorkItemContext.orgId, @@ -229,6 +238,9 @@ export function useSessionLaunch( resolvedWorkItemContext.agentDefinitionId, } : {}), + ...(resolvedWorkItemContext.agentExecMode + ? { mode: resolvedWorkItemContext.agentExecMode } + : {}), agentRole: resolvedWorkItemContext.agentRole, projectSlug: resolvedWorkItemContext.projectSlug, } @@ -337,6 +349,7 @@ export function useSessionLaunch( editorContent, t, guardAgainstSecrets, + creatorProductMode, effectiveSource, composerInputRef, launchMode, diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts index 17a80c21fb..e50fc4ec1c 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts @@ -358,6 +358,9 @@ export function buildSessionFromLaunchResult(options: { ...((result.agentRole ?? launchOrgContext?.agentRole) ? { agentRole: result.agentRole ?? launchOrgContext?.agentRole } : {}), + ...((result.productMode ?? launchOrgContext?.productMode) + ? { productMode: result.productMode ?? launchOrgContext?.productMode } + : {}), ...(result.background ? { background: true } : {}), ...(result.worktreePath ? { worktreePath: result.worktreePath } : {}), ...(result.worktreeBranch ? { worktreeBranch: result.worktreeBranch } : {}), diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/types.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/types.ts index 185d16bee4..679e6da902 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/types.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/types.ts @@ -23,17 +23,24 @@ export interface SessionLaunchWorkItemContext extends Partial; } diff --git a/src/features/SessionCreator/components/EditorArea.tsx b/src/features/SessionCreator/components/EditorArea.tsx index afe681b309..d82c289937 100644 --- a/src/features/SessionCreator/components/EditorArea.tsx +++ b/src/features/SessionCreator/components/EditorArea.tsx @@ -17,7 +17,7 @@ import ComposerShell from "@src/components/ComposerShell"; import Message from "@src/components/Message"; import { VoiceInputButton, VoiceRecordingBar } from "@src/components/Voice"; import { capPillText, storePillText } from "@src/config/pillTokens"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import ContextMenuPortal from "@src/engines/ChatPanel/InputArea/components/ContextMenuPortal"; import SlashCommandPortal from "@src/engines/ChatPanel/InputArea/components/SlashCommandPortal"; import { type VoiceInputError, useVoiceInput } from "@src/hooks/voice"; @@ -158,8 +158,9 @@ export interface EditorAreaProps { onSlashCommand?: (query: string) => void; onSlashCommandClose?: () => void; onSlashSelect?: (item: SlashItem) => void; - onModeSelect?: (mode: AgentExecMode) => void; - currentMode?: AgentExecMode; + onModeSelect?: (mode: ComposerModeEntry["id"]) => void; + currentMode?: ComposerModeEntry["id"]; + includeProjectMode?: boolean; filteredSlashItems?: SlashItem[]; slashLoading?: boolean; /** Fetch+filter slash items without opening the inline "/" menu. */ @@ -223,6 +224,7 @@ const EditorArea: React.FC = ({ onSlashCommandClose, onSlashSelect, onModeSelect, + includeProjectMode, currentMode = "build", filteredSlashItems = [], slashLoading = false, @@ -615,6 +617,7 @@ const EditorArea: React.FC = ({ items={filteredSlashItems} loading={slashLoading} currentMode={currentMode} + includeProjectMode={includeProjectMode} searchQuery={slashQuery} onClose={onSlashCommandClose ?? handlePlusSlashClose} onSelect={(item) => onSlashSelect?.(item)} @@ -635,6 +638,7 @@ const EditorArea: React.FC = ({ items={filteredSlashItems} loading={slashLoading} currentMode={currentMode} + includeProjectMode={includeProjectMode} searchQuery={plusSlashQuery} onClose={handlePlusSlashClose} onSelect={(item) => { diff --git a/src/features/SessionCreator/variants/ChatPanel/index.tsx b/src/features/SessionCreator/variants/ChatPanel/index.tsx index 0985495263..dab34da40e 100644 --- a/src/features/SessionCreator/variants/ChatPanel/index.tsx +++ b/src/features/SessionCreator/variants/ChatPanel/index.tsx @@ -215,6 +215,7 @@ const SessionCreatorChatPanelContent: React.FC< handleSlashSelect, handleModeSelect, currentMode, + includeProjectMode, filteredSlashItems, slashLoading, } = useSessionCreator({ @@ -488,6 +489,7 @@ const SessionCreatorChatPanelContent: React.FC< onSlashSelect: handleSlashSelect, onModeSelect: handleModeSelect, currentMode, + includeProjectMode: isHumanMode ? false : includeProjectMode, filteredSlashItems, slashLoading, dropdownDirection, diff --git a/src/hooks/geo/useTimezoneSelect.ts b/src/hooks/geo/useTimezoneSelect.ts index 36e601cead..2a00f83ae2 100644 --- a/src/hooks/geo/useTimezoneSelect.ts +++ b/src/hooks/geo/useTimezoneSelect.ts @@ -147,9 +147,32 @@ export function useTimezoneSelect({ null ); - const baseOptions = excludeAuto - ? TIMEZONE_OPTIONS.filter((opt) => opt.value !== "auto") - : TIMEZONE_OPTIONS; + const baseOptions = useMemo(() => { + const configuredOptions = excludeAuto + ? TIMEZONE_OPTIONS.filter((option) => option.value !== "auto") + : TIMEZONE_OPTIONS; + if ( + value === "auto" || + value === "utc" || + configuredOptions.some((option) => option.value === value) + ) { + return configuredOptions; + } + + const city = value.split("/").at(-1)?.replace(/_/g, " ") || value; + const { offset, offsetMinutes } = getTimezoneOffset(value); + return [ + ...configuredOptions, + { + value, + label: city, + labelKey: value.toLowerCase().replace(/\//g, "_"), + offset, + offsetMinutes, + region: value.split("/")[0], + }, + ]; + }, [excludeAuto, value]); /** Resolve localized label for a timezone item */ const getLocalizedLabel = useCallback( diff --git a/src/hooks/input/useComposerInput.ts b/src/hooks/input/useComposerInput.ts index a07a77e0eb..61eb74279d 100644 --- a/src/hooks/input/useComposerInput.ts +++ b/src/hooks/input/useComposerInput.ts @@ -22,7 +22,7 @@ import { } from "@src/components/ComposerInput"; import { getTerminalBuffer } from "@src/components/TerminalInteractive/bufferCache"; import { storePillText } from "@src/config/pillTokens"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; import { useSlashCommand } from "@src/engines/ChatPanel/hooks/useInputArea/useSlashCommand"; import { referenceInsertText } from "@src/features/Org2Cloud/referenceInsertText"; import type { SlashItem } from "@src/types/extensions"; @@ -160,8 +160,9 @@ export interface UseComposerInputReturn { handleSlashCommand: (query: string) => void; handleSlashCommandClose: () => void; handleSlashSelect: (item: SlashItem) => void; - handleModeSelect: (mode: AgentExecMode) => void; - currentMode: AgentExecMode; + handleModeSelect: (mode: ComposerModeEntry["id"]) => void; + currentMode: ComposerModeEntry["id"]; + includeProjectMode: boolean; filteredSlashItems: SlashItem[]; slashLoading: boolean; prefetchSlashItems: (query: string) => void; @@ -218,6 +219,7 @@ export function useComposerInput( handleSlashSelect, handleModeSelect, currentMode, + includeProjectMode, filteredItems: filteredSlashItems, slashLoading, prefetchItems: prefetchSlashItems, @@ -515,6 +517,7 @@ export function useComposerInput( handleSlashSelect, handleModeSelect, currentMode, + includeProjectMode, filteredSlashItems, slashLoading, prefetchSlashItems, diff --git a/src/hooks/navigation/index.ts b/src/hooks/navigation/index.ts index 98ed3b6949..45f54324cd 100644 --- a/src/hooks/navigation/index.ts +++ b/src/hooks/navigation/index.ts @@ -8,3 +8,5 @@ export type { } from "./useAppNavigation"; export { useWizardParam } from "./useWizardParam"; export type { UseWizardParamReturn } from "./useWizardParam"; +export { useRoutineResultNavigation } from "./useRoutineResultNavigation"; +export type { RoutineResultTarget } from "./useRoutineResultNavigation"; diff --git a/src/hooks/navigation/useRoutineResultNavigation.test.ts b/src/hooks/navigation/useRoutineResultNavigation.test.ts new file mode 100644 index 0000000000..eb8528ea0a --- /dev/null +++ b/src/hooks/navigation/useRoutineResultNavigation.test.ts @@ -0,0 +1,83 @@ +import { Provider } from "jotai"; +import React from "react"; +import { renderToString } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROUTES } from "@src/config/routes"; +import { chatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; +import { createInstrumentedStore } from "@src/util/core/state/instrumentedStore"; + +import { useRoutineResultNavigation } from "./useRoutineResultNavigation"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + readStandaloneWorkItem: vi.fn(), + workItemDataToUI: vi.fn(), +})); + +vi.mock("react-router-dom", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => mocks.navigate, +})); + +vi.mock("@src/api/http/project", () => ({ + projectApi: { + readStandaloneWorkItem: mocks.readStandaloneWorkItem, + }, + workItemDataToUI: mocks.workItemDataToUI, +})); + +describe("useRoutineResultNavigation", () => { + beforeEach(() => { + mocks.navigate.mockReset(); + mocks.readStandaloneWorkItem.mockReset(); + mocks.workItemDataToUI.mockReset(); + }); + + it("opens a standalone Work Item in My Station and navigates there", async () => { + const storedWorkItem = { + frontmatter: { + id: "work-item-1", + short_id: "WI-0001", + title: "Routine result", + }, + body: "", + filename: "WI-0001.md", + }; + const uiWorkItem = { + session_id: "WI-0001", + name: "Routine result", + }; + mocks.readStandaloneWorkItem.mockResolvedValue(storedWorkItem); + mocks.workItemDataToUI.mockReturnValue(uiWorkItem); + + const store = createInstrumentedStore(); + let openResult: ReturnType | undefined; + + function HookProbe(): null { + // Test probe: capture the hook API synchronously from server rendering. + // eslint-disable-next-line react-hooks/globals + openResult = useRoutineResultNavigation(); + return null; + } + + renderToString( + React.createElement(Provider, { store }, React.createElement(HookProbe)) + ); + + await openResult?.({ workItemId: "WI-0001" }); + + expect(mocks.readStandaloneWorkItem).toHaveBeenCalledWith("WI-0001"); + expect(store.get(stationModeAtom)).toBe("my-station"); + expect(store.get(chatPanelTabsAtom)).toMatchObject({ + tabs: expect.arrayContaining([ + expect.objectContaining({ + type: "work-item", + workItem: expect.objectContaining({ shortId: "WI-0001" }), + }), + ]), + }); + expect(mocks.navigate).toHaveBeenCalledWith(ROUTES.workStation.base.path); + }); +}); diff --git a/src/hooks/navigation/useRoutineResultNavigation.ts b/src/hooks/navigation/useRoutineResultNavigation.ts new file mode 100644 index 0000000000..59327e6be1 --- /dev/null +++ b/src/hooks/navigation/useRoutineResultNavigation.ts @@ -0,0 +1,112 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { projectApi, workItemDataToUI } from "@src/api/http/project"; +import { ROUTES } from "@src/config/routes"; +import { createLogger } from "@src/hooks/logger"; +import { + openOrFocusSessionInChatPanelTabAtom, + openWorkItemInChatPanelTabAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { sessionsAtom } from "@src/store/session"; +import { activeStationChatVisibleAtom } from "@src/store/ui/chatPanelAtom"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; + +const log = createLogger("RoutineResultNavigation"); +const EMPTY_RELATION_MAPS = { + labelMap: new Map(), + memberMap: new Map(), +}; + +export interface RoutineResultTarget { + sessionId?: string; + workItemId?: string; + projectSlug?: string; +} + +/** Open a durable Routine result in My Station. */ +export function useRoutineResultNavigation(): ( + target: RoutineResultTarget +) => Promise { + const navigate = useNavigate(); + const sessions = useAtomValue(sessionsAtom); + const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); + const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + const setStationMode = useSetAtom(stationModeAtom); + const setStationChatVisible = useSetAtom(activeStationChatVisibleAtom); + + return useCallback( + async (target: RoutineResultTarget) => { + setStationMode("my-station"); + setStationChatVisible("my-station", true); + + if (target.sessionId) { + const session = sessions.find( + (candidate) => candidate.session_id === target.sessionId + ); + openSession({ + sessionId: target.sessionId, + sessionName: session?.name, + repoPath: session?.repoPath, + }); + navigate(ROUTES.workStation.base.path); + return; + } + + if (!target.workItemId) return; + + try { + if (target.projectSlug) { + const [projectResult, workItemResult] = await Promise.allSettled([ + projectApi.readProject(target.projectSlug), + projectApi.readWorkItem(target.projectSlug, target.workItemId), + ]); + if (workItemResult.status === "rejected") { + throw workItemResult.reason; + } + const project = + projectResult.status === "fulfilled" + ? projectResult.value + : undefined; + openWorkItem({ + workItem: workItemDataToUI( + workItemResult.value, + EMPTY_RELATION_MAPS + ), + shortId: workItemResult.value.frontmatter.short_id, + projectId: project?.meta.id ?? target.projectSlug, + projectSlug: project?.slug ?? target.projectSlug, + projectName: project?.meta.name ?? target.projectSlug, + orgId: project?.meta.org_id, + }); + navigate(ROUTES.workStation.base.path); + return; + } + + const workItem = await projectApi.readStandaloneWorkItem( + target.workItemId + ); + openWorkItem({ + workItem: workItemDataToUI(workItem, EMPTY_RELATION_MAPS), + shortId: workItem.frontmatter.short_id, + projectId: "", + projectSlug: "", + projectName: "Standalone Work Items", + }); + navigate(ROUTES.workStation.base.path); + } catch (error) { + log.warn("Failed to open Routine result", error); + throw error; + } + }, + [ + navigate, + openSession, + openWorkItem, + sessions, + setStationChatVisible, + setStationMode, + ] + ); +} diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index cad8cdf0f7..a7120ef6aa 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -502,6 +502,7 @@ "title": "Agent Workflow", "startAgent": "Start Agent", "startAgentAndChat": "Start Agent & Open Chat", + "noWorkspaceForAgent": "Open a repository workspace before starting an agent on this item.", "running": "Running...", "pendingLaunch": "Pending launch...", "completed": "Completed", @@ -634,7 +635,16 @@ "cycles": "cycles" }, "viewWorkItems": "View work items", - "fromRoutine": "From routine: {{name}}" + "fromRoutine": "From routine: {{name}}", + "subItems": { + "title": "Sub-items", + "parent": "Parent" + }, + "sessionRail": { + "notOnDeviceTitle": "Session not on this device", + "notOnDeviceHint": "This session ran on another device. Its transcript is not synced here.", + "finalOutput": "Final output" + } }, "settings": { "sidebarGeneral": "General", diff --git a/src/i18n/locales/zh-Hant/projects.json b/src/i18n/locales/zh-Hant/projects.json index ee800bd76d..53fcf08719 100644 --- a/src/i18n/locales/zh-Hant/projects.json +++ b/src/i18n/locales/zh-Hant/projects.json @@ -502,6 +502,7 @@ "title": "Agent 工作流", "startAgent": "啓動 Agent", "startAgentAndChat": "啓動 Agent 並打開 Chat", + "noWorkspaceForAgent": "請先開啟一個儲存庫工作區,再對該事項啟動 agent。", "running": "運行中...", "pendingLaunch": "等待啓動...", "completed": "已完成", @@ -634,7 +635,16 @@ "cycles": "輪次" }, "viewWorkItems": "查看 Work Items", - "fromRoutine": "来自 Routine:{{name}}" + "fromRoutine": "来自 Routine:{{name}}", + "subItems": { + "title": "子任務", + "parent": "父任務" + }, + "sessionRail": { + "notOnDeviceTitle": "會話不在此裝置", + "notOnDeviceHint": "該會話在其他裝置上執行,轉錄未同步到本機。", + "finalOutput": "最終輸出" + } }, "settings": { "sidebarGeneral": "常規", diff --git a/src/i18n/locales/zh/projects.json b/src/i18n/locales/zh/projects.json index 6aa49170ec..1b47a4f097 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -503,6 +503,7 @@ "title": "Agent 工作流", "startAgent": "启动 Agent", "startAgentAndChat": "启动 Agent 并打开 Chat", + "noWorkspaceForAgent": "请先打开一个仓库工作区,再对该事项启动 agent。", "running": "运行中...", "pendingLaunch": "等待启动...", "completed": "已完成", @@ -635,7 +636,16 @@ "cycles": "轮次" }, "viewWorkItems": "查看 Work Items", - "fromRoutine": "来自 Routine:{{name}}" + "fromRoutine": "来自 Routine:{{name}}", + "subItems": { + "title": "子任务", + "parent": "父任务" + }, + "sessionRail": { + "notOnDeviceTitle": "会话不在此设备", + "notOnDeviceHint": "该会话在其他设备上运行,转录未同步到本机。", + "finalOutput": "最终输出" + } }, "settings": { "sidebarGeneral": "常规", diff --git a/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx b/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx index 1a62881588..3d90b71561 100644 --- a/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx +++ b/src/modules/MainApp/Integrations/Routines/Table/RoutinesTable.tsx @@ -1,8 +1,10 @@ +import { ExternalLink } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { RoutineDefinition, RoutineFire } from "@src/api/http/project"; import { projectApi } from "@src/api/http/project"; +import Message from "@src/components/Message"; import SettingsTable, { SETTINGS_TABLE_CELL, SETTINGS_TABLE_COL, @@ -10,6 +12,7 @@ import SettingsTable, { } from "@src/components/SettingsTable"; import Switch from "@src/components/Switch"; import TabPill from "@src/components/TabPill"; +import { useRoutineResultNavigation } from "@src/hooks/navigation"; import { DETAIL_PANEL_TOKENS, DetailPanelContainer, @@ -55,20 +58,38 @@ const FIRE_STATUS_COLOR: Record = { queued: "bg-warning-6", }; +function getRoutineProjectSlug(routine: RoutineDefinition): string | undefined { + if (routine.outputPolicy.mode === "create_work_item") { + return routine.outputPolicy.createWorkItemProjectSlug; + } + if (routine.outputPolicy.mode === "update_existing_work_item") { + return routine.outputPolicy.updateWorkItemProjectSlug; + } + return undefined; +} + /** Expanded-row fire history list, lazily fetched per routine. */ -const RoutineFireHistory: React.FC<{ routineId: string }> = ({ routineId }) => { +const RoutineFireHistory: React.FC<{ routine: RoutineDefinition }> = ({ + routine, +}) => { const { t } = useTranslation("integrations"); const [fires, setFires] = useState(null); + const openResult = useRoutineResultNavigation(); useEffect(() => { let cancelled = false; - projectApi.listRoutineFires(routineId).then((result) => { - if (!cancelled) setFires(result); - }); + projectApi + .listRoutineFires(routine.id) + .then((result) => { + if (!cancelled) setFires(result); + }) + .catch(() => { + if (!cancelled) setFires([]); + }); return () => { cancelled = true; }; - }, [routineId]); + }, [routine.id, routine.lastFireAt]); if (fires === null) return null; if (fires.length === 0) { @@ -82,12 +103,12 @@ const RoutineFireHistory: React.FC<{ routineId: string }> = ({ routineId }) => { return (
{fires.slice(0, 20).map((fire) => (
= ({ routineId }) => { {new Date(fire.firedAt).toLocaleString()} {fire.sessionId && ( - {fire.sessionId} + )} {fire.workItemId && ( - {fire.workItemId} + + )} + {fire.error && ( + + {fire.error} + )}
))} @@ -111,7 +172,7 @@ const RoutineFireHistory: React.FC<{ routineId: string }> = ({ routineId }) => { function getTriggerLabel(routine: RoutineDefinition): string { if (routine.trigger.kind === "one_time") return `One-time: ${routine.trigger.at}`; - return `Cron: ${routine.trigger.cron}`; + return `Cron: ${routine.trigger.cron} · ${routine.trigger.timezone}`; } function getNextFireLabel(routine: RoutineDefinition): string | null { @@ -193,16 +254,38 @@ export const RoutinesTable: React.FC = ({ ), }, { - key: "target", - label: t("routineFields.target"), + key: "lastRun", + label: t("common:schedule.lastRun"), width: SETTINGS_TABLE_COL.valueLg, sorter: (rowA, rowB) => - getRoutineTargetLabel(rowA).localeCompare( - getRoutineTargetLabel(rowB) - ), + (rowA.lastFireAt ?? "").localeCompare(rowB.lastFireAt ?? ""), + renderCell: (routine) => ( +
+ {routine.lastFireStatus ? ( + + ) : ( + + )} + {routine.lastFireAt && ( + + {new Date(routine.lastFireAt).toLocaleString()} + + )} +
+ ), + }, + { + key: "nextRun", + label: t("routineFields.nextFire"), + width: SETTINGS_TABLE_COL.valueLg, + sorter: (rowA, rowB) => + (rowA.nextFireAt ?? "").localeCompare(rowB.nextFireAt ?? ""), renderCell: (routine) => ( - {getRoutineTargetLabel(routine)} + {getNextFireLabel(routine) ?? "—"} ), }, @@ -267,11 +350,11 @@ export const RoutinesTable: React.FC = ({ columns={routinesColumns} rows={filteredRoutines} getRowKey={(routine) => routine.id} - onRowClick={(routine) => - onSelectRoutine( - selectedRowId === routine.id ? null : routine.id - ) - } + onRowClick={(routine) => { + const isExpanded = expandedKeys.includes(routine.id); + setExpandedKeys(isExpanded ? [] : [routine.id]); + onSelectRoutine(isExpanded ? null : routine.id); + }} rowClassName={selectedRowClassName( (routine: RoutineDefinition) => routine.id, selectedRowId @@ -356,7 +439,7 @@ export const RoutinesTable: React.FC = ({ label={t("routineFields.fireHistory")} layout="vertical" > - + } diff --git a/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts b/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts index 8ba590cc00..6a2852f635 100644 --- a/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts +++ b/src/modules/MainApp/Integrations/hooks/useRoutinesState.ts @@ -1,12 +1,14 @@ import { listen } from "@tauri-apps/api/event"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { type RoutineDefinition, invalidateProjectCache, projectApi, } from "@src/api/http/project"; +import Message from "@src/components/Message"; import { WIZARD_IDS } from "@src/config/mainAppPaths"; import { useWizardParam } from "@src/hooks/navigation"; import { @@ -34,6 +36,7 @@ export function useRoutinesState( category: IntegrationCategory, setDetailMode: (mode: DetailMode) => void ): UseRoutinesStateReturn { + const { t } = useTranslation("integrations"); const routinesActive = category === "routines"; const [routines, setRoutines] = useState([]); const [routinesLoading, setRoutinesLoading] = useState(false); @@ -160,9 +163,36 @@ export function useRoutinesState( const handleFire = useCallback(async () => { if (!selectedRoutine) return; - await projectApi.fireRoutine(selectedRoutine.id); - await refreshRoutines(); - }, [selectedRoutine, refreshRoutines]); + try { + const result = await projectApi.fireRoutine(selectedRoutine.id); + await refreshRoutines(); + if ( + result.fire.status === "queued" || + result.fire.status === "coalesced" || + result.fire.status === "skipped" + ) { + Message.info( + t("routineFields.fireAccepted", { + defaultValue: `Run ${result.fire.status}`, + }) + ); + } else { + Message.success( + t("routineFields.fireStarted", { + defaultValue: "Routine run started", + }) + ); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + Message.error( + t("routineFields.fireError", { + defaultValue: `Could not start the Routine: ${detail}`, + }), + 5000 + ); + } + }, [selectedRoutine, refreshRoutines, t]); const openNewRoutineWizard = useCallback(() => { openWizard(WIZARD_IDS.ROUTINE_ADD); diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts index 48d2706d79..cdbe42ff88 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts @@ -126,7 +126,6 @@ vi.mock("../useTeamInboxGitHubIssue", () => ({ vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({ WorkItemThreadSurface: ({ workItem, - onStartAgent, onOpenSession, propertyProps, onUpdateWorkItem, @@ -135,7 +134,6 @@ vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({ ...threadProps }: { workItem: WorkItem; - onStartAgent?: () => void; onOpenSession?: (sessionId: string) => void; propertyProps?: Record; onUpdateWorkItem?: (updates: Partial) => void; @@ -164,15 +162,6 @@ vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({ "data-property-configured": "true", }) : null, - createElement( - "button", - { - type: "button", - "data-testid": "start-agent", - onClick: onStartAgent, - }, - "Start Agent" - ), createElement( "button", { @@ -269,31 +258,6 @@ describe("AssignedWorkItemDetail navigation actions", () => { Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); }); - it("requests canonical Work Item start instead of mounting an Inbox orchestrator", () => { - const onNavigate = vi.fn(); - act(() => { - root.render( - createElement(AssignedWorkItemDetail, { - item, - onNavigate, - }) - ); - }); - - act(() => { - container - .querySelector("[data-testid='start-agent']") - ?.click(); - }); - - expect(onNavigate).toHaveBeenCalledWith({ - kind: "open_work_item", - projectId: "project-1", - workItemId: "work-item-1", - action: "start_agent", - }); - }); - it("shows separate browser and Work Item actions for GitHub-backed items", async () => { const onNavigate = vi.fn(); const githubItem: AssignedWorkItem = { diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx index 5100b4e7a9..b107324c5f 100644 --- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx +++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx @@ -188,18 +188,6 @@ const AssignedWorkItemThread: React.FC = ({ githubIssueInteraction={ isGitHubIssue ? githubIssue.interaction : undefined } - onStartAgent={ - onNavigate - ? () => - onNavigate({ - kind: "open_work_item", - orgId: item.target.orgId, - projectId: item.target.projectId, - workItemId: item.target.workItemId, - action: "start_agent", - }) - : undefined - } onOpenSession={ onNavigate ? (sessionId) => diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts index 13d7ce4b31..7767d93955 100644 --- a/src/modules/MainApp/TeamInbox/domain/types.ts +++ b/src/modules/MainApp/TeamInbox/domain/types.ts @@ -235,5 +235,4 @@ export type TeamInboxNavigationIntent = orgId?: string; projectId: string; workItemId: string; - action?: "start_agent"; }; diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts index 63e05af1b6..b4d6f6963a 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts @@ -11,7 +11,6 @@ import { createLogger } from "@src/hooks/logger"; import { openOrFocusSessionInChatPanelTabAtom, openWorkItemInChatPanelTabAtom, - requestChatPanelWorkItemActionAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; import { sessionsAtom } from "@src/store/session"; @@ -26,7 +25,6 @@ export function useTeamInboxNavigation(): ( const sessions = useAtomValue(sessionsAtom); const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); - const requestWorkItemAction = useSetAtom(requestChatPanelWorkItemActionAtom); return useCallback( (intent: TeamInboxNavigationIntent) => { @@ -70,12 +68,6 @@ export function useTeamInboxNavigation(): ( t("teamInbox.detail.standaloneProject"), orgId: project?.meta.org_id ?? intent.orgId, }); - if (intent.action) { - requestWorkItemAction({ - workItemShortId: shortId, - action: intent.action, - }); - } }; if (!intent.projectId) { @@ -116,6 +108,6 @@ export function useTeamInboxNavigation(): ( log.warn("Failed to open project Team Inbox Work Item", error); }); }, - [openSession, openWorkItem, requestWorkItemAction, sessions, t] + [openSession, openWorkItem, sessions, t] ); } diff --git a/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx b/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx index 3b66f67fcb..821de0551b 100644 --- a/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx +++ b/src/modules/MainApp/WorkManagement/RoutineRunsSurface.tsx @@ -18,6 +18,8 @@ import { projectApi, } from "@src/api/http/project"; import Button from "@src/components/Button"; +import Message from "@src/components/Message"; +import { useRoutineResultNavigation } from "@src/hooks/navigation"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; const STATUS_TONE: Record = { @@ -41,9 +43,27 @@ interface RunRowProps { } const RunRow: React.FC = ({ run }) => { + const { t } = useTranslation("sessions"); const [expanded, setExpanded] = useState(false); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState(null); + const openResult = useRoutineResultNavigation(); + + const openWorkItem = useCallback( + (workItemId: string) => { + void openResult({ + workItemId, + projectSlug: run.scopeId, + }).catch(() => + Message.error( + t("kanban.openRoutineWorkItemError", { + defaultValue: "Could not open the Work Item", + }) + ) + ); + }, + [openResult, run.scopeId, t] + ); const toggle = useCallback(() => { setExpanded((previous) => !previous); @@ -112,19 +132,23 @@ const RunRow: React.FC = ({ run }) => { ) : (
    {detail.workItems.map((item) => ( -
  • - - {item.shortId} - - - {item.title} - - - {item.portableState ?? item.status} - +
  • +
  • ))}
diff --git a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts index 2420d87489..0f809e0007 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts +++ b/src/modules/ProjectManager/ProjectManagerLayout/hooks/useProjectTabActions.ts @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import type { ProjectOrg } from "@src/api/http/project"; import type { QuickAction } from "@src/modules/WorkStation/shared"; import { openCreateTargetInChatPanelStartPageAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { openOrFocusSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { CHAT_PANEL_CREATE_TARGET, activeStationChatVisibleAtom, @@ -34,7 +35,6 @@ import { STORY_ORG_SCOPE, STORY_PERSONAL_ORG_FILTER_ID, STORY_PERSONAL_ORG_NAME, - createChatSessionTab, createProjectDashboardTab, createProjectLinearProjectsTab, createProjectLinearWorkItemsTab, @@ -379,19 +379,30 @@ export function useProjectTabActions({ [activeTab?.id, openTab] ); + const openSessionInChatPanel = useSetAtom( + openOrFocusSessionInChatPanelTabAtom + ); const handleOpenChatSession = useCallback( ( sessionId: string, title?: string, - workItemId?: string, - workItemShortId?: string + _workItemId?: string, + _workItemShortId?: string ) => { - const tabTitle = title || `Chat: ${sessionId.slice(0, 12)}`; - openTab( - createChatSessionTab(sessionId, tabTitle, workItemId, workItemShortId) - ); + // Conversations live in the LEFT chat panel; the station keeps + // showing the item/list the session was opened from. Activating a + // session switches the station to that session's workspace, so the + // tab being read is re-opened there to keep the right pane stable. + // The station chat tab remains reachable only through the explicit + // drag placement gesture (`sessionTabPlacementAtom`). + const pinnedTab = + activeTab?.type === "workItem-detail" ? activeTab : null; + openSessionInChatPanel({ sessionId, sessionName: title }); + if (pinnedTab) { + openTab(pinnedTab); + } }, - [openTab] + [activeTab, openSessionInChatPanel, openTab] ); // --- Sidebar toggle --- diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx deleted file mode 100644 index 2d8a81a2d9..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import { Check, Loader2, RotateCcw } from "lucide-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -import type { - OrchestratorConfig, - OrchestratorPhase, - OrchestratorState, - WorkItemExecutionLock, -} from "@src/api/http/project"; -import Button from "@src/components/Button"; -import InlineAlert from "@src/components/InlineAlert"; -import { Placeholder } from "@src/modules/shared/layouts/blocks"; - -import { ORCHESTRATOR_PHASE } from "../../constants"; - -interface IdleStateProps { - orchestratorConfig?: OrchestratorConfig; - executionLock?: WorkItemExecutionLock | null; - onStartAgent?: (instructions?: string) => void; - isStartingAgent?: boolean; - /** Collab lock held by a teammate (design §16.6): disable + show holder. */ - isLockedByOther?: boolean; - lockHolderName?: string | null; - compact?: boolean; -} - -export const IdleState: React.FC = ({ - orchestratorConfig, - executionLock, - onStartAgent, - isStartingAgent, - isLockedByOther, - lockHolderName, - compact = false, -}) => { - const { t } = useTranslation("projects"); - - const hasActiveExecutionLock = !!executionLock?.activeSessionId; - const canStart = - !!orchestratorConfig?.selected_account_id && - !!orchestratorConfig?.selected_model_id && - !isStartingAgent && - !hasActiveExecutionLock && - !isLockedByOther; - - const startLabel = isLockedByOther - ? t("workItems.agentWorkflow.collabLockRunning", { - name: lockHolderName ?? "", - }) - : isStartingAgent - ? t("workItems.agentWorkflow.running") - : t("workItems.agentWorkflow.startAgent"); - - if (compact) { - return ( -
-
-

- {t("workItems.agentWorkflow.noWorkflowRun")} -

-

- {t("workItems.agentWorkflow.startAgentAndChat")} -

-
- {onStartAgent ? ( - - ) : null} -
- ); - } - - return ( - onStartAgent(), - variant: "primary", - disabled: !canStart, - dataTestId: "work-item-start-agent-button", - } - : undefined - } - /> - ); -}; - -interface ActivePhaseStatusProps { - phase: OrchestratorPhase; - onCancel?: () => void; -} - -export const ActivePhaseStatus: React.FC = ({ - phase, - onCancel, -}) => { - const { t } = useTranslation("projects"); - const phaseLabel = - phase === ORCHESTRATOR_PHASE.Sde || phase === ORCHESTRATOR_PHASE.Coding - ? t("workItems.agentWorkflow.sdePhase") - : phase === ORCHESTRATOR_PHASE.Review - ? t("workItems.agentWorkflow.reviewPhase") - : t("workItems.agentWorkflow.followUpPhase"); - - return ( -
-
- - {phaseLabel} - - {t("workItems.agentWorkflow.running")} - -
- {onCancel && ( - - )} -
- ); -}; - -interface CompletedStateProps { - orchestratorConfig?: OrchestratorConfig; - executionLock?: WorkItemExecutionLock | null; - onStartAgent?: (instructions?: string) => void; - isStartingAgent?: boolean; - /** Collab lock held by a teammate (design §16.6): disable + show holder. */ - isLockedByOther?: boolean; - lockHolderName?: string | null; -} - -export const CompletedState: React.FC = ({ - orchestratorConfig, - executionLock, - onStartAgent, - isStartingAgent, - isLockedByOther, - lockHolderName, -}) => { - const { t } = useTranslation("projects"); - - const hasActiveExecutionLock = !!executionLock?.activeSessionId; - const canStart = - !!orchestratorConfig?.selected_account_id && - !!orchestratorConfig?.selected_model_id && - !isStartingAgent && - !hasActiveExecutionLock && - !isLockedByOther; - - const startLabel = isLockedByOther - ? t("workItems.agentWorkflow.collabLockRunning", { - name: lockHolderName ?? "", - }) - : isStartingAgent - ? t("workItems.agentWorkflow.running") - : t("workItems.agentWorkflow.startAgent"); - - return ( -
-
-
- -
- - {t("workItems.agentWorkflow.completed")} - -
- {onStartAgent && ( - - )} -
- ); -}; - -interface FailedStateProps { - orchestratorState?: OrchestratorState; - onRetry?: () => void; - onCancel?: () => void; -} - -export const FailedState: React.FC = ({ - orchestratorState, - onRetry, - onCancel, -}) => { - const { t } = useTranslation("projects"); - const failure = orchestratorState?.last_failure; - - return ( -
- - {onRetry && ( - - )} - {onCancel && ( - - )} -
- } - > - {failure?.reason && ( -

{failure.reason}

- )} - {orchestratorState && orchestratorState.retry_count > 0 && ( -

- {t("workItems.agentWorkflow.retryWithContext")} ( - {orchestratorState.retry_count}/ - {orchestratorState.active_config?.max_retry_count ?? "?"}) -

- )} - -
- ); -}; - -interface AwaitingUserStateProps { - onRetry?: () => void; - onCancel?: () => void; - onAcceptAsIs?: () => void; - onCreateFollowUp?: () => void; -} - -export const AwaitingUserState: React.FC = ({ - onRetry, - onCancel, - onAcceptAsIs, - onCreateFollowUp, -}) => { - const { t } = useTranslation("projects"); - - return ( -
-

- {t("workItems.agentWorkflow.whatNext")} -

-
- {onRetry && ( - - )} - {onAcceptAsIs && ( - - )} - {onCreateFollowUp && ( - - )} - {onCancel && ( - - )} -
-
- ); -}; - -interface InterruptedStateProps { - onResume?: () => void; - onCancel?: () => void; -} - -export const InterruptedState: React.FC = ({ - onResume, - onCancel, -}) => { - const { t } = useTranslation("projects"); - - return ( - - {onResume && ( - - )} - {onCancel && ( - - )} - - } - > - {t("workItems.agentWorkflow.interruptedMessage")} - - ); -}; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PipelineStepper/StepDot.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PipelineStepper/StepDot.tsx deleted file mode 100644 index 6642821871..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PipelineStepper/StepDot.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Check, Loader2, XCircle } from "lucide-react"; -import React from "react"; - -export type StepStatus = "done" | "active" | "pending" | "error" | "warning"; - -const StepDot: React.FC<{ status: StepStatus }> = ({ status }) => { - switch (status) { - case "done": - return ( -
- -
- ); - case "active": - return ( -
- -
- ); - case "error": - return ( -
- -
- ); - case "warning": - return ( -
-
-
- ); - default: - return ( -
-
-
- ); - } -}; - -export default StepDot; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PipelineStepper/index.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PipelineStepper/index.tsx deleted file mode 100644 index 6f53efc278..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PipelineStepper/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; - -import type { OrchestratorPhase } from "@src/api/http/project"; - -import StepDot, { type StepStatus } from "./StepDot"; - -const PHASE_ORDER: OrchestratorPhase[] = ["sde", "review", "completed"]; - -interface PipelineStepperProps { - currentPhase: OrchestratorPhase; - cycleCount?: number; -} - -const PipelineStepper: React.FC = ({ - currentPhase, - cycleCount = 0, -}) => { - const { t } = useTranslation("projects"); - const phaseLabels: Record = useMemo( - () => ({ - sde: t("workItems.agentWorkflow.sdePhase"), - review: t("workItems.agentWorkflow.reviewPhase"), - completed: t("workItems.agentWorkflow.completed"), - }), - [t] - ); - - const getStepStatus = (step: OrchestratorPhase): StepStatus => { - if (currentPhase === "failed") { - const currentIdx = PHASE_ORDER.indexOf(PHASE_ORDER[0]); - const stepIdx = PHASE_ORDER.indexOf(step); - if (stepIdx < currentIdx) return "done"; - if (stepIdx === currentIdx) return "error"; - return "pending"; - } - if (currentPhase === "awaiting_user") { - if (step === "sde") return "done"; - if (step === "review") return "warning"; - return "pending"; - } - if (currentPhase === "completed") return "done"; - const currentIdx = PHASE_ORDER.indexOf(currentPhase); - const stepIdx = PHASE_ORDER.indexOf(step); - if (stepIdx < currentIdx) return "done"; - if (stepIdx === currentIdx) return "active"; - return "pending"; - }; - - return ( -
- {PHASE_ORDER.map((step, idx) => { - const status = getStepStatus(step); - const isLast = idx === PHASE_ORDER.length - 1; - return ( - -
- - - {phaseLabels[step]} - -
- {!isLast && ( -
-
-
- )} - - ); - })} - {cycleCount > 1 && ( - - {t("workItems.agentWorkflow.cycles", { count: cycleCount })} - - )} -
- ); -}; - -export default PipelineStepper; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionOutputPreview.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionOutputPreview.tsx deleted file mode 100644 index 58335b3456..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionOutputPreview.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { ChevronRight } from "lucide-react"; -import React, { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { Placeholder } from "@src/modules/shared/layouts/blocks"; - -import { useSessionMessages } from "./hooks/useSessionMessages"; -import { STATUS_I18N_KEYS } from "./types"; -import { formatMessageLine } from "./utils"; - -interface SessionOutputPreviewProps { - sessionId: string; - isRunning: boolean; - onSessionComplete?: () => void; - onStatusChange?: (status: string) => void; - onSubAgentChange?: () => void; - defaultCollapsed?: boolean; -} - -const SessionOutputPreview: React.FC = ({ - sessionId, - isRunning, - onSessionComplete, - onStatusChange, - onSubAgentChange, - defaultCollapsed = false, -}) => { - const { t } = useTranslation("projects"); - const [collapsed, setCollapsed] = useState(defaultCollapsed); - const scrollRef = useRef(null); - - const { messages, loading, sessionStatus, isTerminalStatus } = - useSessionMessages({ - sessionId, - isRunning, - onSessionComplete, - onStatusChange, - onSubAgentChange, - }); - - useEffect(() => { - if (collapsed) return; - const el = scrollRef.current; - if (!el) return; - const rafId = requestAnimationFrame(() => { - el.scrollTop = el.scrollHeight; - }); - return () => cancelAnimationFrame(rafId); - }, [messages, collapsed]); - - if (loading && messages.length === 0) { - return ( -
- -
- ); - } - - if (messages.length === 0) { - return ( -
- - {t("workItems.agentWorkflow.noOutputYet")} - -
- ); - } - - return ( -
- - - {!collapsed && ( -
- {messages.map((msg) => { - const { icon, label, detail, isSubAgent } = formatMessageLine( - msg, - t - ); - return ( -
- {icon} -
- - {label} - - {detail && ( - {detail} - )} -
-
- ); - })} -
- )} -
- ); -}; - -export default SessionOutputPreview; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/SessionFileRow.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/SessionFileRow.tsx deleted file mode 100644 index 6619b551ab..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/SessionFileRow.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FileCode2 } from "lucide-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { - type SessionFileChange, - TOOL_ICONS, - TOOL_LABEL_I18N_KEYS, -} from "../types"; - -interface SessionFileRowProps { - file: SessionFileChange; -} - -const SessionFileRow: React.FC = ({ file }) => { - const { t } = useTranslation("projects"); - const ToolIcon = TOOL_ICONS[file.tool] ?? FileCode2; - const toolLabel = TOOL_LABEL_I18N_KEYS[file.tool] - ? t(TOOL_LABEL_I18N_KEYS[file.tool]) - : file.tool; - const fileName = file.path.split("/").pop() ?? file.path; - const dirPath = file.path.includes("/") - ? file.path.slice(0, file.path.lastIndexOf("/") + 1) - : ""; - - return ( -
- -
- {dirPath && {dirPath}} - {fileName} -
- - {toolLabel} - {file.count > 1 && ` ×${file.count}`} - -
- ); -}; - -export default SessionFileRow; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/SubAgentRunCard.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/SubAgentRunCard.tsx deleted file mode 100644 index b77da073a9..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/SubAgentRunCard.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; - -import SessionOutputPreview from "../SessionOutputPreview"; -import { STATUS_I18N_KEYS, getStatusStyle } from "../types"; - -interface SubAgentRunCardProps { - sessionId: string; - agentName: string; - instanceNumber: number; - status: string; - isActive: boolean; - totalTokens?: number; -} - -const SubAgentRunCard: React.FC = ({ - sessionId, - agentName, - instanceNumber, - status, - isActive, - totalTokens, -}) => { - const { t } = useTranslation("projects"); - const style = getStatusStyle(status); - const StatusIcon = style.icon; - - return ( -
-
- - - {agentName} #{instanceNumber} - - - {STATUS_I18N_KEYS[status] ? t(STATUS_I18N_KEYS[status]) : status} - - {totalTokens != null && totalTokens > 0 && ( - - {totalTokens.toLocaleString()} {t("workItems.agentWorkflow.tokens")} - - )} -
-
- -
-
- ); -}; - -export default SubAgentRunCard; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/index.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/index.tsx deleted file mode 100644 index 374698a159..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunCard/index.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { - ChevronDown, - ChevronRight, - Files, - SquareArrowOutUpRight, -} from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import type { LinkedSession } from "@src/api/http/project"; - -import SessionOutputPreview from "../SessionOutputPreview"; -import { useSessionFiles } from "../hooks/useSessionFiles"; -import { - STATUS_I18N_KEYS, - type SessionFilesCache, - TERMINAL_STATUS, - getStatusStyle, -} from "../types"; -import SessionFileRow from "./SessionFileRow"; -import SubAgentRunCard from "./SubAgentRunCard"; - -interface SessionRunCardProps { - sessionId: string; - roleLabel: string; - runNumber: number; - status: string; - isActive: boolean; - onOpenSession?: (sessionId: string) => void; - onSessionComplete?: () => void; - onSubAgentChange?: () => void; - subAgentSessions?: LinkedSession[]; - filesCache: React.MutableRefObject; -} - -const SessionRunCard: React.FC = ({ - sessionId, - roleLabel, - runNumber, - status, - isActive, - onOpenSession, - onSessionComplete, - onSubAgentChange, - subAgentSessions, - filesCache, -}) => { - const { t } = useTranslation("projects"); - const [liveStatus, setLiveStatus] = useState(null); - const [showFiles, setShowFiles] = useState(false); - const delayedRefreshRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (delayedRefreshRef.current) clearTimeout(delayedRefreshRef.current); - }; - }, []); - - const handleSessionComplete = useCallback(() => { - onSessionComplete?.(); - if (delayedRefreshRef.current) clearTimeout(delayedRefreshRef.current); - delayedRefreshRef.current = setTimeout(() => { - onSessionComplete?.(); - }, 3000); - }, [onSessionComplete]); - - const displayStatus = - liveStatus && TERMINAL_STATUS.has(liveStatus) ? liveStatus : status; - const style = getStatusStyle(displayStatus); - const StatusIcon = style.icon; - const isTerminal = TERMINAL_STATUS.has(displayStatus); - - const { sessionFiles, filesLoading, loadSessionFiles } = useSessionFiles({ - sessionId, - displayStatus, - isActive, - isTerminal, - filesCache, - }); - - const toggleFiles = useCallback(async () => { - const next = !showFiles; - setShowFiles(next); - if (next && sessionFiles === null && !filesLoading) { - await loadSessionFiles(); - } - }, [showFiles, sessionFiles, filesLoading, loadSessionFiles]); - - const fileCount = sessionFiles?.length ?? 0; - const canShowFiles = - isTerminal || (isActive && sessionFiles && sessionFiles.length > 0); - - return ( -
- {/* Header */} -
- -
- - {roleLabel} #{runNumber} - - - {t(STATUS_I18N_KEYS[displayStatus] ?? displayStatus)} - -
- -
- {canShowFiles && ( - - )} - {onOpenSession && ( - - )} -
-
- - {/* File list */} - {showFiles && ( -
- {filesLoading && ( - - {t("common:status.loading")} - - )} - {sessionFiles && sessionFiles.length === 0 && ( - - {t("workItems.agentWorkflow.noFilesModified")} - - )} - {sessionFiles && sessionFiles.length > 0 && ( -
- {sessionFiles.map((file) => ( - - ))} -
- )} -
- )} - - {/* Hidden live output listener — unmount once terminal status is known */} - {!isTerminal && ( -
- -
- )} - - {/* Sub-agents */} - {subAgentSessions && subAgentSessions.length > 0 && ( -
-
- {t("workItems.agentWorkflow.subAgents")} -
-
- {subAgentSessions.map((sub) => ( - - ))} -
-
- )} -
- ); -}; - -export default SessionRunCard; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunHistory.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunHistory.tsx deleted file mode 100644 index 8c01750d0b..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/SessionRunHistory.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { ChevronDown, ChevronRight, History } from "lucide-react"; -import React, { useCallback, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import type { LinkedSession, OrchestratorPhase } from "@src/api/http/project"; - -import type { AgentRole } from "../../constants"; -import SessionRunCard from "./SessionRunCard"; -import { - ROLE_I18N_KEYS, - type SessionFilesCache, - type SessionRun, -} from "./types"; - -interface SessionRunHistoryProps { - sessionRuns: SessionRun[]; - subAgentsByParent: Map; - activeAgentSessionId?: string | null; - activeAgentRole?: AgentRole | null; - phase: OrchestratorPhase; - showOnlyActive?: boolean; - onOpenSession?: (sessionId: string) => void; - onRefresh?: () => void; - onSessionComplete?: (sessionId: string) => void; -} - -const SessionRunHistory: React.FC = ({ - sessionRuns, - subAgentsByParent, - activeAgentSessionId, - activeAgentRole, - phase, - showOnlyActive = false, - onOpenSession, - onRefresh, - onSessionComplete, -}) => { - const { t } = useTranslation("projects"); - const filesCacheRef = useRef(new Map()); - const hasActiveSession = sessionRuns.some((run) => run.isActive); - const defaultOpen = - hasActiveSession || - phase === "sde" || - phase === "review" || - phase === "follow_up" || - phase === "completed"; - const [isOpen, setIsOpen] = useState(defaultOpen); - - const handleSessionComplete = useCallback( - (sessionId: string) => { - onSessionComplete?.(sessionId); - onRefresh?.(); - }, - [onRefresh, onSessionComplete] - ); - - const visibleRuns = showOnlyActive - ? sessionRuns.filter((run) => run.isActive) - : sessionRuns; - - const totalRuns = visibleRuns.filter( - (run) => run.effectiveId !== "pending" - ).length; - - return ( -
- - - {isOpen && ( -
- {activeAgentSessionId && - !sessionRuns.some( - (run) => run.effectiveId === activeAgentSessionId - ) && ( - - handleSessionComplete(activeAgentSessionId) - } - onSubAgentChange={onRefresh} - filesCache={filesCacheRef} - /> - )} - {visibleRuns.map((run) => { - if (run.effectiveId === "pending") return null; - const roleLabel = t(ROLE_I18N_KEYS[run.role] ?? run.role); - return ( - handleSessionComplete(run.effectiveId) - : undefined - } - onSubAgentChange={run.isActive ? onRefresh : undefined} - subAgentSessions={subAgentsByParent.get(run.effectiveId)} - filesCache={filesCacheRef} - /> - ); - })} -
- )} -
- ); -}; - -export default SessionRunHistory; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/index.ts b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/index.ts deleted file mode 100644 index c44edb78a7..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useSessionFiles } from "./useSessionFiles"; -export { useSessionMessages } from "./useSessionMessages"; -export { useSessionRunsGrouping } from "./useSessionRunsGrouping"; diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionFiles.ts b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionFiles.ts deleted file mode 100644 index 63a0e8f363..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionFiles.ts +++ /dev/null @@ -1,81 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; - -import { getSessionFiles } from "@src/api/tauri/agent"; -import { parseRawSessionEvent } from "@src/engines/SessionCore/core/schemas"; -import { subscribeToSessionEvents } from "@src/engines/SessionCore/sync/useSessionChannel"; - -import { - type SessionFileChange, - type SessionFilesCache, - TERMINAL_STATUS, -} from "../types"; - -interface UseSessionFilesOptions { - sessionId: string; - displayStatus: string; - isActive: boolean; - isTerminal: boolean; - filesCache: React.MutableRefObject; -} - -const EVENT_SETTLE_MS = 250; - -export function useSessionFiles(options: UseSessionFilesOptions) { - const { sessionId, displayStatus, isActive, isTerminal, filesCache } = - options; - - const [sessionFiles, setSessionFiles] = useState( - () => filesCache.current.get(sessionId) ?? null - ); - const [filesLoading, setFilesLoading] = useState(false); - - const loadSessionFiles = useCallback( - async (force = false) => { - const cached = filesCache.current.get(sessionId); - if (cached && !force) { - setSessionFiles(cached); - return; - } - setFilesLoading(true); - try { - const files = (await getSessionFiles( - sessionId - )) as unknown as SessionFileChange[]; - if (TERMINAL_STATUS.has(displayStatus)) { - filesCache.current.set(sessionId, files); - } - setSessionFiles(files); - } catch { - setSessionFiles([]); - } finally { - setFilesLoading(false); - } - }, - [sessionId, displayStatus, filesCache] - ); - - const refreshTimerRef = useRef | null>(null); - useEffect(() => { - if (isTerminal || !isActive) return; - let cancelled = false; - const scheduleRefresh = (raw: string) => { - if (cancelled || document.hidden) return; - const event = parseRawSessionEvent(raw); - if (event.type !== "agent:file_change") return; - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => { - refreshTimerRef.current = null; - if (!cancelled) void loadSessionFiles(true); - }, EVENT_SETTLE_MS); - }; - void loadSessionFiles(true); - const unsubscribe = subscribeToSessionEvents(sessionId, scheduleRefresh); - return () => { - cancelled = true; - unsubscribe(); - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - }; - }, [sessionId, isActive, isTerminal, loadSessionFiles]); - - return { sessionFiles, filesLoading, loadSessionFiles }; -} diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionMessages.ts b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionMessages.ts deleted file mode 100644 index cd8f77fb46..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionMessages.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -import { - loadMessages as agentLoadMessages, - getSession, -} from "@src/api/tauri/agent"; -import { isSubagentSpawningTool } from "@src/engines/SessionCore/sync/adapters/shared"; -import { subscribeToSessionEvents } from "@src/engines/SessionCore/sync/useSessionChannel"; -import { isTerminalStatus } from "@src/types/session/session"; - -import type { AgentMessage } from "../types"; - -const EVENT_SETTLE_MS = 750; -const MAX_TEXT_MESSAGES = 100; -const MAX_TOOL_MESSAGES = 30; -const TEXT_ROLES = new Set(["user", "assistant"]); - -interface UseSessionMessagesOptions { - sessionId: string; - isRunning: boolean; - onSessionComplete?: () => void; - onStatusChange?: (status: string) => void; - onSubAgentChange?: () => void; -} - -export function useSessionMessages(options: UseSessionMessagesOptions) { - const { - sessionId, - isRunning, - onSessionComplete, - onStatusChange, - onSubAgentChange, - } = options; - - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const [sessionStatus, setSessionStatus] = useState(null); - - const refreshTimerRef = useRef | null>(null); - const completionNotifiedRef = useRef(false); - const terminalRef = useRef(false); - const seenSubAgentMsgIdsRef = useRef>(new Set()); - const onSubAgentChangeRef = useRef(onSubAgentChange); - useEffect(() => { - onSubAgentChangeRef.current = onSubAgentChange; - }, [onSubAgentChange]); - - const loadMessages = useCallback(async () => { - try { - const result = (await agentLoadMessages( - sessionId - )) as unknown as AgentMessage[]; - const textMessages = result - .filter((msg) => TEXT_ROLES.has(msg.role)) - .slice(-MAX_TEXT_MESSAGES); - const toolMessages = result - .filter((msg) => !TEXT_ROLES.has(msg.role)) - .slice(-MAX_TOOL_MESSAGES); - const merged = [...textMessages, ...toolMessages].sort( - (msgA, msgB) => msgA.sequence - msgB.sequence - ); - setMessages(merged); - - let hasNew = false; - for (const msg of result) { - if ( - msg.tool_name && - isSubagentSpawningTool(msg.tool_name) && - !seenSubAgentMsgIdsRef.current.has(msg.id) - ) { - seenSubAgentMsgIdsRef.current.add(msg.id); - hasNew = true; - } - } - while (seenSubAgentMsgIdsRef.current.size > 200) { - const firstKey = seenSubAgentMsgIdsRef.current.values().next().value; - if (firstKey) seenSubAgentMsgIdsRef.current.delete(firstKey); - else break; - } - if (hasNew) { - onSubAgentChangeRef.current?.(); - } - } catch { - // Session may not exist yet - } finally { - setLoading(false); - } - }, [sessionId]); - - const checkSessionStatus = useCallback(async () => { - try { - const session = (await getSession(sessionId)) as unknown as { - session_id: string; - status: string; - } | null; - const status = session?.status ?? null; - setSessionStatus(status); - if (status) onStatusChange?.(status); - if (status && isTerminalStatus(status)) { - terminalRef.current = true; - if (!completionNotifiedRef.current) { - completionNotifiedRef.current = true; - onSessionComplete?.(); - } - } - } catch { - // ignore - } - }, [sessionId, onSessionComplete, onStatusChange]); - - useEffect(() => { - let cancelled = false; - completionNotifiedRef.current = false; - terminalRef.current = false; - - const refresh = async () => { - if (cancelled) return; - await loadMessages(); - await checkSessionStatus(); - }; - - void refresh(); - const scheduleRefresh = () => { - if (cancelled || terminalRef.current || document.hidden) return; - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => { - refreshTimerRef.current = null; - void refresh(); - }, EVENT_SETTLE_MS); - }; - const unsubscribe = isRunning - ? subscribeToSessionEvents(sessionId, scheduleRefresh) - : () => undefined; - const handleVisibilityOrFocus = () => { - if (!document.hidden) scheduleRefresh(); - }; - document.addEventListener("visibilitychange", handleVisibilityOrFocus); - window.addEventListener("focus", handleVisibilityOrFocus); - - return () => { - cancelled = true; - unsubscribe(); - document.removeEventListener("visibilitychange", handleVisibilityOrFocus); - window.removeEventListener("focus", handleVisibilityOrFocus); - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - }; - }, [sessionId, isRunning, loadMessages, checkSessionStatus]); - - return { - messages, - loading, - sessionStatus, - isTerminalStatus, - }; -} diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionRunsGrouping.ts b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionRunsGrouping.ts deleted file mode 100644 index 23f3fbc3ef..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/hooks/useSessionRunsGrouping.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useMemo } from "react"; - -import type { LinkedSession } from "@src/api/http/project"; - -import type { SessionRun } from "../types"; - -interface UseSessionRunsGroupingOptions { - linkedSessions: LinkedSession[]; - activeAgentSessionId?: string | null; -} - -export function useSessionRunsGrouping(options: UseSessionRunsGroupingOptions) { - const { linkedSessions, activeAgentSessionId } = options; - - const cycleCount = useMemo( - () => - linkedSessions.filter( - (ls) => ls.agent_role === "review" && ls.session_id !== "pending" - ).length, - [linkedSessions] - ); - - const resolvedActiveSessionId = useMemo(() => { - if (activeAgentSessionId) return activeAgentSessionId; - const linked = linkedSessions.find( - (ls) => ls.status === "running" && ls.session_id !== "pending" - ); - return linked?.session_id ?? null; - }, [activeAgentSessionId, linkedSessions]); - - const { sessionRuns, subAgentsByParent } = useMemo(() => { - const topLevel = linkedSessions.filter((ls) => !ls.parent_session_id); - const subAgentSessions = linkedSessions.filter( - (ls) => !!ls.parent_session_id - ); - - const roleCounts: Record = {}; - const runs: SessionRun[] = topLevel - .filter( - (ls) => - ls.session_id !== "pending" || ls.session_id === activeAgentSessionId - ) - .map((ls) => { - const role = ls.agent_role ?? "sde"; - roleCounts[role] = (roleCounts[role] ?? 0) + 1; - const effectiveId = - ls.session_id === "pending" && activeAgentSessionId - ? activeAgentSessionId - : ls.session_id; - const isActive = - effectiveId === resolvedActiveSessionId && ls.status === "running"; - return { - effectiveId, - role, - runNumber: roleCounts[role], - isActive, - session_id: ls.session_id, - status: ls.status, - }; - }); - - const grouped = new Map(); - for (const sub of subAgentSessions) { - const parentId = sub.parent_session_id!; - const existing = grouped.get(parentId) ?? []; - existing.push(sub); - grouped.set(parentId, existing); - } - - return { sessionRuns: runs, subAgentsByParent: grouped }; - }, [linkedSessions, activeAgentSessionId, resolvedActiveSessionId]); - - const hasRuns = sessionRuns.some((run) => run.effectiveId !== "pending"); - - return { - cycleCount, - resolvedActiveSessionId, - sessionRuns, - subAgentsByParent, - hasRuns, - }; -} diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/index.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/index.tsx deleted file mode 100644 index deab10044b..0000000000 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/index.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { RefreshCw } from "lucide-react"; -import React, { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import type { - LinkedSession, - OrchestratorConfig, - OrchestratorPhase, - OrchestratorState, - ProofOfWork, - WorkItemExecutionLock, -} from "@src/api/http/project"; -import Button from "@src/components/Button"; -import { useRefreshSpin } from "@src/hooks/ui"; -import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared"; -import { SECTION_CONTAINER_CLASSES } from "@src/modules/shared/layouts/SectionLayout/tokens"; -import { CollapsibleSection } from "@src/modules/shared/layouts/blocks"; -import type { WorkItemStatus } from "@src/types/core/workItem"; - -import { - ACTIVE_PHASES, - AGENT_ROLE, - type AgentRole, - ORCHESTRATOR_PHASE, - toAgentRole, -} from "../../constants"; -import { WORK_ITEM_THREAD_TOKENS } from "../WorkItemThread"; -import { - ActivePhaseStatus, - AwaitingUserState, - CompletedState, - FailedState, - IdleState, - InterruptedState, -} from "./PhaseStates"; -import PipelineStepper from "./PipelineStepper"; -import ReviewFeedbackPanel from "./ReviewFeedbackPanel"; -import SessionRunHistory from "./SessionRunHistory"; -import { useSessionRunsGrouping } from "./hooks/useSessionRunsGrouping"; - -const ACTIVE_WORK_ITEM_STATUS: WorkItemStatus = "in_progress"; -const COMPLETED_WORK_ITEM_STATUS: WorkItemStatus = "completed"; -const RUNNING_LINKED_SESSION_STATUS = "running" as const; - -interface AgentWorkflowProps { - orchestratorState?: OrchestratorState; - orchestratorConfig?: OrchestratorConfig; - proofOfWork?: ProofOfWork; - workItemStatus?: WorkItemStatus; - executionLock?: WorkItemExecutionLock | null; - linkedSessions?: LinkedSession[]; - onStartAgent?: (instructions?: string) => void; - isStartingAgent?: boolean; - onRetry?: () => void; - onCancel?: () => void; - onResume?: () => void; - onAcceptAsIs?: () => void; - onCreateFollowUp?: () => void; - onOpenSession?: (sessionId: string, title?: string) => void; - onOpenFileAtLine?: (filePath: string, line?: number) => void; - onRefresh?: () => void; - activeAgentSessionId?: string | null; - activeAgentRole?: AgentRole | null; - /** Collab execution lock held by a teammate (design §16.6). */ - isLockedByOther?: boolean; - lockHolderName?: string | null; - presentation?: "default" | "thread"; -} - -const AgentWorkflow: React.FC = ({ - orchestratorState, - orchestratorConfig, - proofOfWork, - workItemStatus, - executionLock, - linkedSessions = [], - onStartAgent, - isStartingAgent, - onRetry, - onCancel, - onResume, - onAcceptAsIs, - onCreateFollowUp, - onOpenSession, - onOpenFileAtLine, - onRefresh, - activeAgentSessionId, - activeAgentRole, - isLockedByOther, - lockHolderName, - presentation = "default", -}) => { - const { t } = useTranslation("projects"); - const persistedPhase: OrchestratorPhase = - orchestratorState?.current_phase ?? ORCHESTRATOR_PHASE.Idle; - const { cycleCount, sessionRuns, subAgentsByParent, hasRuns } = - useSessionRunsGrouping({ linkedSessions, activeAgentSessionId }); - const [terminalSessionIds, setTerminalSessionIds] = useState>( - () => new Set() - ); - const handleSessionComplete = useCallback( - (sessionId: string) => { - setTerminalSessionIds((current) => { - if (current.has(sessionId)) return current; - const next = new Set(current); - next.add(sessionId); - return next; - }); - onRefresh?.(); - }, - [onRefresh] - ); - const isCompletedWorkItem = workItemStatus === COMPLETED_WORK_ITEM_STATUS; - const runningLinkedSession = linkedSessions.find( - (session) => - session.status === RUNNING_LINKED_SESSION_STATUS && - !terminalSessionIds.has(session.session_id) - ); - const hasTerminalOnlyLinkedSessions = - linkedSessions.length > 0 && !runningLinkedSession; - const activeExecutionLockSessionId = - isCompletedWorkItem || hasTerminalOnlyLinkedSessions - ? null - : (executionLock?.activeSessionId ?? null); - const persistedActiveSessionId = - activeExecutionLockSessionId ?? runningLinkedSession?.session_id ?? null; - const canUseLocalActiveSession = - workItemStatus === ACTIVE_WORK_ITEM_STATUS && - Boolean(activeAgentSessionId) && - !hasTerminalOnlyLinkedSessions; - const displayActiveSessionId = - persistedActiveSessionId ?? - (canUseLocalActiveSession ? activeAgentSessionId : null); - const effectiveActiveSession = linkedSessions.find( - (session) => session.session_id === displayActiveSessionId - ); - const effectiveActiveAgentRole = - toAgentRole(runningLinkedSession?.agent_role) ?? - activeAgentRole ?? - toAgentRole(effectiveActiveSession?.agent_role) ?? - null; - const hasActiveAgentSession = Boolean( - persistedActiveSessionId || canUseLocalActiveSession - ); - const phase: OrchestratorPhase = (() => { - if (hasActiveAgentSession && !ACTIVE_PHASES.has(persistedPhase)) { - if (effectiveActiveAgentRole === AGENT_ROLE.Review) { - return ORCHESTRATOR_PHASE.Review; - } - if (effectiveActiveAgentRole === AGENT_ROLE.FollowUp) { - return ORCHESTRATOR_PHASE.FollowUp; - } - return ORCHESTRATOR_PHASE.Sde; - } - - if (!hasActiveAgentSession && ACTIVE_PHASES.has(persistedPhase)) { - return hasRuns ? ORCHESTRATOR_PHASE.Completed : ORCHESTRATOR_PHASE.Idle; - } - - return persistedPhase; - })(); - const { spinClass, handleClick: handleRefreshClick } = useRefreshSpin( - onRefresh ?? (() => {}), - false - ); - - const titleAction = - phase !== "idle" && onRefresh ? ( - - - ); -}; - -export default StartAgentButton; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts index 6d2c2635bd..62476e8920 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts @@ -31,6 +31,8 @@ const mocks = vi.hoisted(() => ({ vi.mock("@src/api/http/project", () => ({ projectApi: { transitionWorkItemHandoff: mocks.transitionWorkItemHandoff, + readWorkItems: () => Promise.resolve([]), + readStandaloneWorkItems: () => Promise.resolve([]), }, })); @@ -44,6 +46,7 @@ vi.mock("react-i18next", () => ({ vi.mock("@src/hooks/project", () => ({ useWorkItemImageInsert: () => ({ handleImageInsert: vi.fn() }), + useProjectDataChanged: () => undefined, })); vi.mock("@src/components/Avatar", () => ({ @@ -260,9 +263,6 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({ ), })); -vi.mock("../../AgentWorkflow", () => ({ - default: () => createElement("div", { "data-testid": "mock-agent-workflow" }), -})); vi.mock("../../TodoChecklist", () => ({ default: () => null })); vi.mock("../ThreadTodoChecklist", () => ({ default: () => createElement("div", { "data-testid": "mock-thread-todos" }), @@ -886,9 +886,6 @@ describe("WorkItemContent description editing", () => { expect( container.querySelector("[data-testid='mock-thread-todos']") ).not.toBeNull(); - expect( - container.querySelector("[data-testid='mock-agent-workflow']") - ).not.toBeNull(); expect(container.querySelector("[data-testid='mock-activity']")).toBeNull(); act(() => discussionAction?.click()); @@ -911,9 +908,6 @@ describe("WorkItemContent description editing", () => { expect( container.querySelector("[data-testid='mock-thread-todos']") ).toBeNull(); - expect( - container.querySelector("[data-testid='mock-agent-workflow']") - ).toBeNull(); act(() => backAction?.click()); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts index c7db241ab9..8a8ada46fe 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts @@ -7,16 +7,14 @@ describe("resolveWorkItemContentSectionPolicy", () => { expect(resolveWorkItemContentSectionPolicy("default", true)).toEqual({ showTabbedLowerSection: true, showLinkedSessionsTable: true, - showInlineWorkflow: false, showInlineOutput: false, }); }); - it("keeps workflow in Overview without the legacy linked-session table", () => { + it("keeps the linked-session table in Overview without the tab strip", () => { expect(resolveWorkItemContentSectionPolicy("thread", true)).toEqual({ showTabbedLowerSection: false, - showLinkedSessionsTable: false, - showInlineWorkflow: true, + showLinkedSessionsTable: true, showInlineOutput: true, }); }); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.test.ts new file mode 100644 index 0000000000..6e7cfa7392 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import type { LinkedSession } from "@src/api/http/project/types/agentWorkflow"; + +import { + buildDiscussionForwardMessage, + pickForwardTargetSession, +} from "./discussionCommentForward"; + +function linked(overrides: Partial): LinkedSession { + return { + session_id: "sdeagent-x", + session_type: "native", + agent_role: "custom", + started_at: "2026-08-01T00:00:00Z", + status: "completed", + cost_usd: 0, + total_tokens: 0, + ...overrides, + }; +} + +describe("pickForwardTargetSession", () => { + it("returns null with no linked sessions", () => { + expect(pickForwardTargetSession(undefined)).toBeNull(); + expect(pickForwardTargetSession([])).toBeNull(); + }); + + it("picks the latest top-level session by started_at", () => { + const older = linked({ + session_id: "sdeagent-old", + started_at: "2026-08-01T00:00:00Z", + }); + const newer = linked({ + session_id: "sdeagent-new", + started_at: "2026-08-05T00:00:00Z", + }); + expect(pickForwardTargetSession([older, newer])?.session_id).toBe( + "sdeagent-new" + ); + }); + + it("skips sub-agent sessions even when newest", () => { + const parent = linked({ + session_id: "sdeagent-parent", + started_at: "2026-08-01T00:00:00Z", + }); + const sub = linked({ + session_id: "sdeagent-sub", + started_at: "2026-08-06T00:00:00Z", + parent_session_id: "sdeagent-parent", + }); + expect(pickForwardTargetSession([parent, sub])?.session_id).toBe( + "sdeagent-parent" + ); + }); +}); + +describe("buildDiscussionForwardMessage", () => { + it("frames a reply turn with the org2-pm receipt command", () => { + const { content, displayText } = buildDiscussionForwardMessage({ + shortId: "WI-0042", + author: "vince", + comment: "How far along is the export flow?", + }); + expect(content).toContain("vince commented on WI-0042"); + expect(content).toContain("How far along is the export flow?"); + expect(content).toContain("org2-pm work note WI-0042 --kind comment"); + expect(content).toContain("Do not change status"); + expect(displayText).toBe("💬 How far along is the export flow?"); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts new file mode 100644 index 0000000000..dd797732bf --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/discussionCommentForward.ts @@ -0,0 +1,119 @@ +/** + * discussionCommentForward + * + * Turns a human Discussion comment into a Reply turn on the item's + * linked session, so the Discussion is a live channel instead of a + * write-only log. The agent is instructed to answer through + * `org2-pm work note --kind comment` — its reply lands back on the + * same Discussion, closing the loop. Agent-authored notes arrive via + * the CLI, never through this UI submit path, so forwarding cannot + * recurse. + */ +import type { LinkedSession } from "@src/api/http/project/types/agentWorkflow"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { createLogger } from "@src/hooks/logger"; + +const logger = createLogger("discussionCommentForward"); + +/** + * Latest top-level linked session; sub-agent sessions never own the + * conversation, so they are skipped. + */ +export function pickForwardTargetSession( + linkedSessions: LinkedSession[] | undefined +): LinkedSession | null { + const candidates = (linkedSessions ?? []).filter( + (session) => !session.parent_session_id + ); + if (candidates.length === 0) return null; + return [...candidates].sort((a, b) => + (b.started_at ?? "").localeCompare(a.started_at ?? "") + )[0]; +} + +export function buildDiscussionForwardMessage({ + shortId, + author, + comment, +}: { + shortId: string; + author: string; + comment: string; +}): { content: string; displayText: string } { + const content = [ + `[Work Item Discussion] ${author} commented on ${shortId}:`, + "", + comment, + "", + "This is a Reply turn. Answer on the Discussion with exactly one receipt:", + ` org2-pm work note ${shortId} --kind comment --body ""`, + "(use --body-file for multi-line or shell-sensitive replies)", + "Do not change status or edit fields unless the comment explicitly asks for it.", + ].join("\n"); + const displayText = `💬 ${comment}`; + return { content, displayText }; +} + +/** + * One-click retry of a failed linked run: the + * failed session is resumed with a retry brief so it can finish the + * remaining work and deliver through org2-pm. Fire-and-forget; failures + * (session gone, other device) only log. + */ +export function retryFailedLinkedSession({ + shortId, + sessionId, +}: { + shortId: string; + sessionId: string; +}): void { + if (!shortId || !sessionId) return; + const content = [ + `[Retry] The previous run on ${shortId} did not finish successfully.`, + "", + `Re-read the item with \`org2-pm work show ${shortId}\`, finish the remaining work,`, + "and deliver through org2-pm with exactly one Discussion receipt.", + ].join("\n"); + void SessionService.sendMessage({ + sessionId, + content, + displayText: `↻ Retry ${shortId}`, + turnIntentSource: "user_submit", + }).catch((error) => { + logger.warn(`Retry forward to ${sessionId} failed: ${String(error)}`); + }); +} + +/** + * Fire-and-forget forward. Failures (session busy, session not on this + * device) only log — the comment itself is already durably on the item. + */ +export function forwardDiscussionCommentToLinkedSession({ + shortId, + author, + comment, + linkedSessions, +}: { + shortId: string; + author: string; + comment: string; + linkedSessions: LinkedSession[] | undefined; +}): void { + const target = pickForwardTargetSession(linkedSessions); + if (!target || !shortId) return; + const { content, displayText } = buildDiscussionForwardMessage({ + shortId, + author, + comment, + }); + void SessionService.sendMessage({ + sessionId: target.session_id, + content, + displayText, + turnIntentSource: "user_submit", + }).catch((error) => { + logger.warn( + `Discussion forward to ${target.session_id} failed: ${String(error)}` + ); + }); +} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx index a9b4b848d0..bd93308ce9 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { TabPillItem } from "@src/components/TabPill"; @@ -14,6 +14,7 @@ import type { WorkItem as WorkItemExtended, } from "@src/types/core/workItem"; +import { forwardDiscussionCommentToLinkedSession } from "../discussionCommentForward"; import { SESSION_TAB_KEYS, type SessionTab } from "../types"; import { useWorkItemTimeline } from "../useWorkItemTimeline"; import { normalizeWorkItemMentionIds } from "../workItemMentions"; @@ -28,9 +29,6 @@ interface UseWorkItemContentStateOptions { teamMembers?: Person[]; projectSlug?: string | null; shortId?: string | null; - onStartAgent?: (instructions?: string) => void; - onOpenSession?: (sessionId: string) => void; - activeAgentSessionId?: string | null; } export function useWorkItemContentState( @@ -43,10 +41,7 @@ export function useWorkItemContentState( currentUserProp, teamMembers = [], projectSlug, - shortId: _shortId, - onStartAgent, - onOpenSession, - activeAgentSessionId, + shortId, } = options; const { t } = useTranslation("projects"); @@ -81,43 +76,6 @@ export function useWorkItemContentState( const currentPhase = workItem.orchestratorState?.current_phase ?? "idle"; const isAgentRunning = currentPhase === "sde" || currentPhase === "review"; - const pendingOpenChatRef = useRef(false); - - const handleStartAgentAndOpenChat = useMemo( - () => - onStartAgent - ? (instructions?: string) => { - pendingOpenChatRef.current = true; - onStartAgent(instructions); - } - : undefined, - [onStartAgent] - ); - - useEffect(() => { - if ( - pendingOpenChatRef.current && - activeAgentSessionId && - activeAgentSessionId !== "pending" && - onOpenSession - ) { - pendingOpenChatRef.current = false; - onOpenSession(activeAgentSessionId); - } - }, [activeAgentSessionId, onOpenSession]); - - const prevSessionIdRef = useRef(activeAgentSessionId); - useEffect(() => { - if ( - pendingOpenChatRef.current && - prevSessionIdRef.current && - !activeAgentSessionId - ) { - pendingOpenChatRef.current = false; - } - prevSessionIdRef.current = activeAgentSessionId; - }, [activeAgentSessionId]); - const sessionTabItems: TabPillItem[] = useMemo( () => SESSION_TAB_KEYS.map((key) => ({ @@ -252,6 +210,12 @@ export function useWorkItemContentState( onUpdateWorkItem?.({ comments: [...(workItem.comments ?? []), newComment], } as Partial); + forwardDiscussionCommentToLinkedSession({ + shortId: shortId ?? workItem.shortId ?? "", + author: currentUser.name ?? currentUser.id, + comment: newComment.content, + linkedSessions: workItem.linkedSessions, + }); setCommentText(""); setMentionedUserIds([]); } catch (err) { @@ -264,9 +228,11 @@ export function useWorkItemContentState( isSubmittingComment, workItem, currentUser.id, + currentUser.name, mentionedUserIds, teamMembers, onUpdateWorkItem, + shortId, ]); return { @@ -283,7 +249,6 @@ export function useWorkItemContentState( isSubmittingComment, currentPhase, isAgentRunning, - handleStartAgentAndOpenChat, sessionTabItems, resolvedDescription, rawDescription, diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index 7253c51f6b..87a1586f4b 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -1,11 +1,10 @@ -import { Bot, Pencil, Repeat, Terminal } from "lucide-react"; +import { Bot, Pencil, Repeat, RotateCcw, Terminal } from "lucide-react"; import React, { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { type WorkItemHandoff, projectApi } from "@src/api/http/project"; import Avatar from "@src/components/Avatar"; import TabPill from "@src/components/TabPill"; -import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { useWorkItemImageInsert } from "@src/hooks/project"; import { ProjectContentEditor, @@ -21,6 +20,10 @@ import { TimelineStack, } from "@src/modules/shared/components/ActivityTimeline"; import RichMarkdownEditor from "@src/modules/shared/components/RichMarkdownEditor"; +import { + formatTokensShort, + formatUsd, +} from "@src/modules/shared/dataSource/usageFormat"; import { DetailPanelContainer, PanelFooter, @@ -28,20 +31,16 @@ import { SessionTable, type SessionTableItem, } from "@src/modules/shared/layouts/blocks"; -import { - type LinkedSession, - WORK_ITEM_STATUS, - type WorkItemStatus, -} from "@src/types/core/workItem"; +import { type LinkedSession, WORK_ITEM_STATUS } from "@src/types/core/workItem"; import { formatReplayDateLabel, toIntlLocaleTag, } from "@src/util/data/formatters/date"; -import AgentWorkflow from "../AgentWorkflow"; import { ROLE_I18N_KEYS, STATUS_I18N_KEYS } from "../AgentWorkflow/types"; import TodoChecklist from "../TodoChecklist"; import WorkItemContentStack from "../WorkItemContentStack"; +import WorkItemSubItems, { useWorkItemFamily } from "../WorkItemSubItems"; import { WorkItemThreadLayout, type WorkItemThreadView, @@ -53,6 +52,7 @@ import OutputTab from "./OutputTab"; import ThreadTodoChecklist from "./ThreadTodoChecklist"; import WorkItemHandoffNotice from "./WorkItemHandoffNotice"; import { normalizeLegacyEscapedMarkdown } from "./descriptionMarkdown"; +import { retryFailedLinkedSession } from "./discussionCommentForward"; import { useGitHubIssueTimeline } from "./hooks/useGitHubIssueTimeline"; import { useWorkItemContentState } from "./hooks/useWorkItemContentState"; import { resolveWorkItemContentSectionPolicy } from "./presentation"; @@ -60,6 +60,7 @@ import type { SessionTab, WorkItemContentProps } from "./types"; interface LinkedSessionsListProps { sessions: LinkedSession[]; + shortId?: string | null; activeAgentSessionId?: string | null; onOpenSession?: (sessionId: string) => void; } @@ -79,6 +80,7 @@ function getLinkedSessionTitle(session: LinkedSession): string { const LinkedSessionsList: React.FC = ({ sessions, + shortId, activeAgentSessionId, onOpenSession, }) => { @@ -146,14 +148,74 @@ const LinkedSessionsList: React.FC = ({ monthStyle: "short", } ), + tokensLabel: + session.total_tokens > 0 + ? formatTokensShort(session.total_tokens) + : undefined, + tokensValue: + session.total_tokens > 0 ? session.total_tokens : undefined, active: session.session_id === activeAgentSessionId, testId: `work-item-linked-session-${session.session_id}`, + rowAction: + session.status === "failed" && shortId ? ( + + ) : undefined, }; }); - }, [activeAgentSessionId, dateTimeLabelOptions, sessions, t]); + }, [ + activeAgentSessionId, + dateTimeLabelOptions, + onOpenSession, + sessions, + shortId, + t, + ]); + + const totalTokens = sessions.reduce( + (sum, session) => sum + (session.total_tokens || 0), + 0 + ); + const totalCost = sessions.reduce( + (sum, session) => sum + (session.cost_usd || 0), + 0 + ); return (
+ {sessions.length > 0 && ( +
+ + {t("workItems.sessions.runsCount", { + defaultValue: "{{count}} runs", + count: sessions.length, + })} + + {totalTokens > 0 && ( + {formatTokensShort(totalTokens)} tokens + )} + {totalCost > 0 && {formatUsd(totalCost, 2)}} +
+ )} onOpenSession?.(item.id)} @@ -178,8 +240,8 @@ const WorkItemContent: React.FC = ({ shortId, githubIssueTimeline, githubIssueInteraction, - onStartAgent, - isStartingAgent, + orgId, + onOpenSubItem, onCancelAgent, onRetry, onAcceptAsIs, @@ -191,9 +253,6 @@ const WorkItemContent: React.FC = ({ onRefreshWorkflow, onTransitionHandoff, activeAgentSessionId, - activeAgentRole, - isLockedByOther, - lockHolderName, onCreatePr, }) => { const { t } = useTranslation(["projects", "common"]); @@ -204,6 +263,12 @@ const WorkItemContent: React.FC = ({ editorRef, }); + const subItemFamily = useWorkItemFamily( + shortId ?? workItem.shortId ?? "", + projectSlug, + orgId + ); + const { currentUser, currentUserMemberIds, @@ -224,7 +289,6 @@ const WorkItemContent: React.FC = ({ handleDescriptionChange, handleTodosChange, handleCommentSubmit, - handleStartAgentAndOpenChat, } = useWorkItemContentState({ workItem, onUpdateWorkItem, @@ -233,9 +297,6 @@ const WorkItemContent: React.FC = ({ teamMembers, projectSlug, shortId, - onStartAgent, - onOpenSession, - activeAgentSessionId, }); const creatorName = @@ -614,31 +675,16 @@ const WorkItemContent: React.FC = ({ /> ); - const agentWorkflow = ( - + const subItemsSection = ( + + + ); const outputContent = ( @@ -702,18 +748,15 @@ const WorkItemContent: React.FC = ({ />
- {activeSessionTab === "session" && ( - <> -
{agentWorkflow}
- {sectionPolicy.showLinkedSessionsTable ? ( - - ) : null} - - )} + {activeSessionTab === "session" && + (sectionPolicy.showLinkedSessionsTable ? ( + + ) : null)} {activeSessionTab === "output" && outputContent} @@ -723,12 +766,19 @@ const WorkItemContent: React.FC = ({ const threadLowerSection = ( <> - {sectionPolicy.showInlineWorkflow ? ( + {(workItem.linkedSessions?.length ?? 0) > 0 ? ( - {agentWorkflow} + ) : null} {sectionPolicy.showInlineOutput ? ( @@ -763,6 +813,7 @@ const WorkItemContent: React.FC = ({ {todosSection} + {subItemsSection} {threadLowerSection} {!isGitHubWorkItem ? ( = ({ } todosContent={todosSection} lowerContent={ - sectionPolicy.showTabbedLowerSection - ? tabbedLowerSection - : threadLowerSection + <> + {subItemsSection} + {sectionPolicy.showTabbedLowerSection + ? tabbedLowerSection + : threadLowerSection} + } scrollable /> diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts index af3c437fe2..819b2e164f 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts @@ -3,7 +3,6 @@ export type WorkItemContentPresentation = "default" | "thread"; export interface WorkItemContentSectionPolicy { showTabbedLowerSection: boolean; showLinkedSessionsTable: boolean; - showInlineWorkflow: boolean; showInlineOutput: boolean; } @@ -11,9 +10,9 @@ export interface WorkItemContentSectionPolicy { * Keep the Work Item presentation policy explicit and testable. * * The default surface retains its existing tabs/table. Team Inbox uses the - * thread policy: workflow/session cards stay in Overview, local Discussion is - * a drill-in view, and the duplicate linked-session table is absent. GitHub - * issues supply their native floating comment composer separately. + * thread policy: the linked-session table stays in Overview, local Discussion + * is a drill-in view. GitHub issues supply their native floating comment + * composer separately. */ export function resolveWorkItemContentSectionPolicy( presentation: WorkItemContentPresentation, @@ -22,8 +21,7 @@ export function resolveWorkItemContentSectionPolicy( if (presentation === "thread") { return { showTabbedLowerSection: false, - showLinkedSessionsTable: false, - showInlineWorkflow: true, + showLinkedSessionsTable: true, showInlineOutput: hasProofOfWork, }; } @@ -31,7 +29,6 @@ export function resolveWorkItemContentSectionPolicy( return { showTabbedLowerSection: true, showLinkedSessionsTable: true, - showInlineWorkflow: false, showInlineOutput: false, }; } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts index ba726141e9..c9746804b6 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import type { OrchestratorPhase, PrStatus, + WorkItemData as WorkItemDataPayload, WorkItemHandoffTransition, WorkItemHistoryAction, } from "@src/api/http/project"; @@ -14,7 +15,6 @@ import type { import type { Person } from "@src/types/core/shared"; import type { WorkItem as WorkItemExtended } from "@src/types/core/workItem"; -import type { AgentRole } from "../../constants"; import type { WorkItemContentPresentation } from "./presentation"; export const SESSION_TAB_KEYS = ["session", "output", "history"] as const; @@ -39,6 +39,9 @@ export interface WorkItemContentProps { repoPath?: string | null; projectSlug?: string | null; shortId?: string | null; + orgId?: string | null; + /** Open a parent/child item from the Sub-items section (host-specific navigation). */ + onOpenSubItem?: (item: WorkItemDataPayload) => void; /** * Reuse activity already owned by the surrounding GitHub detail controller. * When omitted, project-backed Work Items resolve and load their own issue @@ -50,8 +53,6 @@ export interface WorkItemContentProps { }; /** Inline GitHub-native body, comment, and status actions for thread surfaces. */ githubIssueInteraction?: GitHubIssueInteractionConfig; - onStartAgent?: (instructions?: string) => void; - isStartingAgent?: boolean; onCancelAgent?: () => void; onRetry?: () => void; onAcceptAsIs?: () => void; @@ -69,9 +70,6 @@ export interface WorkItemContentProps { transition: WorkItemHandoffTransition ) => Promise; activeAgentSessionId?: string | null; - activeAgentRole?: AgentRole | null; - isLockedByOther?: boolean; - lockHolderName?: string | null; onCreatePr?: () => Promise<{ url?: string; error?: string }>; } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx index a9d677727f..f9fbe108c1 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailBody.tsx @@ -1,5 +1,6 @@ import React from "react"; +import type { WorkItemData as WorkItemDataPayload } from "@src/api/http/project"; import { useResizeHandle } from "@src/hooks/ui/useResizeHandle"; import type { AgentDefinition, @@ -15,7 +16,6 @@ import type { WorkItemProject, } from "@src/types/core/workItem"; -import type { AgentRole } from "../../constants"; import WorkItemContent from "../WorkItemContent"; import WorkItemProperties from "../WorkItemProperties"; import type { WorkItemExternalStatusConfig } from "../WorkItemProperties/types"; @@ -39,14 +39,10 @@ interface WorkItemDetailBodyProps { repoPath?: string | null; projectSlug?: string | null; shortId?: string | null; - isStartingAgent: boolean; activeAgentSessionId?: string | null; - activeAgentRole?: AgentRole | null; - isLockedByOther: boolean; - lockHolderName: string | null; + onOpenSubItem?: (item: WorkItemDataPayload) => void; onUpdateWorkItem: (updates: Partial) => void; onUpdateWorkItemImmediate: (updates: Partial) => void; - onStartAgent: (instructions?: string) => void; onCancelAgent: () => void; onRetry: (instructions?: string) => void; onAcceptAsIs: () => void; @@ -75,14 +71,10 @@ export function WorkItemDetailBody({ repoPath, projectSlug, shortId, - isStartingAgent, activeAgentSessionId, - activeAgentRole, - isLockedByOther, - lockHolderName, + onOpenSubItem, onUpdateWorkItem, onUpdateWorkItemImmediate, - onStartAgent, onCancelAgent, onRetry, onAcceptAsIs, @@ -131,8 +123,6 @@ export function WorkItemDetailBody({ repoPath={repoPath} projectSlug={projectSlug} shortId={shortId} - onStartAgent={onStartAgent} - isStartingAgent={isStartingAgent} onCancelAgent={onCancelAgent} onRetry={onRetry} onAcceptAsIs={onAcceptAsIs} @@ -141,11 +131,9 @@ export function WorkItemDetailBody({ onOpenFileDiff={onOpenFileDiff} onOpenFileAtLine={onOpenFileAtLine} onReviewAllFiles={onReviewAllFiles} + onOpenSubItem={onOpenSubItem} onRefreshWorkflow={onRefreshWorkItem} activeAgentSessionId={activeAgentSessionId} - activeAgentRole={activeAgentRole} - isLockedByOther={isLockedByOther} - lockHolderName={lockHolderName} onCreatePr={onCreatePr} />
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx index 83af31e096..d3dc3eec01 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetail/index.tsx @@ -7,10 +7,15 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; +import type { WorkItemData as WorkItemDataPayload } from "@src/api/http/project"; import { HEADER_CLASSES } from "@src/config/workstation/tokens"; -import { usePublishWorkstationTabHeader } from "@src/hooks/workStation"; +import { + usePublishWorkstationTabHeader, + useWorkStationTabs, +} from "@src/hooks/workStation"; import { useAgentDefinitions } from "@src/modules/MainApp/AgentOrgs/hooks/useAgentDefinitions"; import { useAgentOrgs } from "@src/modules/MainApp/AgentOrgs/hooks/useAgentOrgs"; +import { createWorkItemDetailTab } from "@src/store/workstation/tabs"; import { WORK_ITEM_STATUS, type WorkItemPriority, @@ -122,18 +127,14 @@ const WorkItemDetail: React.FC = ({ const displayShortId = formatWorkItemShortId(shortId, displayStatus); const { - isStartingAgent, activeAgentSessionId, activeAgentRole, - handleStartAgent, handleRetry, handleCancelAgent, handleAcceptAsIs, handleCreateFollowUp, worktreePath, projectRepoPath, - isLockedByOther, - lockHolderName, } = useWorkItemOrchestrator({ workItem, displayWorkItem, @@ -149,6 +150,25 @@ const WorkItemDetail: React.FC = ({ const { handleOpenFileDiff, handleOpenFileAtLine, handleReviewAllFiles } = useWorkItemFileActions(repoPath); + const { openTab: openStationTab } = useWorkStationTabs(); + const handleOpenSubItem = useCallback( + (item: WorkItemDataPayload) => { + openStationTab( + createWorkItemDetailTab( + undefined, + undefined, + item.frontmatter.short_id, + item.frontmatter.title || item.frontmatter.short_id, + projectSlug ?? undefined, + undefined, + undefined, + item.frontmatter.status + ) + ); + }, + [openStationTab, projectSlug] + ); + const handleOpenSessionWithContext = useCallback( (sessionId: string) => { if (!onOpenSession) return; @@ -391,14 +411,10 @@ const WorkItemDetail: React.FC = ({ repoPath={repoPath} projectSlug={projectSlug} shortId={shortId} - isStartingAgent={isStartingAgent} activeAgentSessionId={activeAgentSessionId} - activeAgentRole={activeAgentRole} - isLockedByOther={isLockedByOther} - lockHolderName={lockHolderName} + onOpenSubItem={handleOpenSubItem} onUpdateWorkItem={handleLocalUpdate} onUpdateWorkItemImmediate={handleImmediateUpdate} - onStartAgent={handleStartAgent} onCancelAgent={handleCancelAgent} onRetry={handleRetry} onAcceptAsIs={handleAcceptAsIs} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx index b5d19984bc..f754a09b26 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemDetailPage/StandaloneWorkItemDetailPage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { projectApi, workItemDataToUI } from "@src/api/http/project"; +import { useProjectDataChanged } from "@src/hooks/project"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; import { activeWorkspaceRootPathAtom } from "@src/store/workspace"; import type { WorkItem } from "@src/types/core/workItem"; @@ -46,6 +47,19 @@ export function StandaloneWorkItemDetailPage({ void loadWorkItem(); }, [loadWorkItem]); + // Linked-session status mirrors, receipt fallbacks, and CLI writes land + // through the data-changed signal after this tab has mounted. Without a + // subscription the page keeps its open-time snapshot forever. + useProjectDataChanged( + useCallback( + (change) => { + if (change?.workItemId && change.workItemId !== workItemId) return; + void loadWorkItem(); + }, + [loadWorkItem, workItemId] + ) + ); + useEffect(() => { const workItemStatus = workItem?.workItemStatus ?? workItem?.status; if (workItemStatus) onWorkItemStatusResolved?.(workItemStatus); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemSubItems.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemSubItems.test.ts new file mode 100644 index 0000000000..99cd0de26b --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemSubItems.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import type { WorkItemData } from "@src/api/http/project"; + +import { + getSubItemStageNumbers, + groupSubItemsByStage, +} from "./WorkItemSubItems"; + +function child(shortId: string, stage?: number): WorkItemData { + return { + body: "", + filename: `${shortId}.md`, + frontmatter: { + id: shortId, + short_id: shortId, + title: shortId, + status: "planned", + priority: "none", + labels: [], + stage, + created_at: "2026-08-08T00:00:00.000Z", + updated_at: "2026-08-08T00:00:00.000Z", + starred: false, + todos: [], + }, + }; +} + +describe("WorkItemSubItems stage model", () => { + it("offers stage 1 for a parent without staged children", () => { + expect(getSubItemStageNumbers([])).toEqual([1]); + expect(getSubItemStageNumbers([child("WI-0002")])).toEqual([1]); + }); + + it("offers every existing stage plus the next sequential stage", () => { + expect( + getSubItemStageNumbers([ + child("WI-0002", 1), + child("WI-0003", 3), + child("WI-0004"), + ]) + ).toEqual([1, 2, 3, 4]); + }); + + it("keeps unstaged children after ordered stage groups", () => { + expect( + groupSubItemsByStage([ + child("WI-0002"), + child("WI-0003", 2), + child("WI-0004", 1), + ]).map((group) => group.label) + ).toEqual(["Stage 1", "Stage 2", "No stage"]); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemSubItems.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemSubItems.tsx new file mode 100644 index 0000000000..278e08d010 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemSubItems.tsx @@ -0,0 +1,351 @@ +import { emit } from "@tauri-apps/api/event"; +import { ListTree, Plus } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { type WorkItemData, projectApi } from "@src/api/http/project"; +import Select, { type SelectOption } from "@src/components/Select"; +import { + allocateCloudAwareStandaloneWorkItemId, + allocateCloudAwareWorkItemId, +} from "@src/features/Org2Cloud/cloudShortId"; +import { createLogger } from "@src/hooks/logger"; +import { useProjectDataChanged } from "@src/hooks/project"; + +const logger = createLogger("WorkItemSubItems"); + +interface WorkItemFamily { + children: WorkItemData[]; + parent: WorkItemData | null; +} + +/** + * Children and parent of one work item, resolved from the item's own + * scope (project slug or standalone org) via `frontmatter.parent` + * linkage, refreshed on every `orgii-data-changed` signal so CLI writes + * from agent shells appear live. + */ +export function useWorkItemFamily( + shortId: string, + projectSlug?: string | null, + orgId?: string | null +): WorkItemFamily { + const [family, setFamily] = useState({ + children: [], + parent: null, + }); + + const refresh = useCallback(() => { + const read = projectSlug + ? projectApi.readWorkItems(projectSlug) + : projectApi.readStandaloneWorkItems(orgId ? { orgId } : undefined); + read + .then((items) => { + const children = items.filter( + (item) => + item.frontmatter.parent === shortId && !item.frontmatter.deleted_at + ); + const ownParentId = items.find( + (item) => item.frontmatter.short_id === shortId + )?.frontmatter.parent; + const parent = ownParentId + ? (items.find((item) => item.frontmatter.short_id === ownParentId) ?? + null) + : null; + setFamily({ children, parent }); + }) + .catch(() => { + setFamily({ children: [], parent: null }); + }); + }, [shortId, projectSlug, orgId]); + + useEffect(() => { + refresh(); + }, [refresh]); + useProjectDataChanged(refresh); + + return family; +} + +const DONE_SUB_ITEM_STATUSES = new Set(["completed", "cancelled", "done"]); + +interface SubItemStageGroup { + key: string; + /** Group header label; null when the set has no staged items at all. */ + label: string | null; + items: WorkItemData[]; +} + +/** + * Stage grouping: headers appear only when at least one + * child carries a stage; staged groups sort ascending with unstaged + * children trailing under "NO STAGE". + */ +export function groupSubItemsByStage( + children: WorkItemData[] +): SubItemStageGroup[] { + const anyStaged = children.some( + (child) => child.frontmatter.stage !== undefined + ); + if (!anyStaged) { + return [{ key: "all", label: null, items: children }]; + } + const byStage = new Map(); + const unstaged: WorkItemData[] = []; + for (const child of children) { + const stage = child.frontmatter.stage; + if (stage === undefined) { + unstaged.push(child); + continue; + } + const bucket = byStage.get(stage) ?? []; + bucket.push(child); + byStage.set(stage, bucket); + } + const groups: SubItemStageGroup[] = [...byStage.entries()] + .sort(([a], [b]) => a - b) + .map(([stage, items]) => ({ + key: `stage-${stage}`, + label: `Stage ${stage}`, + items, + })); + if (unstaged.length > 0) { + groups.push({ key: "no-stage", label: "No stage", items: unstaged }); + } + return groups; +} + +/** + * Keep quick-add compact while making every existing stage and the next + * sequential stage directly selectable. Filling gaps keeps recovery from a + * deleted or moved stage possible without requiring a separate editor. + */ +export function getSubItemStageNumbers(children: WorkItemData[]): number[] { + const maxStage = children.reduce( + (max, child) => Math.max(max, child.frontmatter.stage ?? 0), + 0 + ); + return Array.from({ length: Math.max(1, maxStage + 1) }, (_, index) => + Number(index + 1) + ); +} + +interface WorkItemSubItemsProps { + family: WorkItemFamily; + parentShortId: string; + projectSlug?: string | null; + orgId?: string | null; + onOpenWorkItem?: (item: WorkItemData) => void; +} + +const WorkItemSubItems: React.FC = ({ + family, + parentShortId, + projectSlug, + orgId, + onOpenWorkItem, +}) => { + const { t } = useTranslation(["projects"]); + const { children, parent } = family; + const [adding, setAdding] = useState(false); + const [draftTitle, setDraftTitle] = useState(""); + const [draftStage, setDraftStage] = useState(null); + const [creating, setCreating] = useState(false); + + const stageOptions = useMemo( + () => [ + { + label: t("workItems.subItems.noStage", { defaultValue: "No stage" }), + value: "none", + }, + ...getSubItemStageNumbers(children).map((stage) => ({ + label: t("workItems.subItems.stage", { + defaultValue: "Stage {{stage}}", + stage, + }), + value: stage, + })), + ], + [children, t] + ); + + const doneCount = children.filter((child) => + DONE_SUB_ITEM_STATUSES.has(child.frontmatter.status) + ).length; + + const handleCreateSubItem = useCallback(async () => { + const title = draftTitle.trim(); + if (!title || creating || !parentShortId) return; + setCreating(true); + try { + if (projectSlug) { + const shortId = await allocateCloudAwareWorkItemId(projectSlug); + await projectApi.createWorkItem(projectSlug, shortId, { + title, + parent: parentShortId, + stage: draftStage ?? undefined, + status: "planned", + }); + } else { + const shortId = await allocateCloudAwareStandaloneWorkItemId( + orgId ?? undefined + ); + await projectApi.createStandaloneWorkItem( + shortId, + { + title, + parent: parentShortId, + stage: draftStage ?? undefined, + status: "planned", + }, + orgId ? { orgId } : undefined + ); + } + setDraftTitle(""); + setDraftStage(null); + setAdding(false); + await emit("orgii-data-changed", { + work_item_id: parentShortId, + project_slug: projectSlug || undefined, + source: "sub-item-quick-add", + }); + } catch (error) { + logger.error("Failed to create sub item", error); + } finally { + setCreating(false); + } + }, [creating, draftStage, draftTitle, orgId, parentShortId, projectSlug]); + + if (!parentShortId) return null; + + return ( +
+
+ + + {t("workItems.subItems.title")} + + {children.length > 0 && ( + + {doneCount}/{children.length} + + )} +
+ {parent && ( + + )} + +
+
+ {children.length > 0 && ( +
+ {groupSubItemsByStage(children).map((group) => ( + + {group.label && ( +
+ {group.label} +
+ )} + {group.items.map((child) => ( + + ))} +
+ ))} +
+ )} + {children.length === 0 && !adding && ( + + )} + {adding && ( +
+ setDraftTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void handleCreateSubItem(); + if (event.key === "Escape") { + setAdding(false); + setDraftTitle(""); + setDraftStage(null); + } + }} + data-testid="work-item-sub-item-title-input" + /> +
+ + )} {draft.customCron && ( - - updateDraft("cron", value)} - placeholder="0 9 * * 1" - size="default" - style={SECTION_CONTROL_STYLE} - autoComplete="off" - autoCorrect="off" - spellCheck={false} - data-testid="routine-wizard-cron-input" - /> - + <> + + updateDraft("cron", value)} + placeholder="0 9 * * 1" + size="default" + style={SECTION_CONTROL_STYLE} + autoComplete="off" + autoCorrect="off" + spellCheck={false} + data-testid="routine-wizard-cron-input" + /> + + +