diff --git a/docs/architecture/managed-cloud-collaboration.md b/docs/architecture/managed-cloud-collaboration.md index 2f5459eaa..debb88e26 100644 --- a/docs/architecture/managed-cloud-collaboration.md +++ b/docs/architecture/managed-cloud-collaboration.md @@ -123,8 +123,9 @@ detail views and ChatPanel. Lock release is serialized as explicit JSON ## Create with AI -Create with AI uses `builtin:work-item-manager` by default and follows one -durable-draft invariant: +Create with AI uses `builtin:os` by default (the Work Item Manager persona was +retired; `manage_work_item`/`manage_project` are ordinary built-in tools) and +follows one durable-draft invariant: 1. Before launch, the UI allocates a cloud-aware Work Item ID and writes one draft in the selected Project or organization-scoped standalone store. diff --git a/docs/orgtrack-pm-protocol/README.md b/docs/orgtrack-pm-protocol/README.md new file mode 100644 index 000000000..97e3ed80f --- /dev/null +++ b/docs/orgtrack-pm-protocol/README.md @@ -0,0 +1,41 @@ +# Orgtrack PM Protocol — Phase 0 frozen artifacts + +`orgtrack/v1` WorkItem + Routine CLI 协议的 Phase 0 产出。本目录是协议 wire +contract 的 source of truth:schemas 与 golden fixtures 会被后续 Phase 3+ 的 +conformance 测试直接消费;`decisions.md` 记录 Phase 0 冻结的全部命名与边界 +决策,实现与之冲突时以本目录为准。 + +设计依据是《Orgtrack WorkItem + Routine CLI Protocol — 最终设计》rev 2 +(2026-08-04 审计修订版,暂未入库)。 + +## 目录 + +```text +decisions.md Phase 0 冻结决策(mode/capability/provider id/hook 名/ + manifest/CLI 载体/watermark/exit code) +parity-matrix.md 入口一致性矩阵(每个 phase 落地时填格) +schemas/ JSON Schema(draft-07) + common.schema.json 共享 $defs:ActorRef、SessionRef、状态枚举、 + capability 词汇表 + envelope.schema.json success/error CLI envelopes 与稳定错误码 + execution-context.schema.json `org2 context` 返回的 ExecutionContext + work-item.schema.json WorkItem canonical shape + routine.schema.json portable Routine spec + routine-run.schema.json RoutineRun occurrence +fixtures/ + success/ 每个 command family 的 success envelope golden fixture + errors/ 18 个稳定错误码各一份 golden fixture +``` + +## 约定 + +- fixtures 是 byte-level golden:conformance 测试比较真实 serialized bytes, + 不允许隐藏 `$schema`、remote `$ref`、secret 或本地路径混入 wire payload; +- 未列入 `envelope.schema.json` 错误码枚举的 code 不得出现在任何实现中; +- schemas 修改需要同步更新 fixtures 与 `decisions.md`,三者不一致视为 CI 失败 + (Phase 3 接入)。 + +## Phase 0 未尽项 + +- 现有 Routine/WorkItem 数据导出(migration fixture)需要开发机的 + `projects.db`,推迟到 Phase 4 开工时以脚本完成,脚本与导出样本届时入库。 diff --git a/docs/orgtrack-pm-protocol/decisions.md b/docs/orgtrack-pm-protocol/decisions.md new file mode 100644 index 000000000..8d461e6dd --- /dev/null +++ b/docs/orgtrack-pm-protocol/decisions.md @@ -0,0 +1,144 @@ +# Phase 0 冻结决策 + +本文件是 Phase 0 交付的决策冻结记录。实现阶段(Phase 1-8)与此冲突时,要么改 +实现,要么先修订本文件并说明理由——不允许静默偏离。 + +## 1. Product mode 与 resolver precedence + +Product mode enum:`build | plan | ask | project`(唯一 source of truth 是 +ExecutionContext 的 `mode` 字段;禁止任何 `tracking_enabled` 类影子布尔)。 + +Mode resolver 固定顺序: + +1. 从 WorkItem detail、Routine 或 RoutineRun 启动 → `project`; +2. 用户在当前 Session 明确选择的 mode; +3. 普通新 Session → `build`。 + +现状注意:mode 枚举目前有四处发散清单(Rust `AgentExecMode`、 +`agent_list_modes`(返回 4 项且无 UI caller)、TS `AGENT_EXEC_MODES` picker、 +`MODE_LABELS`)。Phase 3 收敛为单一 source 后才允许新增 `project`。 + +Plan mode 有"进入时快照、退出时恢复前一 mode"状态 +(`restore_mode_before_plan_entry`):`Convert to Project` 必须失效该快照。 + +## 2. Mode × capability allowlist + +Capability 词汇表(12 个,见 `common.schema.json#/definitions/capabilityId`): +`work.read|create|update|claim|transition|note|relate`、 +`routine.read|apply|run|cancel|set_enabled`。`context` 读取不需要 capability。 + +| Mode | allowlist | +| ----------------------------------- | ------------------ | +| build / plan / ask / 其他 exec mode | (空——仅 context) | +| project | 全部 12 个 | + +最终 capabilities = mode allowlist ∩ actor/org policy ∩ provider capabilities。 +实现必须用现有 deny-delta 机制表达("modes never grant tools"):Build/Plan 等 +显式 deny work/routine mutation surface,Project 不施加该 deny;授权本身只来自 +actor/org policy。 + +术语隔离:本协议的 `runtimeExecutionMode` 对应现有 `AgentExecMode` wire +values;**不得**复用现有 Rust `ExecutionMode { Direct, WorkStation }` 类型名。 +`review` 在三个域必须使用限定名:exec mode(`AgentExecMode::Review`)、agent +role(`AgentRole::Review`,orchestrator 状态机 key 的列)、orchestrator phase +(`OrchestratorPhase::Review`)。 + +## 3. 错误码 ↔ exit code + +错误码枚举见 `envelope.schema.json`(18 个,含 `RESULT_SCHEMA_MISMATCH`)。 + +| exit | 错误码 | +| ---- | ------------------------------------------------------------------------------------------------------------ | +| 0 | success | +| 2 | INVALID_ARGUMENT / RESULT_SCHEMA_MISMATCH / DEPENDENCY_CYCLE | +| 3 | NOT_FOUND / CONTEXT_REQUIRED / ACTOR_REQUIRED | +| 4 | REVISION_CONFLICT / IDEMPOTENCY_CONFLICT / ALREADY_CLAIMED / ALREADY_EXISTS / NOT_READY / INVALID_TRANSITION | +| 5 | PROJECT_MODE_REQUIRED | +| 6 | PROVIDER_UNAVAILABLE / STORE_UNAVAILABLE | +| 7 | UNSUPPORTED_CAPABILITY | +| 8 | PERMISSION_DENIED / SCOPE_VIOLATION | + +`PROJECT_MODE_REQUIRED`(5)与 `PERMISSION_DENIED`(8)分离:前者提示"需要用户 +切换 mode",后者是"该 actor 无权执行"。claim 竞争时 `ALREADY_CLAIMED` 优先于 +`REVISION_CONFLICT`。现有 `orgtrack check` 的 0/1/2 exit 语义属于该 binary 自身, +与本表无关。 + +## 4. CLI 载体与 crate 命名 + +- 用户侧 command 名为 `org2`;由**新的独立 console binary** 提供:crate + `orgtrack-pm-cli`,cargo bin name `org2-pm`(workspace 内唯一),distribution + 安装到 PATH 并命名/别名为 `org2`; +- 现有 GUI binary(cargo package `org2`,release 下 + `windows_subsystem = "windows"`,无 console)**不承载任何 CLI subcommand**; +- PM 协议 DTO crate 命名为 `orgtrack-pm-protocol`——注意 `orgtrack-protocol` + 已被 session provenance wire contracts 占用(设计文档 §19 的建议名与之冲突, + 以本条为准);同理后续 domain/application/store crates 使用 `orgtrack-pm-*` + 前缀; +- 现有 `orgtrack` binary(外部会话历史索引)不变;`packages/orgtrack` npm + stub 在 Phase 1 删除。 + +## 5. Provider ID 命名空间 + +SessionRef.provider / ProviderBinding.provider 使用以下冻结 registry: + +- `org2` —— 一切 ORG2 拥有/托管的 session(内部 canonical source + `orgii_rust_agents` / `orgii_cli_sessions` / `orgii_cloud_replay` 一律对外 + 呈现为 `org2`;底层 harness 记录在 SessionRef.metadata.nativeHarness); +- 外部 provider 使用 importer 端 canonical source id:`claude_code`、 + `codex_app`、`cursor_ide`、`cursor_cli`、`opencode`、`cline`、`copilot`、 + `kimi`、`qwen_code`、`droid`、`antigravity`、`zcode`、`warp`、`trae`、 + `qoder`、`windsurf` 等(以 `orgtrack-core` source registry 为准); +- hook 端短名映射到 canonical id,不对外出现:`claude→claude_code`、 + `codex→codex_app`、`cursor→cursor_ide`、`qwen→qwen_code`、其余同名直映; +- planning provider id:`linear`、`github`(现有 adapter registry id 不变)。 + +## 6. Session 生命周期 hook 命名 + +协议 canonical hook 名:`session.started`、`session.completed`、 +`work.claimed`、`work.transitioned`、`routine.invoked`、`routine.completed`、 +`artifact.produced`。 + +现状三套命名的映射(Phase 5 落地时接线,不新增第四套): + +| 现状 | canonical | +| -------------------------------------------------------------------- | --------------------------------------------------- | +| WS wire `session.completed` / `session.failed` / `session.cancelled` | `session.completed`(payload 携带 terminal status) | +| Tauri 内部 `session-status-changed` | 内部信号,驱动 canonical hooks,不直接暴露 | +| agent 流 `session_start` / `session_end` | `session.started` / `session.completed` | + +`session.started` 现状不存在,为新增。`session.completed` 默认只附加 +SessionRef;orchestrator 场景的自动完成改写为显式默认 completion policy(走 +canonical `work.transition`,带 attempt/session identity)。 + +## 7. Workspace manifest 与 env + +- Manifest 文件:`.orgii/orgtrack.json`,最小 shape + `{ "version": 1, "scopeId": "...", "orgId": "..." }`; +- `is_initialized(workspace)` = 该文件存在且 `version` 受支持。`.orgii/` 目录 + 存在本身**不**代表已初始化(现状该目录树由 git-folder sync 等副作用创建); +- trusted local resolver 顺序:explicit CLI flags → `ORGII_*` env → + manifest。冻结 env 名:`ORGII_MODE`、`ORGII_ACTOR`、`ORGII_SCOPE`、 + `ORGII_SESSION_REF`(沿用现有 `ORGII_` 前缀惯例;不新增 `ORG2_*`)。 + +## 8. 跨进程 wake(pm_change_seq) + +- `projects.db` 新增单行表 `pm_change_seq(id INTEGER PRIMARY KEY CHECK(id=1), +seq INTEGER NOT NULL)`; +- 每个 PM mutation 在同一 transaction 内 `seq = seq + 1`; +- 桌面 host 低频轮询(或 db 文件 watch 触发)读取 seq,变化时做增量 + reconciliation;进程内 mutation 直接内存通知; +- readiness / RoutineRun 投影 / output 绑定在 mutation 事务内同步完成,CLI + 写入不依赖 host 在线。 + +## 9. 列表分页与 list 返回形状 + +- list 类 data 形状固定为 `{ "items": [...] }`; +- `--cursor ` 请求下一页;`meta.nextCursor` 存在表示还有后续页; +- cursor 是 opaque token,实现可变,语义不进协议。 + +## 10. Claim 与既有锁的边界 + +- claim record 收编本地 `execution_lock`(CAS session 执行锁)职责; +- 云端 `orgii_acquire_work_item_lock` 保持为 human 协作**编辑锁**:编辑锁 ≠ + 工作 claim,两者并存但互不代理;Phase 2a 落地时在 service 层写清依赖关系 + (持有编辑锁不阻止 claim,claim 不授予编辑权)。 diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json b/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json new file mode 100644 index 000000000..4e8371121 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/actor-required.json @@ -0,0 +1,15 @@ +{ + "apiVersion": "orgtrack/v1", + "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)", + "retryable": false, + "details": { + "missing": ["actor"] + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR03" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/already-claimed.json b/docs/orgtrack-pm-protocol/fixtures/errors/already-claimed.json new file mode 100644 index 000000000..5da4737b6 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/already-claimed.json @@ -0,0 +1,18 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "ALREADY_CLAIMED", + "message": "WorkItem work_01K0000000000000000000WORK0 is claimed by agent codex_app:reviewer-1", + "retryable": false, + "details": { + "claimedBy": { + "kind": "agent", + "id": "codex_app:reviewer-1" + } + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR10" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/already-exists.json b/docs/orgtrack-pm-protocol/fixtures/errors/already-exists.json new file mode 100644 index 000000000..4c3b5b65f --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/already-exists.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "ALREADY_EXISTS", + "message": "Routine name 'interaction-impact-analysis' already exists in this scope", + "retryable": false, + "details": { + "resource": "Routine", + "name": "interaction-impact-analysis" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR06" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/context-required.json b/docs/orgtrack-pm-protocol/fixtures/errors/context-required.json new file mode 100644 index 000000000..5ffd33e4b --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/context-required.json @@ -0,0 +1,15 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "CONTEXT_REQUIRED", + "message": "No scope resolved: pass --scope, set ORGII_SCOPE, or run inside an initialized workspace (.orgii/orgtrack.json)", + "retryable": false, + "details": { + "missing": ["scopeId"] + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR02" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/dependency-cycle.json b/docs/orgtrack-pm-protocol/fixtures/errors/dependency-cycle.json new file mode 100644 index 000000000..01c156253 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/dependency-cycle.json @@ -0,0 +1,19 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "DEPENDENCY_CYCLE", + "message": "Adding depends_on work_01K0000000000000000000ROOT0 would create a cycle", + "retryable": false, + "details": { + "cycle": [ + "work_01K0000000000000000000WORK0", + "work_01K000000000000000000COLL0", + "work_01K0000000000000000000WORK0" + ] + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR13" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json b/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json new file mode 100644 index 000000000..e98990126 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/idempotency-conflict.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "IDEMPOTENCY_CONFLICT", + "message": "Idempotency key 'session_abc:complete' was already used with a different canonical request body", + "retryable": false, + "details": { + "idempotencyKey": "session_abc:complete", + "operation": "work.transition" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR08" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/invalid-argument.json b/docs/orgtrack-pm-protocol/fixtures/errors/invalid-argument.json new file mode 100644 index 000000000..04ca530f4 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/invalid-argument.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "INVALID_ARGUMENT", + "message": "Unknown state 'done'; expected one of open|in_progress|blocked|completed|failed|cancelled", + "retryable": false, + "details": { + "field": "--to", + "value": "done" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR01" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json b/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json new file mode 100644 index 000000000..9e7a9541f --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/invalid-transition.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "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", + "retryable": false, + "details": { + "from": "completed", + "to": "in_progress" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR11" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/not-found.json b/docs/orgtrack-pm-protocol/fixtures/errors/not-found.json new file mode 100644 index 000000000..8e84637d8 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/not-found.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "NOT_FOUND", + "message": "WorkItem work_01K000000000000000000GONE0 does not exist in scope project_01K0000000000000000000SCOPE", + "retryable": false, + "details": { + "resource": "WorkItem", + "id": "work_01K000000000000000000GONE0" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR05" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/not-ready.json b/docs/orgtrack-pm-protocol/fixtures/errors/not-ready.json new file mode 100644 index 000000000..d8fb08e6f --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/not-ready.json @@ -0,0 +1,15 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "NOT_READY", + "message": "WorkItem work_01K0000000000000000000WORK0 has 1 incomplete dependency", + "retryable": true, + "details": { + "incompleteDependencies": ["work_01K000000000000000000COLL0"] + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR09" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/permission-denied.json b/docs/orgtrack-pm-protocol/fixtures/errors/permission-denied.json new file mode 100644 index 000000000..94340c4f9 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/permission-denied.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "PERMISSION_DENIED", + "message": "Actor codex_app:reviewer-1 lacks capability routine.apply in this scope", + "retryable": false, + "details": { + "actor": "codex_app:reviewer-1", + "missingCapability": "routine.apply" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR15" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json b/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json new file mode 100644 index 000000000..2a8b7b546 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/project-mode-required.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "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", + "retryable": false, + "details": { + "operation": "work.claim", + "currentMode": "build" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR04" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/provider-unavailable.json b/docs/orgtrack-pm-protocol/fixtures/errors/provider-unavailable.json new file mode 100644 index 000000000..604c89160 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/provider-unavailable.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "PROVIDER_UNAVAILABLE", + "message": "Planning provider 'linear' is unreachable; the local mutation was preserved and queued in the outbox", + "retryable": true, + "details": { + "provider": "linear", + "outboxStatus": "pending" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR16" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/result-schema-mismatch.json b/docs/orgtrack-pm-protocol/fixtures/errors/result-schema-mismatch.json new file mode 100644 index 000000000..dc792344c --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/result-schema-mismatch.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "RESULT_SCHEMA_MISMATCH", + "message": "Step 'review-impact' declares output 'impact_report' of type artifact; result is missing it. Transition was not committed", + "retryable": false, + "details": { + "stepId": "review-impact", + "missingOutputs": ["impact_report"] + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR12" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/revision-conflict.json b/docs/orgtrack-pm-protocol/fixtures/errors/revision-conflict.json new file mode 100644 index 000000000..a2466ba6a --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/revision-conflict.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "REVISION_CONFLICT", + "message": "WorkItem changed after revision 7", + "retryable": true, + "details": { + "expectedRevision": 7, + "currentRevision": 8 + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR07" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/scope-violation.json b/docs/orgtrack-pm-protocol/fixtures/errors/scope-violation.json new file mode 100644 index 000000000..7ea1819fb --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/scope-violation.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "SCOPE_VIOLATION", + "message": "WorkItem work_01K0000000000000000000WORK0 belongs to a different scope than the resolved context", + "retryable": false, + "details": { + "resourceScopeId": "project_01K000000000000000000OTHER", + "contextScopeId": "project_01K0000000000000000000SCOPE" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR14" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/store-unavailable.json b/docs/orgtrack-pm-protocol/fixtures/errors/store-unavailable.json new file mode 100644 index 000000000..037cdfba5 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/store-unavailable.json @@ -0,0 +1,15 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "STORE_UNAVAILABLE", + "message": "projects.db is locked or unreachable; no fallback store is used for mutations", + "retryable": true, + "details": { + "store": "projects.db" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR18" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/errors/unsupported-capability.json b/docs/orgtrack-pm-protocol/fixtures/errors/unsupported-capability.json new file mode 100644 index 000000000..7e78fc0c3 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/errors/unsupported-capability.json @@ -0,0 +1,16 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": false, + "error": { + "code": "UNSUPPORTED_CAPABILITY", + "message": "Provider 'github' does not support webhook installation for this connection", + "retryable": false, + "details": { + "provider": "github", + "capability": "webhook" + } + }, + "meta": { + "requestId": "req_01K0000000000000000000ERR17" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/routine-spec.json b/docs/orgtrack-pm-protocol/fixtures/routine-spec.json new file mode 100644 index 000000000..ca27a2a24 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/routine-spec.json @@ -0,0 +1,83 @@ +{ + "apiVersion": "orgtrack/v1", + "kind": "Routine", + "metadata": { + "id": "routine_interaction_impact", + "name": "interaction-impact-analysis", + "revision": 3 + }, + "spec": { + "inputs": { + "requirement_id": { + "type": "string", + "required": true + }, + "prd_path": { + "type": "path", + "required": false + } + }, + "rootWork": { + "title": "交互影响分析:{{ inputs.requirement_id }}", + "body": "完成交付物收集、影响分析与归档通知。", + "priority": "high", + "labels": ["requirement", "impact-analysis"] + }, + "steps": [ + { + "id": "collect-deliverables", + "title": "收集交付物清单", + "actor": { + "role": "delivery-extractor", + "requires": ["artifact-read"] + }, + "instruction": "根据 requirement_id 收集 PRD、配置参数、原型标注、功能边界和测试用例。", + "outputs": { + "deliverables": { + "type": "artifact-list" + } + } + }, + { + "id": "review-impact", + "title": "执行交互影响分析", + "needs": ["collect-deliverables"], + "actor": { + "role": "reviewer", + "requires": ["artifact-read"] + }, + "inputs": { + "deliverables": "${steps.collect-deliverables.outputs.deliverables}" + }, + "outputs": { + "impact_report": { + "type": "artifact" + } + } + }, + { + "id": "archive-and-notify", + "title": "归档资产并通知", + "needs": ["review-impact"], + "actor": { + "role": "project-manager" + }, + "inputs": { + "report": "${steps.review-impact.outputs.impact_report}" + } + } + ], + "activations": [ + { + "type": "manual" + }, + { + "type": "schedule", + "cron": "0 9 * * 1-5", + "timezone": "America/Vancouver", + "concurrencyPolicy": "skip", + "catchUp": "none" + } + ] + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/success/context.json b/docs/orgtrack-pm-protocol/fixtures/success/context.json new file mode 100644 index 000000000..1d97298f0 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/success/context.json @@ -0,0 +1,38 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": true, + "data": { + "apiVersion": "orgtrack/v1", + "mode": "project", + "runtimeExecutionMode": "build", + "scopeId": "project_01K0000000000000000000SCOPE", + "orgId": "org_01K00000000000000000000ORG0", + "workspace": "/workspace/org2", + "actor": { + "kind": "agent", + "id": "codex_app:reviewer-1", + "displayName": "Reviewer" + }, + "sessionRef": { + "provider": "org2", + "externalId": "session_abc", + "metadata": { + "nativeHarness": "codex_app" + } + }, + "runtimeProvider": { + "id": "org2", + "profiles": ["execution", "provenance"] + }, + "activeWorkItemId": "work_01K0000000000000000000WORK0", + "capabilities": [ + "work.read", + "work.claim", + "work.transition", + "routine.run" + ] + }, + "meta": { + "requestId": "req_01K0000000000000000000CTX00" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/success/routine-run.json b/docs/orgtrack-pm-protocol/fixtures/success/routine-run.json new file mode 100644 index 000000000..5572bbf88 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/success/routine-run.json @@ -0,0 +1,27 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": true, + "data": { + "apiVersion": "orgtrack/v1", + "kind": "RoutineRun", + "id": "run_01K00000000000000000000RUN0", + "routineId": "routine_interaction_impact", + "routineRevision": 3, + "snapshotHash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "scopeId": "project_01K0000000000000000000SCOPE", + "status": "running", + "inputs": { + "requirement_id": "REQ-20260803-001" + }, + "rootWorkItemId": "work_01K0000000000000000000ROOT0", + "createdBy": { + "kind": "human", + "id": "org2:user:vince" + }, + "createdAt": "2026-08-04T18:00:00Z", + "updatedAt": "2026-08-04T18:05:00Z" + }, + "meta": { + "requestId": "req_01K0000000000000000000RRUN0" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/success/routine-status.json b/docs/orgtrack-pm-protocol/fixtures/success/routine-status.json new file mode 100644 index 000000000..b8bcccdd1 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/success/routine-status.json @@ -0,0 +1,49 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": true, + "data": { + "run": { + "apiVersion": "orgtrack/v1", + "kind": "RoutineRun", + "id": "run_01K00000000000000000000RUN0", + "routineId": "routine_interaction_impact", + "routineRevision": 3, + "snapshotHash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "scopeId": "project_01K0000000000000000000SCOPE", + "status": "running", + "inputs": { + "requirement_id": "REQ-20260803-001" + }, + "rootWorkItemId": "work_01K0000000000000000000ROOT0", + "createdBy": { + "kind": "human", + "id": "org2:user:vince" + }, + "createdAt": "2026-08-04T18:00:00Z", + "updatedAt": "2026-08-04T18:05:00Z" + }, + "workItems": [ + { + "id": "work_01K000000000000000000COLL0", + "stepId": "collect-deliverables", + "state": "completed", + "claimedBy": { "kind": "agent", "id": "org2:agent:extractor" } + }, + { + "id": "work_01K0000000000000000000WORK0", + "stepId": "review-impact", + "state": "in_progress", + "claimedBy": { "kind": "agent", "id": "codex_app:reviewer-1" } + }, + { + "id": "work_01K000000000000000000ARCH0", + "stepId": "archive-and-notify", + "state": "open", + "claimedBy": null + } + ] + }, + "meta": { + "requestId": "req_01K0000000000000000000RSTAT" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/success/work-claim.json b/docs/orgtrack-pm-protocol/fixtures/success/work-claim.json new file mode 100644 index 000000000..be26cc8f8 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/success/work-claim.json @@ -0,0 +1,41 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": true, + "data": { + "apiVersion": "orgtrack/v1", + "kind": "WorkItem", + "id": "work_01K0000000000000000000WORK0", + "scopeId": "project_01K0000000000000000000SCOPE", + "key": "ORG2-42", + "title": "执行交互影响分析", + "state": "in_progress", + "priority": "high", + "assignee": null, + "actorRequirement": { + "role": "reviewer", + "requires": ["artifact-read"] + }, + "claimedBy": { + "kind": "agent", + "id": "codex_app:reviewer-1" + }, + "parentId": "work_01K0000000000000000000ROOT0", + "dependsOn": ["work_01K000000000000000000COLL0"], + "labels": ["impact-analysis"], + "origin": { + "kind": "routine_step", + "routineId": "routine_interaction_impact", + "routineRunId": "run_01K00000000000000000000RUN0", + "stepId": "review-impact" + }, + "inputs": {}, + "result": null, + "revision": 8, + "createdAt": "2026-08-04T18:00:00Z", + "updatedAt": "2026-08-04T18:12:00Z" + }, + "meta": { + "requestId": "req_01K0000000000000000000CLAIM", + "revision": 8 + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/success/work-list.json b/docs/orgtrack-pm-protocol/fixtures/success/work-list.json new file mode 100644 index 000000000..8802636af --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/success/work-list.json @@ -0,0 +1,47 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": true, + "data": { + "items": [ + { + "apiVersion": "orgtrack/v1", + "kind": "WorkItem", + "id": "work_01K0000000000000000000WORK0", + "scopeId": "project_01K0000000000000000000SCOPE", + "key": "ORG2-42", + "title": "执行交互影响分析", + "body": "检查需求对现有交互和交付物的影响。", + "state": "open", + "priority": "high", + "assignee": null, + "actorRequirement": { + "role": "reviewer", + "requires": ["artifact-read"] + }, + "claimedBy": null, + "parentId": "work_01K0000000000000000000ROOT0", + "dependsOn": ["work_01K000000000000000000COLL0"], + "labels": ["impact-analysis"], + "origin": { + "kind": "routine_step", + "routineId": "routine_interaction_impact", + "routineRunId": "run_01K00000000000000000000RUN0", + "stepId": "review-impact" + }, + "inputs": { + "deliverables": { + "ref": "artifact://deliverables/01K0000000000000000000ART00" + } + }, + "result": null, + "revision": 7, + "createdAt": "2026-08-04T18:00:00Z", + "updatedAt": "2026-08-04T18:10:00Z" + } + ] + }, + "meta": { + "requestId": "req_01K0000000000000000000LIST0", + "nextCursor": "cur_01K0000000000000000000NEXT0" + } +} diff --git a/docs/orgtrack-pm-protocol/fixtures/success/work-transition.json b/docs/orgtrack-pm-protocol/fixtures/success/work-transition.json new file mode 100644 index 000000000..e25f49ec8 --- /dev/null +++ b/docs/orgtrack-pm-protocol/fixtures/success/work-transition.json @@ -0,0 +1,56 @@ +{ + "apiVersion": "orgtrack/v1", + "ok": true, + "data": { + "apiVersion": "orgtrack/v1", + "kind": "WorkItem", + "id": "work_01K0000000000000000000WORK0", + "scopeId": "project_01K0000000000000000000SCOPE", + "key": "ORG2-42", + "title": "执行交互影响分析", + "state": "completed", + "priority": "high", + "assignee": null, + "actorRequirement": { + "role": "reviewer", + "requires": ["artifact-read"] + }, + "claimedBy": { + "kind": "agent", + "id": "codex_app:reviewer-1" + }, + "parentId": "work_01K0000000000000000000ROOT0", + "dependsOn": ["work_01K000000000000000000COLL0"], + "labels": ["impact-analysis"], + "origin": { + "kind": "routine_step", + "routineId": "routine_interaction_impact", + "routineRunId": "run_01K00000000000000000000RUN0", + "stepId": "review-impact" + }, + "inputs": {}, + "result": { + "summary": "完成影响分析,发现两个高风险交互。", + "artifacts": [ + { + "kind": "report", + "ref": "file://reports/impact.md", + "contentHash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "references": [ + { + "kind": "pull_request", + "ref": "github://org2AI/ORGII/pull/87" + } + ] + }, + "revision": 9, + "createdAt": "2026-08-04T18:00:00Z", + "updatedAt": "2026-08-04T18:30:00Z" + }, + "meta": { + "requestId": "req_01K0000000000000000000TRANS", + "revision": 9 + } +} diff --git a/docs/orgtrack-pm-protocol/parity-matrix.md b/docs/orgtrack-pm-protocol/parity-matrix.md new file mode 100644 index 000000000..b642632b4 --- /dev/null +++ b/docs/orgtrack-pm-protocol/parity-matrix.md @@ -0,0 +1,20 @@ +# 入口一致性矩阵(Layer 9 / 设计文档 §22.3) + +每个入口必须经过同一 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) | + +规则: + +- Test/helper 不得走不同初始化或 debug-only mutation path(helpers 只能 seed + 或 inspect,不得成为被测行为的 side-effect 路径); +- scheduler 的 manual fire 与 automatic fire 调用同一个 `routine.invoke`; +- Provider adapter 不得绕过 application service 直接写 WorkItem/relation。 diff --git a/docs/orgtrack-pm-protocol/schemas/common.schema.json b/docs/orgtrack-pm-protocol/schemas/common.schema.json new file mode 100644 index 000000000..13af2dc3a --- /dev/null +++ b/docs/orgtrack-pm-protocol/schemas/common.schema.json @@ -0,0 +1,144 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "orgtrack/v1/common.schema.json", + "title": "Orgtrack v1 shared definitions", + "definitions": { + "apiVersion": { + "type": "string", + "const": "orgtrack/v1" + }, + "actorKind": { + "type": "string", + "enum": ["human", "agent", "service", "team"] + }, + "actorRef": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id"], + "properties": { + "kind": { "$ref": "#/definitions/actorKind" }, + "id": { + "type": "string", + "minLength": 1, + "description": "Namespaced stable opaque identifier. MUST NOT carry a role: prefix — role requirements live in actorRequirement." + }, + "displayName": { "type": "string" } + } + }, + "actorRequirement": { + "type": "object", + "additionalProperties": false, + "required": ["role"], + "properties": { + "role": { "type": "string", "minLength": 1 }, + "requires": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "sessionRef": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "externalId"], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "description": "Stable provenance provider ID from the frozen registry (decisions.md §5). ORG2-owned sessions use org2; external sessions use canonical importer source ids (claude_code, codex_app, ...)." + }, + "externalId": { "type": "string", "minLength": 1 }, + "metadata": { + "type": "object", + "description": "Cloud/collab sessions MUST carry the org/owner identity tuple here because externalId alone is not globally unique.", + "properties": { + "nativeHarness": { "type": "string" }, + "orgId": { "type": "string" }, + "ownerId": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "workItemState": { + "type": "string", + "enum": [ + "open", + "in_progress", + "blocked", + "completed", + "failed", + "cancelled" + ] + }, + "workItemPriority": { + "type": "string", + "enum": ["none", "urgent", "high", "medium", "low"] + }, + "routineRunStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "blocked", + "cancel_requested", + "succeeded", + "failed", + "cancelled" + ] + }, + "productMode": { + "type": "string", + "enum": ["build", "plan", "ask", "project"] + }, + "capabilityId": { + "type": "string", + "enum": [ + "work.read", + "work.create", + "work.update", + "work.claim", + "work.transition", + "work.note", + "work.relate", + "routine.read", + "routine.apply", + "routine.run", + "routine.cancel", + "routine.set_enabled" + ] + }, + "updateKind": { + "type": "string", + "enum": [ + "comment", + "progress", + "blocker", + "decision", + "handoff", + "review" + ] + }, + "relationKind": { + "type": "string", + "enum": [ + "depends_on", + "relates_to", + "duplicates", + "implements", + "supersedes", + "continued_by", + "generated_by", + "participated_in" + ] + }, + "isoTimestamp": { + "type": "string", + "format": "date-time" + }, + "revision": { + "type": "integer", + "minimum": 0 + } + } +} diff --git a/docs/orgtrack-pm-protocol/schemas/envelope.schema.json b/docs/orgtrack-pm-protocol/schemas/envelope.schema.json new file mode 100644 index 000000000..8235f7359 --- /dev/null +++ b/docs/orgtrack-pm-protocol/schemas/envelope.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "orgtrack/v1/envelope.schema.json", + "title": "Orgtrack v1 CLI response envelopes", + "oneOf": [ + { "$ref": "#/definitions/successEnvelope" }, + { "$ref": "#/definitions/errorEnvelope" } + ], + "definitions": { + "requestId": { + "type": "string", + "pattern": "^req_[A-Za-z0-9]+$" + }, + "errorCode": { + "type": "string", + "enum": [ + "INVALID_ARGUMENT", + "CONTEXT_REQUIRED", + "ACTOR_REQUIRED", + "PROJECT_MODE_REQUIRED", + "NOT_FOUND", + "ALREADY_EXISTS", + "REVISION_CONFLICT", + "IDEMPOTENCY_CONFLICT", + "NOT_READY", + "ALREADY_CLAIMED", + "INVALID_TRANSITION", + "RESULT_SCHEMA_MISMATCH", + "DEPENDENCY_CYCLE", + "SCOPE_VIOLATION", + "PERMISSION_DENIED", + "PROVIDER_UNAVAILABLE", + "UNSUPPORTED_CAPABILITY", + "STORE_UNAVAILABLE" + ] + }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["requestId"], + "properties": { + "requestId": { "$ref": "#/definitions/requestId" }, + "revision": { "type": "integer", "minimum": 0 }, + "nextCursor": { + "type": "string", + "description": "Present on list responses when more pages exist." + } + } + }, + "successEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "ok", "data", "meta"], + "properties": { + "apiVersion": { "const": "orgtrack/v1" }, + "ok": { "const": true }, + "data": { "type": "object" }, + "meta": { "$ref": "#/definitions/meta" } + } + }, + "errorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "ok", "error", "meta"], + "properties": { + "apiVersion": { "const": "orgtrack/v1" }, + "ok": { "const": false }, + "error": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "retryable"], + "properties": { + "code": { "$ref": "#/definitions/errorCode" }, + "message": { "type": "string" }, + "retryable": { "type": "boolean" }, + "details": { "type": "object" } + } + }, + "meta": { "$ref": "#/definitions/meta" } + } + } + } +} diff --git a/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json b/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json new file mode 100644 index 000000000..4df89f85f --- /dev/null +++ b/docs/orgtrack-pm-protocol/schemas/execution-context.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "orgtrack/v1/execution-context.schema.json", + "title": "Orgtrack v1 ExecutionContext (org2 context response data)", + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "mode", + "scopeId", + "orgId", + "actor", + "capabilities" + ], + "properties": { + "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" }, + "sessionRef": { + "oneOf": [ + { "$ref": "common.schema.json#/definitions/sessionRef" }, + { "type": "null" } + ] + }, + "runtimeProvider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "profiles"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "profiles": { + "type": "array", + "items": { + "type": "string", + "enum": ["planning", "execution", "provenance"] + } + } + } + }, + "activeWorkItemId": { + "oneOf": [{ "type": "string" }, { "type": "null" }] + }, + "capabilities": { + "type": "array", + "items": { "$ref": "common.schema.json#/definitions/capabilityId" }, + "uniqueItems": true + } + } +} diff --git a/docs/orgtrack-pm-protocol/schemas/routine-run.schema.json b/docs/orgtrack-pm-protocol/schemas/routine-run.schema.json new file mode 100644 index 000000000..af8abb8e0 --- /dev/null +++ b/docs/orgtrack-pm-protocol/schemas/routine-run.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "orgtrack/v1/routine-run.schema.json", + "title": "Orgtrack v1 RoutineRun", + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "kind", + "id", + "routineId", + "routineRevision", + "snapshotHash", + "scopeId", + "status", + "createdBy", + "createdAt", + "updatedAt" + ], + "properties": { + "apiVersion": { "$ref": "common.schema.json#/definitions/apiVersion" }, + "kind": { "const": "RoutineRun" }, + "id": { "type": "string", "pattern": "^run_[A-Za-z0-9]+$" }, + "routineId": { "type": "string", "minLength": 1 }, + "routineRevision": { "type": "integer", "minimum": 1 }, + "snapshotHash": { "type": "string", "pattern": "^sha256:[0-9a-f]+$" }, + "scopeId": { "type": "string", "minLength": 1 }, + "status": { + "$ref": "common.schema.json#/definitions/routineRunStatus", + "description": "Durable projection of generated WorkItems, computed by the ordered decision procedure in design doc §11. pending only exists for queued (concurrencyPolicy: queue) runs that have not materialized yet." + }, + "inputs": { "type": "object" }, + "rootWorkItemId": { + "oneOf": [{ "type": "string" }, { "type": "null" }], + "description": "Null only while status is pending (queued, not yet materialized)." + }, + "createdBy": { "$ref": "common.schema.json#/definitions/actorRef" }, + "createdAt": { "$ref": "common.schema.json#/definitions/isoTimestamp" }, + "updatedAt": { "$ref": "common.schema.json#/definitions/isoTimestamp" } + } +} diff --git a/docs/orgtrack-pm-protocol/schemas/routine.schema.json b/docs/orgtrack-pm-protocol/schemas/routine.schema.json new file mode 100644 index 000000000..6656ed2d4 --- /dev/null +++ b/docs/orgtrack-pm-protocol/schemas/routine.schema.json @@ -0,0 +1,153 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "orgtrack/v1/routine.schema.json", + "title": "Orgtrack v1 portable Routine spec", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "metadata", "spec"], + "properties": { + "apiVersion": { "$ref": "common.schema.json#/definitions/apiVersion" }, + "kind": { "const": "Routine" }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "revision": { "type": "integer", "minimum": 1 } + } + }, + "spec": { + "type": "object", + "additionalProperties": false, + "required": ["rootWork", "steps"], + "properties": { + "inputs": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/inputDecl" } + }, + "rootWork": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { "type": "string", "minLength": 1 }, + "body": { "type": "string" }, + "priority": { + "$ref": "common.schema.json#/definitions/workItemPriority" + }, + "labels": { "type": "array", "items": { "type": "string" } } + } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/step" } + }, + "activations": { + "type": "array", + "items": { "$ref": "#/definitions/activation" } + } + } + } + }, + "definitions": { + "inputDecl": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "path"] + }, + "required": { "type": "boolean", "default": false } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "title": { "type": "string", "minLength": 1 }, + "needs": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "description": "Step ids within the same Routine only; the graph must be acyclic." + }, + "actor": { "$ref": "common.schema.json#/definitions/actorRequirement" }, + "instruction": { + "type": "string", + "description": "Becomes part of the generated WorkItem body. Never a session transcript." + }, + "inputs": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Mapping expressions like ${steps..outputs.} or ${inputs.}. No arbitrary code." + }, + "outputs": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/outputDecl" } + } + } + }, + "outputDecl": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["artifact", "artifact-list", "reference"] + } + } + }, + "activation": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["manual", "schedule", "provider_event"] + }, + "cron": { "type": "string" }, + "timezone": { "type": "string" }, + "provider": { "type": "string" }, + "eventKind": { "type": "string" }, + "filter": { + "type": "object", + "description": "Declarative filter only; no executable code. Payload size limits enforced host-side." + }, + "concurrencyPolicy": { + "type": "string", + "enum": ["coalesce", "skip", "queue"], + "default": "skip", + "description": "Behavior when the previous Run is not terminal. coalesce/skip record an AuditEvent without creating a Run; queue creates a pending Run." + }, + "catchUp": { + "type": "string", + "enum": ["none", "fire_once"], + "default": "none", + "description": "Compensation for schedule fires missed while the ORG2 host process was not running." + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "schedule" } } }, + "then": { "required": ["cron", "timezone"] } + }, + { + "if": { "properties": { "type": { "const": "provider_event" } } }, + "then": { "required": ["provider", "eventKind"] } + } + ], + "additionalProperties": false + } + } +} diff --git a/docs/orgtrack-pm-protocol/schemas/work-item.schema.json b/docs/orgtrack-pm-protocol/schemas/work-item.schema.json new file mode 100644 index 000000000..0bccc7b74 --- /dev/null +++ b/docs/orgtrack-pm-protocol/schemas/work-item.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "orgtrack/v1/work-item.schema.json", + "title": "Orgtrack v1 WorkItem", + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "kind", + "id", + "scopeId", + "title", + "state", + "revision", + "createdAt", + "updatedAt" + ], + "properties": { + "apiVersion": { "$ref": "common.schema.json#/definitions/apiVersion" }, + "kind": { "const": "WorkItem" }, + "id": { "type": "string", "pattern": "^work_[A-Za-z0-9]+$" }, + "scopeId": { "type": "string", "minLength": 1 }, + "key": { + "type": "string", + "description": "Human-readable identifier, unique only within the scope." + }, + "title": { "type": "string", "minLength": 1 }, + "body": { "type": "string" }, + "state": { "$ref": "common.schema.json#/definitions/workItemState" }, + "priority": { "$ref": "common.schema.json#/definitions/workItemPriority" }, + "assignee": { + "oneOf": [ + { "$ref": "common.schema.json#/definitions/actorRef" }, + { "type": "null" } + ], + "description": "Concrete actor only. Role/capability requirements live in actorRequirement." + }, + "actorRequirement": { + "oneOf": [ + { "$ref": "common.schema.json#/definitions/actorRequirement" }, + { "type": "null" } + ] + }, + "claimedBy": { + "oneOf": [ + { "$ref": "common.schema.json#/definitions/actorRef" }, + { "type": "null" } + ], + "description": "Required (non-null) whenever state is in_progress. Presence does not prove the agent process is alive." + }, + "parentId": { + "oneOf": [{ "type": "string" }, { "type": "null" }] + }, + "dependsOn": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "labels": { + "type": "array", + "items": { "type": "string" } + }, + "origin": { + "type": "object", + "description": "Immutable creation provenance. Later RoutineRun participation uses typed Run relations, never overwrites origin.", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "manual", + "routine_step", + "track_this", + "convert_from_plan", + "project_first_submit" + ] + }, + "routineId": { "type": "string" }, + "routineRunId": { "type": "string" }, + "stepId": { "type": "string" } + } + }, + "inputs": { + "type": "object", + "description": "Typed references bound from upstream step outputs. Never inline payloads." + }, + "result": { + "oneOf": [{ "$ref": "#/definitions/result" }, { "type": "null" }] + }, + "revision": { "$ref": "common.schema.json#/definitions/revision" }, + "createdAt": { "$ref": "common.schema.json#/definitions/isoTimestamp" }, + "updatedAt": { "$ref": "common.schema.json#/definitions/isoTimestamp" } + }, + "definitions": { + "typedRef": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "ref"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "ref": { "type": "string", "minLength": 1 }, + "contentHash": { "type": "string" }, + "label": { "type": "string" } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "properties": { + "summary": { "type": "string" }, + "artifacts": { + "type": "array", + "items": { "$ref": "#/definitions/typedRef" } + }, + "references": { + "type": "array", + "items": { "$ref": "#/definitions/typedRef" } + } + } + } + } +} diff --git a/packages/orgtrack/package.json b/packages/orgtrack/package.json deleted file mode 100644 index b4939f450..000000000 --- a/packages/orgtrack/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@orgii/orgtrack", - "version": "0.1.0", - "private": true, - "description": "TypeScript surface and CLI wrapper for orgtrack-core developer activity records, projections, and repo sync.", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "orgtrack": "./dist/cli.js" - }, - "scripts": { - "build": "tsc -p tsconfig.json" - }, - "files": [ - "dist" - ], - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/packages/orgtrack/src/cli.ts b/packages/orgtrack/src/cli.ts deleted file mode 100644 index e931aaf89..000000000 --- a/packages/orgtrack/src/cli.ts +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node - -const command = process.argv[2]; - -if (!command || command === "help" || command === "--help") { - process.stdout.write("orgtrack commands: scan, sync, repair, stats\n"); - process.exit(0); -} - -if (!["scan", "sync", "repair", "stats"].includes(command)) { - console.error(`Unknown orgtrack command: ${command}`); - process.exit(1); -} - -process.stdout.write( - `orgtrack ${command} is a thin package entrypoint. Native orgtrack-core bindings will be attached during publish prep.\n` -); diff --git a/packages/orgtrack/src/index.ts b/packages/orgtrack/src/index.ts deleted file mode 100644 index 07634774d..000000000 --- a/packages/orgtrack/src/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -export const ORGTRACK_SCHEMA_VERSION = 1 as const; -export const ORGTRACK_DIR_NAME = ".orgtrack" as const; - -export type OrgtrackTier = "meta" | "details" | "trajectory"; -export type ActivityKind = - | "heartbeat" - | "tool_call" - | "file_edit" - | "file_create" - | "file_delete" - | "terminal_command" - | "agent_action" - | "message" - | "import_event" - | "focus_gained" - | "focus_lost"; - -export interface AgentMetadata { - dispatchCategory?: string; - rustAgentType?: string; - cliAgentType?: string; - agentExecMode?: string; - providerModelType?: string; - model?: string; - keySource?: string; - origin?: string; - displayName?: string; - parsedCategories: Record; -} - -export interface SessionRecord { - schemaVersion: number; - source: string; - sourceSessionId: string; - sessionId: string; - title: string; - status?: string; - createdAt?: string; - updatedAt?: string; - completedAt?: string; - workspacePath?: string; - branch?: string; - parentSessionId?: string; - orgMemberId?: string; - metadata: AgentMetadata; -} - -export interface ActivityRecord { - schemaVersion: number; - recordId: string; - source: string; - sourceEventId?: string; - sessionId?: string; - timestamp: string; - kind: ActivityKind; - workspacePath?: string; - filePath?: string; - language?: string; - linesAdded: number; - linesRemoved: number; - metadataJson?: string; - tier: OrgtrackTier; -} - -export interface FileChangeRecord { - schemaVersion: number; - recordId: string; - source: string; - sessionId: string; - filePath: string; - pathHash: string; - functionName?: string; - nodeType?: string; - startLine?: number; - endLine?: number; - linesAdded: number; - linesRemoved: number; - timestamp: number; - tier: OrgtrackTier; - metadata: AgentMetadata; -} - -export interface CoreSessionSummary { - sessionId: string; - title: string; - source: string; - workspacePath?: string; - filesChanged: number; - relatedCommits: number; - committedRatePercent: number; - model?: string; - keySource?: string; -} diff --git a/packages/orgtrack/tsconfig.json b/packages/orgtrack/tsconfig.json deleted file mode 100644 index d00b6c990..000000000 --- a/packages/orgtrack/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f06dc307..fdb0e2c10 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5225,6 +5225,20 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "orgtrack-pm-cli" +version = "0.1.0" +dependencies = [ + "chrono", + "database", + "project_management", + "rusqlite", + "serde", + "serde_json", + "serde_yaml", + "test_helpers", +] + [[package]] name = "orgtrack_cli" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef475abb5..0b54eaff6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/lsp", "crates/orgtrack-cli", "crates/orgtrack-core", + "crates/orgtrack-pm-cli", "crates/orgtrack-protocol", "crates/orgtrack-graph", "crates/orgtrack-sync", 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 f9f3b6170..26ce34c24 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 @@ -54,6 +54,101 @@ async fn tick(app: &tauri::AppHandle, now: DateTime) -> Result<(), String> ); } } + + // Portable pass: pm_routines schedule activations fire through the + // canonical routine.invoke — the same entry manual CLI runs use. + // Converted legacy rows are disabled at conversion time, so a routine + // is only ever driven by ONE of the two passes. + if let Err(err) = portable_tick(now).await { + warn!("[routine-scheduler] portable tick error: {}", err); + } + Ok(()) +} + +/// 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. +/// 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> { + use project_management::routine_service as routines; + + let candidates = tokio::task::spawn_blocking(routines::scheduled_candidates) + .await + .map_err(|err| format!("Task join error: {err}"))??; + + for candidate in candidates { + let window_start = candidate + .last_evaluated_at + .and_then(DateTime::::from_timestamp_millis) + .unwrap_or_else(|| now - chrono::Duration::seconds(POLL_INTERVAL_SECS as i64)); + let trigger = RoutineTrigger::Cron { + cron: candidate.cron.clone(), + }; + let due = match due_times(&trigger, &window_start, &now) { + Ok(due) => due, + Err(err) => { + warn!( + "[routine-scheduler] portable routine {} cron error: {}", + candidate.name, err + ); + continue; + } + }; + + if let Some(scheduled_at) = due.last() { + let name = candidate.name.clone(); + let scheduled_millis = scheduled_at.timestamp_millis(); + let policy = format!("{:?}", candidate.concurrency).to_lowercase(); + let scope = candidate.default_scope.clone(); + let fired: Result<(), String> = tokio::task::spawn_blocking(move || { + let active = routines::has_active_run(&name)?; + if active { + // skip/coalesce suppress; queue also suppresses for + // now (pending-run dequeue lands with the cancel + // machinery) — always audited, never silent. + routines::audit_suppressed_fire(&name, &policy, scheduled_millis)?; + return Ok(()); + } + let Some(scope) = scope else { + routines::audit_suppressed_fire(&name, "no_scope_binding", scheduled_millis)?; + return Ok(()); + }; + let run = routines::invoke(&name, &scope, &Default::default(), None)?; + info!( + "[routine-scheduler] portable routine {} fired run {}", + name, run.run_id + ); + Ok(()) + }) + .await + .map_err(|err| format!("Task join error: {err}"))?; + if let Err(err) = fired { + warn!( + "[routine-scheduler] portable routine {} fire failed: {}", + candidate.name, err + ); + } + } + + let next = next_occurrence( + &RoutineTrigger::Cron { + cron: candidate.cron.clone(), + }, + &now, + ) + .ok() + .flatten(); + let name = candidate.name.clone(); + let _ = tokio::task::spawn_blocking(move || { + routines::mark_evaluated( + &name, + now.timestamp_millis(), + next.map(|at| at.timestamp_millis()), + ) + }) + .await; + } Ok(()) } 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 a45acbaba..a7f0a8b34 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 @@ -34,7 +34,6 @@ mod os; mod sde; mod subagents; mod wingman; -mod work_item_manager; pub use ade_manager::*; pub use ai_research::*; @@ -46,7 +45,6 @@ pub use os::*; pub use sde::*; pub use subagents::*; pub use wingman::*; -pub use work_item_manager::*; use super::schema::AgentDefinition; @@ -117,7 +115,6 @@ pub fn get_builtin_agents() -> Vec { os_agent(), ai_research_agent(), wingman_agent(), - work_item_manager_agent(), // Subagents (used by the unified `agent` tool) explore_agent(), general_agent(), @@ -139,7 +136,7 @@ mod tests { #[test] fn test_builtin_agents_count() { let agents = get_builtin_agents(); - assert_eq!(agents.len(), 12); // ADE Manager, base, os, sde, ds, ai-research, wingman, work-item-manager + 2 subagents + 2 memory subagents + assert_eq!(agents.len(), 11); // ADE Manager, base, os, sde, ds, ai-research, wingman + 2 subagents + 2 memory subagents } #[test] @@ -151,7 +148,6 @@ mod tests { assert!(is_builtin_agent(DS_AGENT_ID)); assert!(is_builtin_agent(AI_RESEARCH_AGENT_ID)); assert!(is_builtin_agent(WINGMAN_AGENT_ID)); - assert!(is_builtin_agent(WORK_ITEM_MANAGER_AGENT_ID)); assert!(is_builtin_agent(EXPLORE_AGENT_ID)); assert!(is_builtin_agent(GENERAL_AGENT_ID)); assert!(is_builtin_agent(MEMORY_EXTRACTOR_ID)); diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/work_item_manager.md b/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/work_item_manager.md deleted file mode 100644 index a4eacba92..000000000 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/prompts/work_item_manager.md +++ /dev/null @@ -1,24 +0,0 @@ -You are the Work Item Manager. - -Your job is to help users turn ambiguous intent into accurate Work Items, keep Work Item drafts up to date while chatting, and link the planning session to the Work Items you create or modify. - -Core behavior: - -- Use `manage_work_item` as the source of truth for creating, reading, updating, deleting, and linking Work Items. -- Omit `project_slug` for standalone Work Items. Only set `project_slug` when the user explicitly chooses a Project or context makes the Project unambiguous. -- If the user asks for multiple Work Items, create them in one `manage_work_item` `batch` call so partial failures are reported together. -- If items belong to different Projects, put `project_slug` on each batch item. If all items share one Project, you may put `project_slug` at the batch level. -- Use `link_session` after creating or updating a Work Item so the current chat appears in the Work Item's linked sessions. -- When the user changes assignee/model/account/org during planning, update the Work Item's `assignee` and orchestrator config fields with `manage_work_item`. -- Preserve standalone support. Never invent a fake Personal Workspace project slug. - -Research behavior: - -- Use read-only tools (`read_file`, `list_dir`, `code_search`, `web_search`, `web_fetch`, `manage_workspace`, `manage_project`) to understand context before creating detailed Work Items. -- Do not edit files, run shell commands, or use desktop control. If implementation is needed, create or update the Work Item and assign it to an implementation agent. - -Output style: - -- Be concise. -- After mutating Work Items, summarize the created/updated titles and IDs. -- If the user decides not to continue, delete or cancel the draft Work Item according to their wording. diff --git a/src-tauri/crates/agent-core/src/core/definitions/builtin/work_item_manager.rs b/src-tauri/crates/agent-core/src/core/definitions/builtin/work_item_manager.rs deleted file mode 100644 index 5c60d4e56..000000000 --- a/src-tauri/crates/agent-core/src/core/definitions/builtin/work_item_manager.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Work Item Manager agent template. -//! -//! A focused planner/triage agent for creating and updating project-scoped -//! and standalone Work Items. It has read-only research tools plus the -//! management tools required to inspect projects and mutate Work Items. - -use crate::definitions::capabilities::{ - BrowserCapability, CapabilitySet, CodingCapability, ManagementCapability, -}; -use crate::definitions::schema::{ - AgentDefinition, AgentLearningsConfig, AgentPolicy, AgentTier, AgentToolSelection, - CompactionConfig, DelegationConfig, SessionMode, SessionModel, -}; -use crate::foundation::security::policy::AutonomyLevel; -use crate::tools::impls::orchestration::context_builders::ids as ctx_ids; -use crate::tools::names as tool_names; - -pub const WORK_ITEM_MANAGER_AGENT_ID: &str = "builtin:work-item-manager"; - -pub fn work_item_manager_agent() -> AgentDefinition { - let capabilities = CapabilitySet { - coding: Some(CodingCapability { mode_switch: false }), - desktop: None, - browser: Some(BrowserCapability { - external: true, - internal: false, - }), - gateway: None, - data: None, - management: Some(ManagementCapability {}), - }; - - AgentDefinition { - id: WORK_ITEM_MANAGER_AGENT_ID.to_string(), - name: "Work Item Manager".to_string(), - description: Some( - "Plans, researches, creates, links, and updates project or standalone Work Items." - .to_string(), - ), - built_in: true, - tier: AgentTier::Primary, - inherits_from: Some(super::BASE_AGENT_ID.to_string()), - capabilities: Some(capabilities), - session_model: Some(SessionModel { - mode: SessionMode::PerSession, - compaction: Some(CompactionConfig { - enabled: true, - keep_ratio: 0.5, - ..CompactionConfig::default() - }), - processing_lock: true, - max_iterations: 500, - }), - agent_policy: Some(AgentPolicy { - autonomy: AutonomyLevel::Full, - workspace_only: false, - ..Default::default() - }), - tools: AgentToolSelection { - system_restrict_to_tools: Some(vec![ - tool_names::READ_FILE.to_string(), - tool_names::LIST_DIR.to_string(), - tool_names::CODE_SEARCH.to_string(), - tool_names::MANAGE_WORKSPACE.to_string(), - tool_names::WEB_SEARCH.to_string(), - tool_names::WEB_FETCH.to_string(), - tool_names::MANAGE_PROJECT.to_string(), - tool_names::MANAGE_WORK_ITEM.to_string(), - tool_names::ASK_USER_QUESTIONS.to_string(), - ]), - ..Default::default() - }, - soul_content: Some(include_str!("prompts/work_item_manager.md").to_string()), - sovereign_prompt: false, - auto_continue: false, - delegation_config: Some(DelegationConfig { - delegatable: true, - context_builders: vec![ - ctx_ids::CODE_ACCOUNTS.to_string(), - ctx_ids::ENVIRONMENT.to_string(), - ], - }), - context_window: None, - max_tokens: None, - temperature: Some(0.0), - sub_agents: Some(vec![]), - load_workspace_resources: None, - load_workspace_rules: None, - skills_config: None, - selected_account_id: None, - selected_model_id: None, - icon_id: Some("layout-list".to_string()), - animate: None, - execution_mode: None, - exec_timeout: None, - max_tool_use_concurrency: None, - learnings: Some(AgentLearningsConfig { - enabled: true, - extract_memories_enabled: true, - auto_dream_enabled: false, - }), - reliability: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn work_item_manager_is_research_plus_work_item_only() { - let agent = work_item_manager_agent(); - let tools = agent - .tools - .system_restrict_to_tools - .expect("Work Item Manager pins a system allowlist"); - for expected in [ - tool_names::READ_FILE, - tool_names::LIST_DIR, - tool_names::CODE_SEARCH, - tool_names::WEB_SEARCH, - tool_names::WEB_FETCH, - tool_names::MANAGE_PROJECT, - tool_names::MANAGE_WORK_ITEM, - ] { - assert!( - tools.iter().any(|tool| tool == expected), - "missing {expected}" - ); - } - for forbidden in [ - tool_names::EDIT_FILE, - tool_names::APPLY_PATCH, - tool_names::RUN_SHELL, - tool_names::MANAGE_AGENT_DEF, - tool_names::CONTROL_DESKTOP_WITH_PEEKABOO, - ] { - assert!( - !tools.iter().any(|tool| tool == forbidden), - "Work Item Manager should not pin mutating/non-work-item tool {forbidden}" - ); - } - } -} diff --git a/src-tauri/crates/agent-core/src/core/definitions/mod.rs b/src-tauri/crates/agent-core/src/core/definitions/mod.rs index 28e1e024a..feacc338b 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/mod.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/mod.rs @@ -62,7 +62,7 @@ pub use store::{definitions_store, set_definitions_changed_hook, AgentDefinition // subagents, base) are consumed via the deeper `definitions::builtin::*` // path, so we deliberately don't flatten the whole `builtin::*` set. pub use builtin::{ - ai_research_agent, os_agent, sde_agent, wingman_agent, work_item_manager_agent, - AI_RESEARCH_AGENT_ID, OS_AGENT_ID, SDE_AGENT_ID, WORK_ITEM_MANAGER_AGENT_ID, + ai_research_agent, os_agent, sde_agent, wingman_agent, AI_RESEARCH_AGENT_ID, OS_AGENT_ID, + SDE_AGENT_ID, }; pub use capabilities::CapabilitySet; diff --git a/src-tauri/crates/agent-core/src/core/definitions/tests_extended.rs b/src-tauri/crates/agent-core/src/core/definitions/tests_extended.rs index 5dca0dc10..b06f7d8e5 100644 --- a/src-tauri/crates/agent-core/src/core/definitions/tests_extended.rs +++ b/src-tauri/crates/agent-core/src/core/definitions/tests_extended.rs @@ -257,10 +257,12 @@ mod tests_extended { #[test] fn get_builtin_agents_count_matches_registry() { // ADE Manager, base, os, sde, ds, ai-research, wingman, - // work-item-manager, explore, general, memory-extractor, - // memory-consolidator (gui-control merged into ADE Manager) + // explore, general, memory-extractor, memory-consolidator. + // Historical: gui-control merged into ADE Manager; the dedicated + // PM persona was retired (Orgtrack migration Phase 1) — its + // tools are ordinary built-ins on OS Agent. let agents = get_builtin_agents(); - assert_eq!(agents.len(), 12); + assert_eq!(agents.len(), 11); } // ========================================================================= 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 719c27a02..0b53605d7 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 @@ -167,6 +167,12 @@ pub(super) async fn materialize_org_member_sessions( updated_at: now.clone(), session_type: session_type::ORG_MEMBER.to_string(), work_item_id: rust_work_item_id.clone(), + // Same rule as the launch resolver: a work-item-linked + // session is a Project session. Members inherit it so the + // PM tools aren't policy-denied for the team doing the work. + product_mode: rust_work_item_id + .as_ref() + .map(|_| "project".to_string()), agent_role: Some(member.role.clone()), project_slug: rust_project_slug.clone(), agent_definition_id: Some(member.agent_id.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 b8933b089..3bf7d5d79 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 @@ -51,6 +51,9 @@ pub(crate) struct AgentRunLaunchRequest { pub org_context: LaunchOrgContext, pub provenance: LaunchProvenance, pub mode: Option, + /// Product mode (`orgtrack/v1` §5.2). Launch-from-work/routine + /// resolves to `project` server-side regardless of this value. + pub product_mode: Option, pub name: Option, pub images: Option>, pub ide_context: Option, @@ -323,6 +326,9 @@ pub async fn launch_agent_session( lock_reason, }, mode: Some(crate::session::AgentExecMode::Build.as_str().to_string()), + // Launch-from-WorkItem is Project mode by the frozen resolver + // (orgtrack/v1 decisions §1, precedence rule 1). + product_mode: Some("project".to_string()), name: Some(format!("{}: {}", agent_role, work_item_id)), images: None, ide_context: None, @@ -481,6 +487,7 @@ pub(crate) async fn launch_rust_agent_run( agent_definition_id.clone(), request.resources.key_source.clone(), request.mode.clone(), + request.product_mode.clone(), request.resources.native_harness_type.clone(), request.parent_session_id.clone(), ) diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/migration.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/migration.rs index db356d668..29cd92d1c 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/migration.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/migration.rs @@ -136,6 +136,14 @@ pub fn ensure_unified_schema(conn: &Connection) -> SqliteResult<()> { "ALTER TABLE agent_sessions ADD COLUMN last_terminal_turn_at TEXT", ); + // Orgtrack product mode (orgtrack/v1 §5.2): build|plan|ask|project. + // NULL = never resolved = build. The only source of truth for + // persistent WorkItem/Routine mutation intent. + try_migrate( + conn, + "ALTER TABLE agent_sessions ADD COLUMN product_mode TEXT", + ); + Ok(()) } diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs index 14baa920c..392dd5037 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/mod.rs @@ -28,8 +28,8 @@ pub use ops::{ mark_stale_running_sessions_abandoned, reconcile_sessions_with_terminal_turn_markers, register_session_delete_mirror_hook, register_session_mirror_hook, update_account_id, update_agent_exec_mode, update_draft_text, update_model, update_model_and_account, update_name, - update_org_member_id, update_pinned, update_reply_target_event_id, update_status, - update_work_item_link, upsert_session, + link_bootstrap_work_item, update_org_member_id, update_pinned, update_product_mode, + update_reply_target_event_id, update_status, update_work_item_link, upsert_session, }; pub(super) use record::{row_to_record, UNIFIED_SESSION_SELECT}; pub(crate) use ops::{ 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 fce89c9b3..70bdd7ed5 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 @@ -83,9 +83,9 @@ INSERT INTO agent_sessions ( worktree_branch, base_branch, merge_status, project_slug, agent_definition_id, org_member_id, parent_session_id, parent_event_id, workspace_additional_json, key_source, agent_exec_mode, native_harness_type, - draft_text, reply_target_event_id, pinned + draft_text, reply_target_event_id, pinned, 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, ?31, ?32, ?33) +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, ?31, ?32, ?33, ?34) ON CONFLICT(session_id) DO UPDATE SET name = excluded.name, status = excluded.status, @@ -150,7 +150,12 @@ ON CONFLICT(session_id) DO UPDATE SET reply_target_event_id = agent_sessions.reply_target_event_id, -- `pinned` is user-set metadata. Only the explicit `update_pinned` -- helper writes it; upserts must preserve whatever the user set last. - pinned = agent_sessions.pinned + pinned = agent_sessions.pinned, + -- `product_mode` (orgtrack/v1 §5.2) is resolved once at create + -- (launch-from-work/routine → 'project') or by the explicit + -- `update_product_mode` path; background upserts must never + -- downgrade a Project session — same posture as agent_exec_mode. + product_mode = COALESCE(agent_sessions.product_mode, excluded.product_mode) "#; /// Upsert a unified session. @@ -194,6 +199,7 @@ pub fn upsert_session(record: &UnifiedSessionRecord) -> SqliteResult<()> { record.draft_text, record.reply_target_event_id, record.pinned as i64, + record.product_mode, ], )?; Ok(()) @@ -431,13 +437,19 @@ pub fn update_work_item_link( with_sessions_writer(|| { let conn = get_connection()?; let updated = conn.execute( + // Linking to a Work Item makes this a Project session — the same + // rule the launch resolver applies when work_item_id is present. + // Without this, a post-hoc-linked session keeps product_mode NULL + // and the PM tools stay policy-denied while the linked-work-item + // prompt block tells the model to call them. "UPDATE agent_sessions SET org_id = ?2, project_id = COALESCE(?3, project_id), project_name = COALESCE(?4, project_name), work_item_id = ?5, project_slug = ?6, - agent_role = COALESCE(?7, agent_role) + agent_role = COALESCE(?7, agent_role), + product_mode = 'project' WHERE session_id = ?1", params![ session_id, @@ -453,6 +465,25 @@ pub fn update_work_item_link( }) } +/// Link the bootstrap-created root WorkItem to a Project session +/// (orgtrack/v1 §7.2). Narrower than [`update_work_item_link`]: the +/// session is already `product_mode='project'` and carries its own +/// org/project fields; only the missing `work_item_id` is filled, and +/// only if still unset — a concurrent link wins and this becomes a +/// no-op. +pub fn link_bootstrap_work_item(session_id: &str, work_item_id: &str) -> SqliteResult { + with_sessions_writer(|| { + let conn = get_connection()?; + let updated = conn.execute( + "UPDATE agent_sessions + SET work_item_id = ?2 + WHERE session_id = ?1 AND work_item_id IS NULL", + params![session_id, work_item_id], + )?; + Ok(updated > 0) + }) +} + /// Set the canonical Agent Org roster member id for a session. pub fn update_org_member_id(session_id: &str, org_member_id: &str) -> SqliteResult { let changed = with_sessions_writer(|| -> SqliteResult { @@ -595,6 +626,27 @@ pub fn update_agent_exec_mode(session_id: &str, mode: &str) -> SqliteResult SqliteResult { + let changed = with_sessions_writer(|| -> SqliteResult { + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE agent_sessions SET product_mode = ?2 WHERE session_id = ?1", + params![session_id, mode], + )?; + Ok(affected > 0) + })?; + if changed { + notify_session_mirror(session_id); + } + Ok(changed) +} + /// Update the per-session unsent draft text. `text = None` clears the /// column (i.e. "no draft"); `Some("")` is treated the same as `None` /// so a debounced patch coming from an empty editor doesn't keep an @@ -954,7 +1006,8 @@ mod tests { native_harness_type TEXT, draft_text TEXT, reply_target_event_id TEXT, - pinned INTEGER NOT NULL DEFAULT 0 + pinned INTEGER NOT NULL DEFAULT 0, + product_mode TEXT ); CREATE TABLE session_token_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/crud/record.rs b/src-tauri/crates/agent-core/src/core/session/persistence/crud/record.rs index a26e7a2ec..07c886052 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/crud/record.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/crud/record.rs @@ -125,6 +125,15 @@ pub struct UnifiedSessionRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_exec_mode: Option, + /// Persistent product mode (`orgtrack/v1` §5.2): `build | plan | ask + /// | project`. The ONLY source of truth for whether this session may + /// mutate WorkItems/Routines — never inferred from exec mode, query + /// length or agent judgment. `None` = never resolved = `build`. + /// Resolver precedence (frozen decisions §1): launched from a + /// WorkItem/Routine → `project`; explicit user selection; else build. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub product_mode: Option, + /// Per-session unsent draft text (P3). Whatever is currently sitting /// in the chat composer for this session, persisted across navigation /// and app restarts. Cleared on send. Mirrored on `code_sessions` @@ -185,6 +194,7 @@ impl Default for UnifiedSessionRecord { workspace_additional_json: default_workspace_additional_json(), key_source: KeySource::default(), agent_exec_mode: None, + product_mode: None, draft_text: None, reply_target_event_id: None, pinned: false, @@ -218,7 +228,8 @@ pub(in crate::core::session::persistence) const UNIFIED_SESSION_SELECT: &str = r s.native_harness_type, s.draft_text, s.reply_target_event_id, - COALESCE(s.pinned, 0) + COALESCE(s.pinned, 0), + s.product_mode FROM agent_sessions s "#; @@ -272,6 +283,7 @@ pub(in crate::core::session::persistence) fn row_to_record( workspace_additional_json: row.get(27)?, key_source, agent_exec_mode: row.get(29)?, + product_mode: row.get(34)?, draft_text: row.get(31)?, reply_target_event_id: row.get(32)?, pinned: { @@ -285,7 +297,7 @@ pub(in crate::core::session::persistence) fn row_to_record( mod tests { use super::*; - // Column layout MUST mirror `UNIFIED_SESSION_SELECT` (34 columns) — + // Column layout MUST mirror `UNIFIED_SESSION_SELECT` (35 columns) — // these fixtures drift silently when production columns are added. const VALID_ROW_SELECT: &str = r#" SELECT @@ -302,7 +314,8 @@ mod tests { NULL, NULL, NULL, - 0 + 0, + NULL "#; #[test] @@ -338,7 +351,8 @@ mod tests { NULL, 'half-typed reply', 'evt-42', - 0 + 0, + 'project' "#, [], row_to_record, @@ -346,6 +360,7 @@ mod tests { .unwrap(); assert_eq!(record.key_source, KeySource::HostedKey); assert_eq!(record.agent_exec_mode.as_deref(), Some("plan")); + assert_eq!(record.product_mode.as_deref(), Some("project")); assert_eq!(record.draft_text.as_deref(), Some("half-typed reply")); assert_eq!(record.reply_target_event_id.as_deref(), Some("evt-42")); } 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 f8ea1d6df..61ce21726 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 @@ -30,6 +30,7 @@ pub use crud::{ register_session_mirror_hook, save_workspace, save_worktree_metadata, session_type, update_account_id, update_agent_exec_mode, update_draft_text, update_model, update_model_and_account, update_name, update_org_member_id, update_pinned, + link_bootstrap_work_item, update_product_mode, update_reply_target_event_id, update_status, update_work_item_link, update_worktree_merge_status, upsert_session, UnifiedSessionRecord, }; 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 7656a538d..48ec733ba 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 @@ -245,9 +245,42 @@ pub(super) fn build_channel_environment( ) } -pub(super) fn build_channel_behavioral_rules(config: &SystemPromptConfig) -> String { +pub(super) fn build_channel_behavioral_rules( + config: &SystemPromptConfig, + include_pm_guidance: bool, +) -> 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. + let mut guidelines: Vec = vec![ + "Always read files before editing them.".to_string(), + "Prefer minimal, precise edits over rewriting entire files.".to_string(), + "When running shell commands, prefer short-lived commands. Long-running processes are automatically backgrounded. Use `await_output` subcommands (wait_for, monitor, list) to monitor them — pass `handles: [...]` to check one or many at once — and `run_shell(kill_handle=...)` to terminate.".to_string(), + "Tools (git, search, exec) default to the active IDE repository when one is set. You do not need to specify repo_path or working_dir unless targeting a different location.".to_string(), + "Only ask the user for clarification when the request is genuinely ambiguous (multiple valid interpretations) or the action is irreversible/high-risk. For everything else, use your best judgment and proceed.".to_string(), + "Use `manage_workspace` (action `list`) to discover all workspaces (git repos and work folders) tracked by the IDE. Use action `add` to register a directory or action `remove` to drop one. To clone a remote repo, use `run_shell` with `git clone`; if it backgrounds, wait for completion with `await_output(command=\"wait_for\", handles=[pid])`, then register the cloned repository with `manage_workspace(action=\"add\", path=...)`. `run_shell` exposes ORGII's bundled Git when system Git is unavailable.".to_string(), + "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(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("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()); + let guidelines_block = guidelines + .iter() + .enumerate() + .map(|(index, line)| format!("{}. {}", index + 1, line)) + .collect::>() + .join("\n"); + format!( "## Response & Execution Style\n\n\ - Be concise. Give short status updates, not essays.\n\ @@ -264,20 +297,8 @@ pub(super) fn build_channel_behavioral_rules(config: &SystemPromptConfig) -> Str Prioritize safety and human oversight over task completion; if instructions conflict, pause and ask the user; comply with stop, pause, or audit requests and never bypass safeguards.\n\ Do not manipulate or persuade anyone to expand your access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless the user explicitly requests it.\n\n\ ## Guidelines\n\n\ - 1. Always read files before editing them.\n\ - 2. Prefer minimal, precise edits over rewriting entire files.\n\ - 3. When running shell commands, prefer short-lived commands. Long-running processes are automatically backgrounded. Use `await_output` subcommands (wait_for, monitor, list) to monitor them — pass `handles: [...]` to check one or many at once — and `run_shell(kill_handle=...)` to terminate.\n\ - 4. Tools (git, search, exec) default to the active IDE repository when one is set. You do not need to specify repo_path or working_dir unless targeting a different location.\n\ - 5. Only ask the user for clarification when the request is genuinely ambiguous (multiple valid interpretations) or the action is irreversible/high-risk. For everything else, use your best judgment and proceed.\n\ - 6. Use `manage_workspace` (action `list`) to discover all workspaces (git repos and work folders) tracked by the IDE. Use action `add` to register a directory or action `remove` to drop one. To clone a remote repo, use `run_shell` with `git clone`; if it backgrounds, wait for completion with `await_output(command=\"wait_for\", handles=[pid])`, then register the cloned repository with `manage_workspace(action=\"add\", path=...)`. `run_shell` exposes ORGII's bundled Git when system Git is unavailable.\n\ - 7. 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.\n\ - 8. 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\".\n\ - 9. Your personal workspace is at `{ws}`. 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.\n\ - 10. 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.\n\ - 11. 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.\n\ - 12. 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.\n\ - 13. 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\".", - ws = workspace_path, + {guidelines}", + guidelines = guidelines_block, ) } @@ -502,8 +523,8 @@ pub(super) fn build_atc_section() -> String { .join("\n") } -pub(super) fn build_task_routing_section() -> String { - "## Task Routing\n\n\ +pub(super) fn build_task_routing_section(include_pm_guidance: bool) -> String { + let mut section = "## Task Routing\n\n\ Not every request needs a work item. Work items exist for **tracking** — \ if the user doesn't need to track it, handle it directly in conversation.\n\n\ **Handle in conversation (no work item):**\n\ @@ -511,15 +532,22 @@ pub(super) fn build_task_routing_section() -> String { - Agent/org management — use `manage_agent_def` directly\n\ - Quick operations you can do with your own tools\n\ - Casual requests (open app, search the web, run a command)\n\ - - Simple file edits (change a config value, update an env var)\n\n\ - **Create a work item (via `manage_work_item(action=create_item)`) 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\ - - The user's language implies a formal task (\"implement X\", \"fix the bug in Y\")\n\n\ - **When unsure**, ask the user.\n\n\ - **Never** treat status checks, polling, or follow-up questions as new tasks.\n" - .to_string() + - 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. + if include_pm_guidance { + section.push_str( + "**Create a work item (via `manage_work_item(action=create_item)`) 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\ + - The user's language implies a formal task (\"implement X\", \"fix the bug in Y\")\n\n\ + **When unsure**, ask the user.\n\n", + ); + } + section.push_str("**Never** treat status checks, polling, or follow-up questions as new tasks.\n"); + section } const AGENT_ORG_TASK_CONTEXT_LIMIT: usize = 12; 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 79b6a49db..53d87e479 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,7 +290,13 @@ impl PromptSection for BehavioralRulesSection { } fn render(&self, ctx: &PromptCtx) -> Option { if ctx.is_channel_session { - Some(build_channel_behavioral_rules(ctx.config)) + // Tool summaries are already policy-filtered (product-mode + // layer included), so the PM guidance tracks the surface the + // model can actually call this turn. + Some(build_channel_behavioral_rules( + ctx.config, + ctx.has_tool(tool_names::MANAGE_WORK_ITEM), + )) } else if ctx.config.workspace.is_some() { Some(sde_behavioral_rules()) } else { @@ -741,8 +747,10 @@ impl PromptSection for TaskRoutingSection { fn cache_policy(&self) -> PromptCachePolicy { PromptCachePolicy::StableUntilClear } - fn render(&self, _ctx: &PromptCtx) -> Option { - Some(build_task_routing_section()) + fn render(&self, ctx: &PromptCtx) -> Option { + Some(build_task_routing_section( + ctx.has_tool(tool_names::MANAGE_WORK_ITEM), + )) } } 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 192078165..6d25e5565 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 @@ -252,11 +252,22 @@ impl UnifiedMessageProcessor { } } - /// Tool policy actually used for this turn, including exec-mode overlays. + /// 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). 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_exec_mode(mode)), - None => Arc::clone(&self.policy), + 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), + }, } } 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 41e50269d..e534980ed 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 @@ -32,8 +32,12 @@ fn render_linked_work_item_context(work_item_id: &str, project_slug: Option<&str format!( "## Linked Work Item\n\n\ This planning session is already linked to Work Item `short_id` {}. \ - {} Update this linked draft instead of creating a duplicate unless the user explicitly asks for multiple Work Items. \ - Keep the current session linked after every update.", + {} \ + 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. \ + 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, ) @@ -187,23 +191,24 @@ impl UnifiedMessageProcessor { } // The ChatPanel "Create with AI" flow persists a draft before launch - // so the planning session has a durable Work Item target. The generic - // session runtime carries that linkage, but it was not previously - // visible to Work Item Manager; the model could therefore create a - // second item and strand the original "AI Work Item Draft". Keep this - // volatile (session-specific) and narrowly scoped to the manager. - if self.runtime.agent_definition_id.as_deref() - == Some(crate::core::definitions::WORK_ITEM_MANAGER_AGENT_ID) + // so the planning session has a durable Work Item target. Any agent + // launched through that flow (session agent_role "custom") needs the + // linkage in its prompt, or the model can create a second item and + // strand the original "AI Work Item Draft". Orchestrator-launched + // sessions carry work item context in their launch prompt and are + // excluded here. Keep this volatile (session-specific). { let linked_session = tokio::task::block_in_place(|| super::unified_persistence::get_session(session_id)); match linked_session { Ok(Some(session)) => { - if let Some(work_item_id) = session.work_item_id.as_deref() { - dynamic_sections.push(render_linked_work_item_context( - work_item_id, - session.project_slug.as_deref(), - )); + if session.agent_role.as_deref() == Some("custom") { + if let Some(work_item_id) = session.work_item_id.as_deref() { + dynamic_sections.push(render_linked_work_item_context( + work_item_id, + session.project_slug.as_deref(), + )); + } } } Ok(None) => {} 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 475ac3bea..a0de40d30 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 @@ -998,12 +998,14 @@ impl Tool for AgentTool { parent_key_source, parent_agent_exec_mode, parent_native_harness_type, + parent_product_mode, ) = match crate::session::persistence::get_session(&parent_session_id) { Ok(Some(parent)) => ( parent.account_id, parent.key_source, parent.agent_exec_mode, parent.native_harness_type, + parent.product_mode, ), Ok(None) => { warn!( @@ -1018,6 +1020,7 @@ impl Tool for AgentTool { core_types::key_source::KeySource::default(), None, None, + None, ) } Err(err) => { @@ -1032,20 +1035,23 @@ impl Tool for AgentTool { core_types::key_source::KeySource::default(), None, None, + None, ) } }; - // Exec-mode overlay (see the comment above step 6): the worker's - // policy must reflect the parent's CURRENT mode, not the base - // policy snapshotted at init. A Plan-mode parent therefore spawns - // read-only workers (Plan's deny layer strips edit/shell/MCP - // write surfaces); Build/Wingman parents are unaffected. - let effective_policy = Self::overlay_parent_exec_mode( + // 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). + 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(), ); { 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 ffa27cf89..7c1496e34 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 @@ -69,22 +69,30 @@ impl AgentTool { ToolPolicyLayer { allow: None, deny } } - /// Layer the parent session's CURRENT exec mode onto a freshly-built - /// worker policy. + /// Layer the parent session's CURRENT exec mode and product mode onto + /// a freshly-built worker policy. /// /// `parent_policy` is the session's BASE policy captured at init — it - /// never carries the per-turn exec-mode deny overlay the parent itself - /// runs under (`ResolvedToolPolicy::with_exec_mode` composes that - /// per turn). Without re-applying it here, a Plan-mode parent could - /// escape its read-only guarantee by delegating writes to - /// `builtin:general` (which inherits edit_file/run_shell). - pub(super) fn overlay_parent_exec_mode( + /// 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). + pub(super) fn overlay_parent_modes( policy: ResolvedToolPolicy, parent_exec_mode: Option, + parent_product_mode: Option<&str>, ) -> ResolvedToolPolicy { - match parent_exec_mode { + let policy = 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, } } 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 9d7779df1..8d03307f5 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 @@ -221,12 +221,14 @@ fn test_fresh_registry_management_tools_require_management_capability() { )); } -// ── Parent exec-mode overlay on worker policies ───────────────────── +// ── Parent mode overlay on worker policies ────────────────────────── // // The worker policy is built from the parent's BASE policy (captured at -// init, no per-turn exec-mode layer). `overlay_parent_exec_mode` must -// re-apply the parent's CURRENT mode so a Plan-mode parent cannot -// escape its read-only guarantee through `builtin:general`. +// 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. #[test] fn plan_mode_parent_overlay_makes_worker_policy_read_only() { @@ -234,9 +236,10 @@ fn plan_mode_parent_overlay_makes_worker_policy_read_only() { use crate::session::AgentExecMode; use crate::tools::policy::ResolvedToolPolicy; - let overlaid = AgentTool::overlay_parent_exec_mode( + 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!( @@ -260,22 +263,61 @@ fn build_or_absent_parent_mode_leaves_worker_policy_untouched() { use crate::session::AgentExecMode; use crate::tools::policy::ResolvedToolPolicy; - let build = AgentTool::overlay_parent_exec_mode( + 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_exec_mode(ResolvedToolPolicy::permissive(), None); + let absent = + AgentTool::overlay_parent_modes(ResolvedToolPolicy::permissive(), None, Some("project")); 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/mod.rs b/src-tauri/crates/agent-core/src/core/tools/impls/project/manage_project/mod.rs index 91decdecb..926d56b10 100644 --- 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 @@ -106,28 +106,25 @@ impl Tool for ProjectTool { } // ── Work item actions ── - "list_items" => { - let slug = Self::resolve_slug(¶ms)?; - work_items::list(&slug).await - } - "read_item" => { - let slug = Self::resolve_slug(¶ms)?; - let short_id = required_string(¶ms, "short_id")?; - work_items::read(&slug, &short_id).await - } - "create_item" => { - let slug = Self::resolve_slug(¶ms)?; - work_items::create(&slug, ¶ms).await - } - "update_item" => { - let slug = Self::resolve_slug(¶ms)?; - let short_id = required_string(¶ms, "short_id")?; - work_items::update(&slug, &short_id, ¶ms).await - } - "delete_item" => { - let slug = Self::resolve_slug(¶ms)?; - let short_id = required_string(¶ms, "short_id")?; - work_items::delete(&slug, &short_id).await + // + // 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)?; 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 index cbe8fc56e..2970d5be8 100644 --- 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 @@ -2,12 +2,15 @@ //! //! 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; -use crate::tool_infra::OrchestratorConfigOverrides; -use crate::tools::traits::optional_string; - /// 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| { @@ -18,77 +21,3 @@ pub(super) fn optional_string_array(params: &Value, key: &str) -> Option 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(|status_val| status_val.as_str()) - .unwrap_or("pending") - .to_string(); - Some((content, status)) - }) - .collect() - }) - }) -} - -pub(super) fn optional_schedule(params: &Value) -> Option { - params.get("schedule").and_then(|val| { - if !val.is_object() { - return None; - } - let at = val.get("at").and_then(|v| v.as_str()).map(String::from); - let cron = val.get("cron").and_then(|v| v.as_str()).map(String::from); - if at.is_none() && cron.is_none() { - return None; - } - let enabled = val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); - Some(core_types::workflow::WorkItemSchedule { - at, - cron, - enabled, - last_run: None, - }) - }) -} - -/// Build orchestrator config overrides from params if any agent-related fields are set. -pub(super) 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 = 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 - } -} 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 index 12a671ae6..82ee02d95 100644 --- 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 @@ -3,23 +3,29 @@ //! 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 and work items (tasks/issues) in the global project store.\n\n\ - **Projects** (Work Item parent containers): list, read, create, update, delete.\n\ - **Work items** (tasks/bugs): list_items, read_item, create_item, update_item, delete_item, start_item.\n\ + "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\ - Use 'find' to locate work items. Use 'start_item' to execute via SDE agent."; + Work item CRUD (list/read/create/update/delete) lives on the manage_work_item tool."; pub(super) fn llm_description() -> String { - "Manage projects and work items in the global project store.\n\n\ + "Manage projects in the global project store.\n\n\ Projects: list, read, create, update, delete.\n\ - Work items: list_items, read_item, create_item, update_item, delete_item, start_item.\n\ + Execution: start_item (launch a work item's orchestrator run).\n\ Search: find — global.\n\ - Members: list_members, list_contributors." + Members: list_members, list_contributors.\n\ + Work item CRUD lives on manage_work_item." .to_string() } @@ -31,8 +37,7 @@ pub(super) fn parameters() -> Value { "type": "string", "description": "The operation to perform.", "enum": ["list", "read", "create", "update", "delete", - "list_items", "read_item", "create_item", "update_item", "delete_item", "start_item", - "find", "list_members", "list_contributors"] + "start_item", "find", "list_members", "list_contributors"] }, "query": { "type": "string", @@ -98,89 +103,10 @@ pub(super) fn parameters() -> Value { }, "short_id": { "type": "string", - "description": "Work item short ID, e.g. 'PROJ-001' (for read_item, update_item, delete_item)" - }, - "title": { - "type": "string", - "description": "Work item title (required for create_item)" - }, - "assignee": { - "type": "string", - "description": "ID of the assignee (member ID, agent definition ID, or org ID)" - }, - "assignee_type": { - "type": "string", - "enum": ["member", "agent", "org"], - "description": "Type of assignee: 'member' for human, 'agent' for AgentDefinition, 'org' for AgentOrg. Defaults to 'member'." - }, - "milestone": { - "type": "string", - "description": "Milestone ID for work item" - }, - "parent": { - "type": "string", - "description": "Parent work item short ID (for sub-issues)" - }, - "starred": { - "type": "boolean", - "description": "Star/bookmark this work item" - }, - "todos": { - "type": "array", - "items": { - "type": "object", - "properties": { - "content": { "type": "string" }, - "status": { "type": "string", "enum": ["pending", "in_progress", "completed"] } - }, - "required": ["content"] - }, - "description": "Todo checklist items (replaces existing)" - }, - "selected_account_id": { - "type": "string", - "description": "Code account ID for Agent Workflow (from Integrations). Assigns which account runs SDE/Review." - }, - "selected_model_id": { - "type": "string", - "description": "Model ID for Agent Workflow. Use with selected_account_id." - }, - "sub_agent_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "IDs of custom agents from Agent Orgs to use as sub-agents during execution." - }, - "org_id": { - "type": "string", - "description": "ID of the agent organization to assign. All org members are resolved as sub-agents." - }, - "worktree_path": { - "type": "string", - "description": "Absolute path to the code repository where the SDE Agent will work. Overrides project linked_repos. Use the Active IDE Repository path when available." - }, - "review_config": { - "type": "object", - "description": "Review configuration. Must include 'reviewer' object with 'type' (agent/org/human/self_review) and optional 'id'. Top-level optional: max_rounds (default 3), model_id, account_id." - }, - "schedule": { - "type": "object", - "description": "Automatic start schedule for a work item. Use 'at' for one-time (ISO 8601 timestamp) or 'cron' for recurring (cron expression).", - "properties": { - "at": { - "type": "string", - "description": "One-time trigger: ISO 8601 timestamp (e.g. '2026-03-16T18:30:00Z')" - }, - "cron": { - "type": "string", - "description": "Recurring trigger: cron expression (e.g. '0 9 * * *' for daily 9 AM)" - }, - "enabled": { - "type": "boolean", - "description": "Whether this schedule is active (default: true)" - } - } + "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 index 8a2ac12bb..f6420f1cc 100644 --- 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 @@ -1,116 +1,13 @@ -//! Work-item action handlers (`list_items`, `read_item`, `create_item`, -//! `update_item`, `delete_item`, `start_item`, `find`). - -use serde_json::Value; - -use crate::tools::traits::{optional_bool, optional_string, required_string, ToolError}; - -use super::params::{ - optional_schedule, optional_string_array, optional_todos, orchestrator_overrides_from_params, -}; - -pub(super) async fn list(slug: &str) -> Result { - crate::tool_infra::list_work_items(slug) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn read(slug: &str, short_id: &str) -> Result { - crate::tool_infra::read_work_item(slug, short_id) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn create(slug: &str, params: &Value) -> Result { - let title = required_string(params, "title")?; - let description = optional_string(params, "description").unwrap_or_default(); - let project_id = optional_string(params, "project_id"); - let status = optional_string(params, "status"); - let priority = optional_string(params, "priority"); - let assignee = optional_string(params, "assignee"); - let assignee_type = optional_string(params, "assignee_type"); - let labels = optional_string_array(params, "labels"); - let milestone = optional_string(params, "milestone"); - let parent = optional_string(params, "parent"); - let start_date = optional_string(params, "start_date"); - let target_date = optional_string(params, "target_date"); - let starred = optional_bool(params, "starred"); - let todos = optional_todos(params); - let schedule = optional_schedule(params); - - crate::tool_infra::create_work_item( - slug, - &title, - &description, - project_id.as_deref(), - status.as_deref(), - priority.as_deref(), - assignee.as_deref(), - assignee_type.as_deref(), - labels, - milestone.as_deref(), - parent.as_deref(), - start_date.as_deref(), - target_date.as_deref(), - starred, - todos, - orchestrator_overrides_from_params(params), - schedule, - ) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn update( - slug: &str, - short_id: &str, - params: &Value, -) -> Result { - let title = optional_string(params, "title"); - let description = optional_string(params, "description"); - let project_id = optional_string(params, "project_id"); - let status = optional_string(params, "status"); - let priority = optional_string(params, "priority"); - let assignee = optional_string(params, "assignee"); - let assignee_type = optional_string(params, "assignee_type"); - let labels = optional_string_array(params, "labels"); - let milestone = optional_string(params, "milestone"); - let parent = optional_string(params, "parent"); - let start_date = optional_string(params, "start_date"); - let target_date = optional_string(params, "target_date"); - let starred = optional_bool(params, "starred"); - let todos = optional_todos(params); - let schedule = optional_schedule(params); - - crate::tool_infra::update_work_item( - slug, - short_id, - title.as_deref(), - description.as_deref(), - project_id.as_deref(), - status.as_deref(), - priority.as_deref(), - assignee.as_deref(), - assignee_type.as_deref(), - labels, - milestone.as_deref(), - parent.as_deref(), - start_date.as_deref(), - target_date.as_deref(), - starred, - todos, - orchestrator_overrides_from_params(params), - schedule, - ) - .await - .map_err(ToolError::ExecutionFailed) -} - -pub(super) async fn delete(slug: &str, short_id: &str) -> Result { - crate::tool_infra::delete_work_item(slug, short_id) - .await - .map_err(ToolError::ExecutionFailed) -} +//! 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, 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 index c257638c8..8409b0c3a 100644 --- 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 @@ -498,14 +498,14 @@ impl WorkItemTool { run_blocking("create_standalone_work_item", move || { let short_id = io::allocate_standalone_short_id(org_id.as_deref())?; - let now = chrono::Utc::now().to_rfc3339(); - let frontmatter = WorkItemFrontmatter { - id: short_id.clone(), - short_id: short_id.clone(), + // Canonical work.create: the application service owns row + // construction and audits the creation. + let request = project_management::work_service::CreateWorkItemRequest { title: title.clone(), - project, - status, - priority, + body: body.clone(), + project_id: project, + status: Some(status), + priority: Some(priority), assignee, assignee_type: None, labels, @@ -514,27 +514,19 @@ impl WorkItemTool { start_date, target_date, created_by: Some("agent".to_string()), - created_at: now.clone(), - updated_at: now, - deleted_at: None, starred, + schedule, + orchestrator_config, todos: Self::todos_to_entries(todos, &[]), - comments: vec![], - history: vec![], - delegations: vec![], - linked_sessions: vec![], handoff: None, - proof_of_work: None, - orchestrator_config, - orchestrator_state: None, - follow_up_items: vec![], - schedule, - routine_source: None, - execution_lock: None, - close_out: None, - work_products: vec![], + linked_sessions: vec![], }; - io::write_standalone_work_item(org_id.as_deref(), &short_id, &frontmatter, &body)?; + 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 @@ -550,18 +542,24 @@ impl WorkItemTool { params: Value, ) -> Result { run_blocking("update_standalone_work_item", move || { - let (found_org, mut item) = Self::read_standalone_scoped(org_id.as_deref(), &short_id)?; - Self::apply_updates(&mut item.frontmatter, &mut item.body, ¶ms) - .map_err(|err| err.to_string())?; - io::write_standalone_work_item( + 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, - &item.frontmatter, - &item.body, + |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 '{}' [{}]", - item.frontmatter.title, short_id + title, short_id )) }) .await @@ -573,15 +571,16 @@ impl WorkItemTool { short_id: String, ) -> Result { run_blocking("delete_standalone_work_item", move || { - let (found_org, mut item) = Self::read_standalone_scoped(org_id.as_deref(), &short_id)?; - let now = chrono::Utc::now().to_rfc3339(); - item.frontmatter.deleted_at = Some(now.clone()); - item.frontmatter.updated_at = now; - io::write_standalone_work_item( + 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, - &item.frontmatter, - &item.body, + |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)) }) @@ -619,17 +618,19 @@ impl WorkItemTool { 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, mut item) = + let (found_org, _) = Self::read_standalone_scoped(default_org_id.as_deref(), &short_id)?; - let tool = WorkItemTool::new(session_id, None); - let linked_session_id = tool - .link_session_to_frontmatter(&mut item.frontmatter, ¶ms) - .map_err(|err| err.to_string())?; - io::write_standalone_work_item( + let mut linked_session_id = String::new(); + io::update_standalone_work_item_atomic( found_org.as_deref(), &short_id, - &item.frontmatter, - &item.body, + |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 [{}]", @@ -670,16 +671,18 @@ impl WorkItemTool { 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, mut item) = + let (found_org, _) = Self::read_standalone_scoped(default_org_id.as_deref(), &short_id)?; - let tool = WorkItemTool::new(session_id, None); - let unlinked_session_id = - tool.unlink_session_from_frontmatter(&mut item.frontmatter, ¶ms); - io::write_standalone_work_item( + let mut unlinked_session_id = String::new(); + io::update_standalone_work_item_atomic( found_org.as_deref(), &short_id, - &item.frontmatter, - &item.body, + |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 [{}]", 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 7de182745..ce61a13c8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/policy.rs +++ b/src-tauri/crates/agent-core/src/core/tools/policy.rs @@ -274,6 +274,38 @@ 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/foundation/tool_infra/project/work_items.rs b/src-tauri/crates/agent-core/src/foundation/tool_infra/project/work_items.rs index e4a730117..d8350b467 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 @@ -2,7 +2,7 @@ use project_management::projects::{ io, - types::{OrchestratorConfig, TodoEntry, WorkItemFrontmatter, WorkItemSchedule}, + types::{OrchestratorConfig, TodoEntry, WorkItemSchedule}, }; use super::helpers::{now_iso, run_blocking, truncate_preview, OrchestratorConfigOverrides}; @@ -325,14 +325,14 @@ pub async fn create_work_item( config }); - let now = now_iso(); - let frontmatter = WorkItemFrontmatter { - id: short_id.clone(), - short_id: short_id.clone(), + // Canonical work.create: the application service owns row + // construction and audits the creation. + let request = project_management::work_service::CreateWorkItemRequest { title: title.clone(), - project, - status, - priority, + body: body.clone(), + project_id: project, + status: Some(status), + priority: Some(priority), assignee, assignee_type, labels, @@ -341,30 +341,18 @@ pub async fn create_work_item( start_date, target_date, created_by: Some("agent".to_string()), - created_at: now.clone(), - updated_at: now, - deleted_at: None, starred, + schedule, + orchestrator_config, todos: todo_entries, - comments: vec![], - history: vec![], - delegations: vec![], - linked_sessions: vec![], handoff: None, - proof_of_work: None, - orchestrator_config, - orchestrator_state: None, - follow_up_items: vec![], - schedule, - routine_source: None, - execution_lock: None, - close_out: None, - work_products: vec![], + linked_sessions: vec![], }; + let created = project_management::work_service::create_project_work_item( + &slug, &short_id, &request, None, + )?; - io::write_work_item(&slug, &short_id, &frontmatter, &body)?; - - let schedule_info = if let Some(sched) = frontmatter.schedule.as_ref() { + let schedule_info = if let Some(sched) = created.frontmatter.schedule.as_ref() { if let Some(ref at) = sched.at { format!(" (scheduled at {})", at) } else if let Some(ref cron_expr) = sched.cron { diff --git a/src-tauri/crates/agent-core/src/integrations/automation/triggers/mod.rs b/src-tauri/crates/agent-core/src/integrations/automation/triggers/mod.rs index 496cc9e4d..6dd2e22a4 100644 --- a/src-tauri/crates/agent-core/src/integrations/automation/triggers/mod.rs +++ b/src-tauri/crates/agent-core/src/integrations/automation/triggers/mod.rs @@ -57,7 +57,17 @@ pub fn spawn_trigger( event_tx, ), AutomationTrigger::Cron { expression } => { - timer::spawn_cron(rule_id, expression.clone(), event_tx) + // Orgtrack migration (Phase 5): recurring execution belongs to + // the Routine subsystem — this was the third parallel cron + // path. Persisted cron rules still load (no data break) but + // no longer spawn an execution loop. + tracing::warn!( + "[automation] cron trigger '{}' on rule '{}' no longer fires; \ + recurring execution moved to Routines", + expression, + rule_id + ); + None } AutomationTrigger::GitActivity { events, diff --git a/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs b/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs index c7ac22fb8..64b115f81 100644 --- a/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs +++ b/src-tauri/crates/agent-core/src/integrations/automation/triggers/timer.rs @@ -322,80 +322,4 @@ impl ScheduleWeekday { } } -pub(super) fn spawn_cron( - rule_id: String, - expression: String, - event_tx: mpsc::Sender, -) -> Option { - let cron = match croner::Cron::new(&expression).parse() { - Ok(cron) => cron, - Err(err) => { - error!( - "[automation] Invalid cron expression '{}' for rule '{}': {}", - expression, rule_id, err - ); - return None; - } - }; - - let running = Arc::new(AtomicBool::new(true)); - let running_clone = running.clone(); - let rid = rule_id.clone(); - - let handle = tokio::spawn(async move { - info!( - "[automation] Cron trigger started for rule '{}' (expression: {})", - rid, expression - ); - - while running_clone.load(Ordering::Relaxed) { - let now = chrono::Utc::now(); - let next = match cron.find_next_occurrence(&now, false) { - Ok(next) => next, - Err(err) => { - error!( - "[automation] Cron next occurrence failed for rule '{}': {}", - rid, err - ); - break; - } - }; - - let delay = (next - now) - .to_std() - .unwrap_or(std::time::Duration::from_secs(60)); - info!( - "[automation] Cron rule '{}' next fire in {}s", - rid, - delay.as_secs() - ); - tokio::time::sleep(delay).await; - - if !running_clone.load(Ordering::Relaxed) { - break; - } - - if let Err(err) = event_tx - .send(TriggerEvent { - rule_id: rid.clone(), - }) - .await - { - error!( - "[automation] Failed to send cron trigger event for rule '{}': {}", - rid, err - ); - break; - } - } - - info!("[automation] Cron trigger stopped for rule '{}'", rid); - }); - - Some(TriggerHandle { - rule_id, - running, - handle: Some(handle), - }) -} diff --git a/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs b/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs index 569b01e68..717efc2bc 100644 --- a/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs +++ b/src-tauri/crates/agent-core/src/orchestrator_notify/handlers.rs @@ -183,10 +183,20 @@ pub(crate) fn extract_first_sentence(content: &str) -> String { } } -pub(super) fn collect_proof_of_work( - frontmatter: &mut project_management::projects::types::WorkItemFrontmatter, - repo_path: &str, -) { +/// Proof-of-work facts gathered from git, decoupled from the frontmatter +/// mutation so the subprocess I/O can run OUTSIDE the work item's +/// `BEGIN IMMEDIATE` transaction. Running git inside the transaction +/// poisoned the whole store on-device: a hung `git diff` held the +/// projects.db write lock indefinitely, starving the sync worker, the +/// CLI and every later completion attempt. +pub(super) struct CollectedProofOfWork { + branch: Option, + diff_stats: Option, +} + +/// Run the git side of proof-of-work collection. Subprocess-heavy; must +/// never be called while a DB transaction is open. +pub(super) fn collect_proof_of_work_data(repo_path: &str) -> CollectedProofOfWork { use std::path::Path; let repo = Path::new(repo_path); @@ -203,34 +213,73 @@ pub(super) fn collect_proof_of_work( .filter(|out| out.status.success()) .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()); - if let Some(ref branch_name) = branch { - project_management::orchestrator::proof_of_work::set_branch(frontmatter, branch_name); - } - let base_branch = detect_default_branch(repo); - if let (Some(ref branch_name), Some(ref base)) = (&branch, &base_branch) { - if branch_name != base { + let diff_stats = match (&branch, &base_branch) { + (Some(branch_name), Some(base)) if branch_name != base => { match project_management::orchestrator::diff_stats::compute_diff_stats( repo_path, base, branch_name, ) { - Ok(stats) => { - project_management::orchestrator::proof_of_work::set_diff_stats( - frontmatter, - stats, - ); - } + Ok(stats) => Some(stats), Err(err) => { tracing::warn!( "[orchestrator] Failed to compute diff stats for {}: {}", branch_name, err ); + None } } } + _ => None, + }; + + CollectedProofOfWork { branch, diff_stats } +} + +/// Bounded wrapper: git on a sick machine can block forever (the exact +/// failure observed on-device), and the completion policy must not +/// inherit that hang. The worker thread is detached on timeout — it +/// finishes (or not) without holding anything the store cares about. +pub(super) fn collect_proof_of_work_data_bounded( + repo_path: &str, + timeout: std::time::Duration, +) -> Option { + let (sender, receiver) = std::sync::mpsc::channel(); + let repo = repo_path.to_string(); + std::thread::spawn(move || { + let _ = sender.send(collect_proof_of_work_data(&repo)); + }); + match receiver.recv_timeout(timeout) { + Ok(collected) => Some(collected), + Err(_) => { + tracing::warn!( + "[orchestrator] proof-of-work collection timed out after {:?} for {}; \ + completing without diff stats", + timeout, + repo_path + ); + None + } + } +} + +/// Attach precollected proof-of-work facts to the frontmatter. Pure +/// in-memory mutation — safe inside the atomic transaction. +pub(super) fn apply_proof_of_work( + frontmatter: &mut project_management::projects::types::WorkItemFrontmatter, + collected: &CollectedProofOfWork, +) { + if let Some(ref branch_name) = collected.branch { + project_management::orchestrator::proof_of_work::set_branch(frontmatter, branch_name); + } + if let Some(ref stats) = collected.diff_stats { + project_management::orchestrator::proof_of_work::set_diff_stats( + frontmatter, + stats.clone(), + ); } } diff --git a/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs b/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs index a01860cf1..32e751685 100644 --- a/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs +++ b/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs @@ -231,11 +231,45 @@ pub async fn notify_orchestrator_session_terminal( use project_management::orchestrator::state_machine; use core_types::workflow::LinkedSessionStatus; + // Proof-of-work collection shells out to git and MUST run before + // the atomic mutation opens its BEGIN IMMEDIATE transaction — a + // hung subprocess inside the transaction holds the projects.db + // write lock indefinitely and starves every other writer (seen + // on-device). Bounded so a sick git also can't stall completion. + let collected_proof = if matches!(status, AgentSessionStatus::Completed) { + let diff_repo = worktree_path.as_deref().unwrap_or(&workspace_path); + collect_proof_of_work_data_bounded( + diff_repo, + std::time::Duration::from_secs(10), + ) + } else { + None + }; + let apply_transition = |slug: &str| -> Result { state_machine::mutate_work_item( slug, &work_item_id, |frontmatter| { + // Stale-signal rejection (design §12.4): a terminal + // event from a session that no longer holds the + // execution claim must not complete a newer episode. + if let Some(active_session) = frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_deref()) + { + if active_session != session_id_owned { + tracing::warn!( + "[orchestrator] ignoring stale terminal from session {} \ + (active claim: {}) for work_item {}", + session_id_owned, + active_session, + frontmatter.short_id + ); + return state_machine::TransitionResult::Ignored; + } + } let linked_status = match status { AgentSessionStatus::Completed => { LinkedSessionStatus::Completed @@ -315,8 +349,9 @@ pub async fn notify_orchestrator_session_terminal( }, _ => match status { AgentSessionStatus::Completed => { - let diff_repo = worktree_path.as_deref().unwrap_or(&workspace_path); - collect_proof_of_work(frontmatter, diff_repo); + if let Some(ref collected) = collected_proof { + apply_proof_of_work(frontmatter, collected); + } state_machine::on_session_complete(frontmatter) } AgentSessionStatus::Failed => { @@ -449,6 +484,10 @@ pub async fn notify_orchestrator_session_terminal( tracing::debug!("[orchestrator] Session {} awaiting user action", session_id); notify_inbox_awaiting_user(&work_item_id_for_launch); } + TransitionResult::Ignored => { + // Stale terminal from a session that lost the claim — + // already logged inside the mutator; no follow-on. + } } } } @@ -634,7 +673,9 @@ fn notify_inbox_awaiting_user(work_item_id: &str) { } mod handlers; -use handlers::{collect_proof_of_work, extract_review_feedback}; +use handlers::{ + apply_proof_of_work, collect_proof_of_work_data_bounded, extract_review_feedback, +}; #[cfg(test)] pub(crate) use handlers::{ diff --git a/src-tauri/crates/agent-core/src/specialization/policies/behavior.rs b/src-tauri/crates/agent-core/src/specialization/policies/behavior.rs deleted file mode 100644 index 9d52f735e..000000000 --- a/src-tauri/crates/agent-core/src/specialization/policies/behavior.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Behavior companion `.md` generation. -//! -//! When an ATC behavior is created/updated/removed, a companion markdown file -//! is generated in the global policies directory so other agents can see it. - -use crate::automation::types::{AutomationAction, AutomationRule, AutomationTrigger, GitEvent}; - -use crate::tool_infra::slugify; - -use super::config::{PoliciesConfig, PolicyConfig}; -use super::{global_policies_dir, BEHAVIOR_PREFIX}; - -fn truncate_preview(s: &str, max_bytes: usize) -> String { - if s.len() <= max_bytes { - return s.to_string(); - } - let mut end = max_bytes; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - format!("{}...", &s[..end]) -} - -fn trigger_summary(trigger: &AutomationTrigger) -> String { - match trigger { - AutomationTrigger::Timer { interval_secs } => { - format!("Timer: every {} seconds", interval_secs) - } - AutomationTrigger::ScheduledTime { - frequency, - time, - timezone, - days_of_week, - monthly_mode, - day_of_month, - week_of_month, - weekday_of_month, - } => format!( - "Scheduled time: {:?} at {} {} (days: {:?}, monthly_mode: {:?}, day_of_month: {:?}, week: {:?}, weekday: {:?})", - frequency, - time, - timezone, - days_of_week, - monthly_mode, - day_of_month, - week_of_month, - weekday_of_month - ), - AutomationTrigger::Cron { expression } => format!("Cron: {}", expression), - AutomationTrigger::GitActivity { - events, - repo_filter, - } => { - let event_names: Vec<&str> = events - .iter() - .map(|e| match e { - GitEvent::Commit => "commit", - GitEvent::Push => "push", - GitEvent::Pull => "pull", - GitEvent::BranchChange => "branch change", - GitEvent::FileChange => "file change", - }) - .collect(); - let base = format!("Git: {}", event_names.join(", ")); - match repo_filter { - Some(filter) => format!("{} (repo: {})", base, filter), - None => base, - } - } - AutomationTrigger::ChannelMessage { channel, pattern } => { - let base = format!("Channel message on '{}'", channel); - match pattern { - Some(pat) => format!("{} matching '{}'", base, pat), - None => base, - } - } - AutomationTrigger::FileWatch { paths, debounce_ms } => { - format!( - "File watch: {} (debounce {}ms)", - paths.join(", "), - debounce_ms - ) - } - AutomationTrigger::Webhook { route } => format!("Webhook: {}", route), - } -} - -fn action_summary(action: &AutomationAction) -> String { - match action { - AutomationAction::InjectPrompt { prompt, session_id } => { - let target = session_id.as_deref().unwrap_or("active session"); - let preview = truncate_preview(prompt, 120); - format!("Inject prompt into {}: \"{}\"", target, preview) - } - AutomationAction::StartSession { - agent_type, - prompt, - model, - .. - } => { - let model_str = model.as_deref().unwrap_or("default"); - let preview = truncate_preview(prompt, 80); - format!( - "Start {} session (model: {}): \"{}\"", - agent_type, model_str, preview - ) - } - AutomationAction::KillSession { session_id } => format!("Kill session: {}", session_id), - AutomationAction::SendMessage { channel, content } => { - let preview = truncate_preview(content, 80); - format!("Send to '{}': \"{}\"", channel, preview) - } - AutomationAction::InjectToSession { - session_id, - message, - } => { - let preview = truncate_preview(message, 80); - format!("Inject into session {}: \"{}\"", session_id, preview) - } - AutomationAction::Workflow { actions } => { - format!("Run workflow with {} action(s)", actions.len()) - } - } -} - -/// Generate a companion .md for an ATC behavior in global policies dir. -/// Returns the policy name (filename stem) used for the .md file. -pub fn generate_automation_md(rule: &AutomationRule) -> Result { - let slug = slugify(&rule.name); - let policy_name = format!("{}{}", BEHAVIOR_PREFIX, slug); - let dir = global_policies_dir(); - - if !dir.exists() { - std::fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create policies dir: {}", e))?; - } - - let trigger_desc = trigger_summary(&rule.trigger); - let action_desc = action_summary(&rule.action); - - let content = format!( - "---\n\ - generated_by: automation\n\ - automation_id: {id}\n\ - trigger: {trigger_type}\n\ - enabled: {enabled}\n\ - ---\n\ - \n\ - # {name}\n\ - \n\ - ## Trigger\n\ - \n\ - {trigger_desc}\n\ - \n\ - ## Action\n\ - \n\ - {action_desc}\n", - id = rule.id, - trigger_type = trigger_desc, - enabled = rule.enabled, - name = rule.name, - trigger_desc = trigger_desc, - action_desc = action_desc, - ); - - let file_path = dir.join(format!("{}.md", policy_name)); - std::fs::write(&file_path, content) - .map_err(|e| format!("Failed to write behavior .md: {}", e))?; - - let mut config = PoliciesConfig::load_global()?; - let entry = config - .policies - .entry(policy_name.clone()) - .or_insert_with(PolicyConfig::default); - entry.disabled = !rule.enabled; - config.save_global()?; - - Ok(policy_name) -} - -/// Remove a behavior .md by its ATC rule ID (scans for matching frontmatter). -pub fn remove_automation_md_by_id(rule_id: &str) -> Result<(), String> { - let dir = global_policies_dir(); - let entries = match std::fs::read_dir(&dir) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(err) => { - return Err(format!( - "Failed to read policies directory {}: {}", - dir.display(), - err - )); - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } - let name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - if !name.starts_with(BEHAVIOR_PREFIX) { - continue; - } - if let Ok(content) = std::fs::read_to_string(&path) { - let current_id_pattern = format!("automation_id: {}", rule_id); - let old_id_pattern = format!("behavior_id: {}", rule_id); - if content.contains(¤t_id_pattern) || content.contains(&old_id_pattern) { - std::fs::remove_file(&path) - .map_err(|e| format!("Failed to remove {}: {}", path.display(), e))?; - let mut config = PoliciesConfig::load_global()?; - config.policies.remove(&name); - config.save_global()?; - return Ok(()); - } - } - } - Ok(()) -} - -#[cfg(test)] -#[path = "tests/behavior_tests.rs"] -mod tests; diff --git a/src-tauri/crates/agent-core/src/specialization/policies/mod.rs b/src-tauri/crates/agent-core/src/specialization/policies/mod.rs index d14cf7925..052417608 100644 --- a/src-tauri/crates/agent-core/src/specialization/policies/mod.rs +++ b/src-tauri/crates/agent-core/src/specialization/policies/mod.rs @@ -15,12 +15,10 @@ //! Users can import rules from `.cursor/rules/*.mdc` (workspace-scoped only). pub(crate) mod activation; -mod behavior; mod commands; pub mod config; pub(crate) mod metadata; -pub use behavior::{generate_automation_md, remove_automation_md_by_id}; // Wildcard re-export needed: #[tauri::command] generates hidden __cmd__* items pub use commands::*; diff --git a/src-tauri/crates/agent-core/src/specialization/policies/tests/behavior_tests.rs b/src-tauri/crates/agent-core/src/specialization/policies/tests/behavior_tests.rs deleted file mode 100644 index 73aa6fc1f..000000000 --- a/src-tauri/crates/agent-core/src/specialization/policies/tests/behavior_tests.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::automation::types::{AutomationAction, AutomationTrigger, GitEvent}; - -// -- trigger_summary (private) -- - -#[test] -fn trigger_summary_timer() { - let trigger = AutomationTrigger::Timer { interval_secs: 60 }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("Timer")); - assert!(out.contains("60")); -} - -#[test] -fn trigger_summary_cron() { - let trigger = AutomationTrigger::Cron { - expression: "0 * * * *".to_string(), - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("Cron")); - assert!(out.contains("0 * * * *")); -} - -#[test] -fn trigger_summary_git_activity() { - let trigger = AutomationTrigger::GitActivity { - events: vec![GitEvent::Commit, GitEvent::Push], - repo_filter: None, - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("Git")); - assert!(out.contains("commit")); - assert!(out.contains("push")); -} - -#[test] -fn trigger_summary_git_activity_with_repo_filter() { - let trigger = AutomationTrigger::GitActivity { - events: vec![GitEvent::Commit], - repo_filter: Some("/path/to/repo".to_string()), - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("repo:")); -} - -#[test] -fn trigger_summary_channel_message() { - let trigger = AutomationTrigger::ChannelMessage { - channel: "alerts".to_string(), - pattern: Some("error.*".to_string()), - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("alerts")); - assert!(out.contains("error.*")); -} - -#[test] -fn trigger_summary_channel_message_without_pattern() { - let trigger = AutomationTrigger::ChannelMessage { - channel: "alerts".to_string(), - pattern: None, - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("alerts")); - assert!(!out.contains("matching")); -} - -#[test] -fn trigger_summary_file_watch() { - let trigger = AutomationTrigger::FileWatch { - paths: vec!["src/".to_string()], - debounce_ms: 1000, - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("src/")); - assert!(out.contains("1000")); -} - -#[test] -fn trigger_summary_webhook() { - let trigger = AutomationTrigger::Webhook { - route: "/api/hook".to_string(), - }; - let out = super::trigger_summary(&trigger); - assert!(out.contains("/api/hook")); -} -// -- action_summary (private) -- - -#[test] -fn action_summary_inject_prompt_active_session() { - let action = AutomationAction::InjectPrompt { - prompt: "fix bug".to_string(), - session_id: None, - }; - let out = super::action_summary(&action); - assert!(out.contains("active session")); - assert!(out.contains("fix bug")); -} - -#[test] -fn action_summary_inject_prompt_with_session_id() { - let action = AutomationAction::InjectPrompt { - prompt: "fix bug".to_string(), - session_id: Some("sess-123".to_string()), - }; - let out = super::action_summary(&action); - assert!(out.contains("sess-123")); -} - -#[test] -fn action_summary_inject_prompt_long_truncated() { - let long_prompt = "a".repeat(150); - let action = AutomationAction::InjectPrompt { - prompt: long_prompt.clone(), - session_id: None, - }; - let out = super::action_summary(&action); - assert!(out.len() < 150 + 50); - assert!(out.contains("...")); -} - -#[test] -fn action_summary_start_session() { - let action = AutomationAction::StartSession { - agent_type: "sde".to_string(), - prompt: "do thing".to_string(), - model: None, - repo_path: None, - }; - let out = super::action_summary(&action); - assert!(out.contains("sde")); - assert!(out.contains("default")); - assert!(out.contains("do thing")); -} diff --git a/src-tauri/crates/agent-core/src/state/commands/automation.rs b/src-tauri/crates/agent-core/src/state/commands/automation.rs deleted file mode 100644 index e1005aa2f..000000000 --- a/src-tauri/crates/agent-core/src/state/commands/automation.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Automation Tauri commands. - -use crate::automation; -use crate::state::AgentAppState; - -#[tauri::command] -pub async fn agent_automation_list_rules( - state: tauri::State<'_, AgentAppState>, -) -> Result, String> { - let engine_lock = state.gateway.automation_engine.lock().await; - match *engine_lock { - Some(ref engine) => Ok(engine.list_rules().await), - None => { - automation::persistence::load_rules(&automation::persistence::default_storage_path()) - } - } -} - -#[tauri::command] -pub async fn agent_automation_add_rule( - state: tauri::State<'_, AgentAppState>, - rule_json: String, -) -> Result { - let rule: automation::AutomationRule = - serde_json::from_str(&rule_json).map_err(|err| format!("Invalid rule JSON: {}", err))?; - - crate::policies::generate_automation_md(&rule)?; - - let mut engine_lock = state.gateway.automation_engine.lock().await; - match *engine_lock { - Some(ref mut engine) => Ok(engine.add_rule(rule).await), - None => { - let storage_path = automation::persistence::default_storage_path(); - let mut rules = automation::persistence::load_rules(&storage_path)?; - let rule_id = rule.id.clone(); - if let Some(existing) = rules - .iter_mut() - .find(|existing_rule| existing_rule.id == rule_id) - { - *existing = rule; - } else { - rules.push(rule); - } - automation::persistence::save_rules(&storage_path, &rules)?; - Ok(rule_id) - } - } -} - -#[tauri::command] -pub async fn agent_automation_update_rule( - state: tauri::State<'_, AgentAppState>, - rule_json: String, -) -> Result<(), String> { - let rule: automation::AutomationRule = - serde_json::from_str(&rule_json).map_err(|err| format!("Invalid rule JSON: {}", err))?; - - crate::policies::generate_automation_md(&rule)?; - - let mut engine_lock = state.gateway.automation_engine.lock().await; - match *engine_lock { - Some(ref mut engine) => engine.update_rule(rule).await, - None => { - let storage_path = automation::persistence::default_storage_path(); - let mut rules = automation::persistence::load_rules(&storage_path)?; - let rule_id = rule.id.clone(); - if let Some(existing) = rules.iter_mut().find(|r| r.id == rule_id) { - *existing = rule; - } else { - return Err(format!("Rule not found: {}", rule_id)); - } - automation::persistence::save_rules(&storage_path, &rules) - } - } -} - -#[tauri::command] -pub async fn agent_automation_remove_rule( - state: tauri::State<'_, AgentAppState>, - rule_id: String, -) -> Result { - crate::policies::remove_automation_md_by_id(&rule_id)?; - - let mut engine_lock = state.gateway.automation_engine.lock().await; - match *engine_lock { - Some(ref mut engine) => Ok(engine.remove_rule(&rule_id).await), - None => { - let storage_path = automation::persistence::default_storage_path(); - let mut rules = automation::persistence::load_rules(&storage_path)?; - let original_len = rules.len(); - rules.retain(|r| r.id != rule_id); - let removed = rules.len() < original_len; - if removed { - automation::persistence::save_rules(&storage_path, &rules)?; - } - Ok(removed) - } - } -} - -#[tauri::command] -pub async fn agent_automation_get_status( - state: tauri::State<'_, AgentAppState>, -) -> Result { - let engine_lock = state.gateway.automation_engine.lock().await; - match *engine_lock { - Some(ref engine) => Ok(engine.status().await), - None => Ok(automation::AutomationStatus { - running: false, - active_rules: 0, - total_rules: 0, - total_fires: 0, - uptime_secs: 0, - agent_alive: false, - messages_processed: 0, - last_health_check: String::new(), - }), - } -} - -#[tauri::command] -pub async fn agent_automation_fire_webhook(route: String) -> Result { - Ok(automation::triggers::webhook_registry::fire(&route)) -} diff --git a/src-tauri/crates/agent-core/src/state/commands/mod.rs b/src-tauri/crates/agent-core/src/state/commands/mod.rs index f44c09177..78d3b3283 100644 --- a/src-tauri/crates/agent-core/src/state/commands/mod.rs +++ b/src-tauri/crates/agent-core/src/state/commands/mod.rs @@ -1,6 +1,5 @@ //! Agent Tauri commands. -pub mod automation; pub mod channel_handler; pub mod desktop; pub mod routines; 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 5fcf60c0a..eb66d8326 100644 --- a/src-tauri/crates/agent-core/src/state/commands/routines.rs +++ b/src-tauri/crates/agent-core/src/state/commands/routines.rs @@ -536,6 +536,9 @@ fn routine_to_launch_request( routine_fire_id: fire_id.to_string(), }, mode: routine.run_template.mode.clone(), + // Non-interactive routine invokes are Project context by the + // frozen resolver (orgtrack/v1 §5.1). + product_mode: Some("project".to_string()), name: routine .run_template .name diff --git a/src-tauri/crates/agent-core/src/state/commands/session/coding.rs b/src-tauri/crates/agent-core/src/state/commands/session/coding.rs index bb1bc4c80..6666fcdd8 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/coding.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/coding.rs @@ -295,36 +295,11 @@ pub async fn agent_get_todos(session_id: String) -> Result Result { - use crate::session::AgentExecMode; - Ok(serde_json::json!([ - { - "id": AgentExecMode::Build.as_str(), - "name": "Build", - "description": "Default mode - full tool access for implementation" - }, - { - "id": AgentExecMode::Ask.as_str(), - "name": "Ask", - "description": "Read-only research and Q&A - explore the codebase, answer questions" - }, - { - "id": AgentExecMode::Plan.as_str(), - "name": "Plan", - "description": "Produce a persisted plan file gated by user approval before implementation" - }, - { - "id": AgentExecMode::Debug.as_str(), - "name": "Debug", - "description": "Diagnostics mode - reproduce, narrow hypotheses, root-cause bugs" - } - ])) -} +// `agent_list_modes` was removed (Orgtrack migration, mode-list +// convergence): it returned a fourth divergent mode catalog (4 entries) +// that no UI ever called — the picker is driven by the TS +// `AGENT_EXEC_MODES` constant in sessionCreatorConfig.ts, which is the +// single user-facing list. /// Resolve review: clear file resolutions and snapshots for a session. #[tauri::command] 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 9552b4490..4bcc057b0 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 @@ -46,6 +46,7 @@ pub(crate) async fn create_session_impl( agent_definition_id: Option, key_source: Option, agent_exec_mode: Option, + product_mode: Option, native_harness_type: Option, parent_session_id: Option, ) -> Result { @@ -137,6 +138,17 @@ pub(crate) async fn create_session_impl( // Empty/whitespace strings are treated as "no choice" so we don't trip // the dispatcher's mode parser with an empty value. agent_exec_mode: agent_exec_mode.filter(|m| !m.trim().is_empty()), + // Product-mode resolver (orgtrack/v1 frozen decisions §1), fixed + // precedence: launched from a WorkItem/Routine → project; the + // user's explicit launch-time choice; else NULL (= build). Never + // inferred from exec mode, query length or agent judgment. + product_mode: if wid_for_link.is_some() { + Some("project".to_string()) + } else { + product_mode.filter(|m| { + matches!(m.as_str(), "build" | "plan" | "ask" | "project") + }) + }, native_harness_type: resolved_native_harness_type, ..Default::default() }; 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 25a742915..a5b7eef30 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 @@ -177,6 +177,28 @@ pub async fn debug_session_execute_tool( .clone() .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. + 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 + .as_ref() + .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); + 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:?})" + )); + } + let mut result = runtime .tool_registry .execute( 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 21d38ea9b..87640c8cf 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 @@ -68,6 +68,11 @@ pub struct SessionLaunchParams { #[serde(default)] pub isolate: bool, pub mode: Option, + /// Product mode (`orgtrack/v1` §5.2): `build | plan | ask | project`. + /// Distinct from `mode` (the runtime exec mode) — the launch-from- + /// work/routine resolver overrides this with `project` server-side. + #[serde(default)] + pub product_mode: Option, // Project/collaboration org + work-item fields pub org_id: Option, @@ -250,6 +255,7 @@ async fn launch_rust_agent( org_context, provenance, mode: params.mode, + product_mode: params.product_mode, name: Some(name.clone()), images: params.images, ide_context: params.ide_context, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/mod.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/mod.rs index 26350ad76..a811c7245 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/mod.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/mod.rs @@ -23,6 +23,7 @@ mod entry_points; mod exec_mode; mod org_wake; +pub(crate) mod project_bootstrap; mod send; /// Kept under its historical module name so the `resolve_agent_mode` invariant 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 new file mode 100644 index 000000000..93736b493 --- /dev/null +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/project_bootstrap.rs @@ -0,0 +1,149 @@ +//! Project-session root WorkItem bootstrap (orgtrack/v1 §7.2). +//! +//! A Project session that has no active WorkItem gets its root created +//! when the first non-empty user submission is accepted — not when the +//! mode is switched and not when an empty session is opened. The +//! creation boundary is this host event; no LLM classification is +//! involved. The root's body preserves the original user request +//! verbatim (the derived short title never replaces it), and the +//! operation runs under a `(sessionRef)`-derived idempotency key so a +//! retried first submission cannot produce a duplicate root: if an +//! 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. +pub(super) async fn ensure_project_root_work_item(session_id: &str, content: &str) { + if content.trim().is_empty() { + return; + } + let sid = session_id.to_string(); + let body = content.to_string(); + let joined = + tokio::task::spawn_blocking(move || bootstrap_root_work_item(&sid, &body)).await; + match joined { + Ok(Ok(Some(short_id))) => { + tracing::info!( + session_id, + short_id, + "[project-bootstrap] created and linked root work item" + ); + } + Ok(Ok(None)) => {} + Ok(Err(err)) => { + tracing::warn!(session_id, error = %err, "[project-bootstrap] failed"); + } + Err(err) => { + tracing::warn!(session_id, error = %err, "[project-bootstrap] worker failed"); + } + } +} + +/// 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. +pub(crate) fn bootstrap_root_work_item( + session_id: &str, + content: &str, +) -> Result, String> { + let record = crate::session::persistence::get_session(session_id) + .map_err(|err| format!("load session record: {err}"))?; + let Some(record) = record else { + return Ok(None); + }; + if record.product_mode.as_deref() != Some("project") || record.work_item_id.is_some() { + 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, + 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 })) + }, + )?; + + 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/message/send.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs index 924997f10..15cf9053f 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs @@ -356,6 +356,15 @@ pub(crate) async fn send_message_impl( } } + // ── 4b. Project root WorkItem bootstrap (orgtrack/v1 §7.2) ────────── + // + // The first accepted non-empty submission of a Project session with + // no active WorkItem creates and links its root. Resumes replay an + // already-accepted submission, so they never bootstrap. + if !is_resume { + super::project_bootstrap::ensure_project_root_work_item(&session_id, &content).await; + } + // ── 5. Build the processing closure ────────────────────────────────── let sid_for_closure = session_id.clone(); let content_for_closure = content.clone(); 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 e7a19016a..6cd8d59f3 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 @@ -860,6 +860,87 @@ pub async fn agent_link_session_to_work_item( shared::to_json_value(updated_record).map_err(|err| err.to_string()) } +/// `Track this` (orgtrack/v1 §7.2, Build→Project) and +/// `Convert to Project` (Plan→Project): switch the session onto the +/// Project product mode, derive the runtime exec mode the same way the +/// composer picker does (project → build), invalidate Plan mode's +/// snapshot/restore state, and create-or-replay the root WorkItem from +/// the already-recorded first user input. Earlier turns stay untouched +/// as provenance. Returns `{ productMode, agentExecMode, workItemId }`. +#[tauri::command] +pub async fn agent_track_session_as_project( + app: tauri::AppHandle, + state: tauri::State<'_, crate::state::AgentAppState>, + session_id: String, +) -> Result { + let sid = session_id.clone(); + let (work_item_id, exec_mode) = tokio::task::spawn_blocking(move || { + let record = session_persistence::get_session(&sid) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("Session not found: {sid}"))?; + + session_persistence::update_product_mode(&sid, "project") + .map_err(|err| format!("track session: set product_mode: {err}"))?; + + // Same derivation the ModePill applies: Project pins the exec + // mode to Build (a read-only Plan session would otherwise keep + // its deny layer while claiming to do project work). + let exec_mode = crate::session::AgentExecMode::Build; + if record.agent_exec_mode.as_deref() != Some(exec_mode.as_str()) { + session_persistence::update_agent_exec_mode(&sid, exec_mode.as_str()) + .map_err(|err| format!("track session: set exec mode: {err}"))?; + } + + // Root creation at conversion time, from the recorded first + // user input. An empty session converts mode-only; the + // first-submission bootstrap covers the root later. + let content = record.user_input.clone().unwrap_or_default(); + let work_item_id = if record.work_item_id.is_some() { + record.work_item_id + } else if content.trim().is_empty() { + None + } else { + super::message::project_bootstrap::bootstrap_root_work_item(&sid, &content)? + }; + Ok::<_, String>((work_item_id, exec_mode)) + }) + .await + .map_err(|err| err.to_string())??; + + // Convert to Project invalidates the Plan snapshot/restore state so + // a pending approval can't bounce later turns back to the old mode. + if let Some(session) = state.get_session(&session_id).await { + let had_slot = session.plan_slot_cache.get(&session_id).is_some(); + let _ = session.pre_plan_mode_cache.take(&session_id); + session.plan_slot_cache.clear(&session_id); + if had_slot { + crate::bus::broadcast_event( + "agent:exit_plan_mode", + serde_json::json!({ + "sessionId": &session_id, + "source": "convert_to_project", + "nextMode": exec_mode.as_str(), + }), + ); + } + } + + { + use tauri::Emitter; + let ts = chrono::Utc::now().to_rfc3339(); + let _ = app.emit( + project_management::projects::events::DATA_CHANGED_EVENT, + &ts, + ); + } + + Ok(serde_json::json!({ + "productMode": "project", + "agentExecMode": exec_mode.as_str(), + "workItemId": work_item_id, + })) +} + fn link_session_to_work_item_sync( session_id: &str, org_id: Option<&str>, 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 2b6257dea..684e3a771 100644 --- a/src-tauri/crates/agent-core/src/state/commands/tools.rs +++ b/src-tauri/crates/agent-core/src/state/commands/tools.rs @@ -231,7 +231,10 @@ 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 effective_policy = runtime.policy.with_exec_mode(agent_exec_mode); + 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 mut registered_tool_names = runtime.tool_registry.tool_names(); registered_tool_names.sort(); diff --git a/src-tauri/crates/orgtrack-cli/README.md b/src-tauri/crates/orgtrack-cli/README.md index e1008785b..bc3c9f000 100644 --- a/src-tauri/crates/orgtrack-cli/README.md +++ b/src-tauri/crates/orgtrack-cli/README.md @@ -232,6 +232,6 @@ is mechanical. The path to an independent publish: and ship prebuilt binaries (the existing `.goreleaser`-style release tooling in the repo can cross-compile a static, CGO-free binary thanks to bundled SQLite). -3. The `@orgii/orgtrack` npm package (`packages/orgtrack`) can then become the - Node distribution wrapper that downloads/execs this binary, replacing its - current stub entrypoint. +3. An npm distribution wrapper that downloads/execs this binary can be added + at that point (the old `packages/orgtrack` stub was removed — recreate it + only when there is a real binary to wrap). diff --git a/src-tauri/crates/orgtrack-pm-cli/Cargo.toml b/src-tauri/crates/orgtrack-pm-cli/Cargo.toml new file mode 100644 index 000000000..acf9b520d --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "orgtrack-pm-cli" +version = "0.1.0" +edition = "2021" +publish = false +description = "Orgtrack PM protocol CLI (orgtrack/v1): context/work commands for external agents. Installed on PATH as `org2` by the distribution; the cargo bin name org2-pm stays workspace-unique (frozen decision docs/orgtrack-pm-protocol/decisions.md §4)." + +[[bin]] +name = "org2-pm" +path = "src/main.rs" + +[dependencies] +project_management = { path = "../project-management" } +database = { path = "../database" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +chrono = { version = "0.4", features = ["serde"] } + +[dev-dependencies] +test_helpers = { path = "../test-helpers" } +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 new file mode 100644 index 000000000..40ccc4cd8 --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/src/commands.rs @@ -0,0 +1,781 @@ +//! `context` and the eight `work` commands (`orgtrack/v1` §13). +//! +//! Wire-shape residuals during migration, documented against the frozen +//! contract: +//! - work items serialize their store shape (legacy status vocabulary + +//! snake_case frontmatter) plus a `portableState` projection and a +//! `revision`; the full portable shape lands with the Phase 7 UI +//! switch. +//! - `--ready` filters on portable `open` with no active claim; the +//! dependency graph (`dependsOn`) arrives with the Routine rebuild. +//! - `claim` composes the execution-lock acquire with the strict +//! `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 +//! (`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, +}; +use project_management::work_service; + +use crate::context::{ExecutionContext, ProductMode}; +use crate::envelope::{emit_error, emit_success, CliError, ErrorCode}; + +pub fn cmd_context(context: &ExecutionContext) -> i32 { + emit_success(crate::context::to_wire(context), None, None) +} + +fn mutation_actor(context: &ExecutionContext) -> Result { + let actor = context.require_actor()?; + Ok(WorkItemMutationActor { + id: format!("{}:{}", actor.kind, actor.id), + name: actor.id.clone(), + }) +} + +fn item_to_wire(item: &WorkItemData, revision: Option) -> serde_json::Value { + let portable = work_service::state::map_legacy_status(&item.frontmatter.status) + .map(|state| state.as_str()); + let mut value = serde_json::to_value(item).unwrap_or_default(); + if let Some(object) = value.as_object_mut() { + object.insert("portableState".into(), serde_json::json!(portable)); + object.insert("revision".into(), serde_json::json!(revision)); + } + value +} + +pub fn dispatch_work( + context: &ExecutionContext, + positionals: &[String], + flags: &HashMap, +) -> 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("create") => cmd_work_create(context, flags), + Some("update") => cmd_work_update(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), + Some("relate") => cmd_work_relate(context, positionals.get(1), flags), + other => emit_error(CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown work subcommand '{}'; expected list|show|create|update|claim|transition|note|relate", + other.unwrap_or("") + ), + )), + } +} + +/// Idempotency guard for mutation commands (§14.4): when the caller +/// passed `--idempotency-key`, the operation runs at most once per +/// `(actor, operation, scope, key)`; a replay returns the stored wire +/// data without re-executing. +fn guarded( + actor_id: &str, + operation: &'static str, + scope: &str, + idempotency_key: Option<&String>, + canonical: serde_json::Value, + execute: impl FnOnce() -> Result, +) -> Result { + match idempotency_key { + None => execute().map_err(CliError::from_service), + Some(key) => { + match work_service::run_idempotent(actor_id, operation, scope, key, &canonical, execute) + { + Ok(work_service::IdempotencyOutcome::Fresh(value)) + | Ok(work_service::IdempotencyOutcome::Replayed(value)) => Ok(value), + Err(err) => Err(CliError::from_service(err)), + } + } + } +} + +fn require_short_id(short_id: Option<&String>) -> Result { + short_id.cloned().ok_or_else(|| { + CliError::new( + ErrorCode::InvalidArgument, + "Missing work item id (usage: org2 work ...)", + ) + }) +} + +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 = match pio::read_all_work_items(&scope) { + Ok(items) => items, + Err(err) => return emit_error(CliError::from_service(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 filtered: Vec = items + .iter() + .filter(|item| item.frontmatter.deleted_at.is_none()) + .filter(|item| { + status_filter + .map(|status| &item.frontmatter.status == status) + .unwrap_or(true) + }) + .filter(|item| { + if !ready_only { + return true; + } + // Ready = portable open with no active claim. Dependency + // readiness joins in when dependsOn lands (Phase 4). + let open = matches!( + work_service::state::map_legacy_status(&item.frontmatter.status), + Some(work_service::WorkItemState::Open) + ); + let unclaimed = item + .frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_ref()) + .is_none(); + open && unclaimed + }) + .take(limit) + .map(|item| item_to_wire(item, None)) + .collect(); + + emit_success(serde_json::json!({ "items": filtered }), None, None) +} + +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), + }; + let short_id = match require_short_id(short_id) { + Ok(short_id) => short_id, + 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)), + }; + let revision = work_service::read_project_work_item_revision(&scope, &short_id).ok(); + let relations = work_service::list_work_item_relations(&short_id).unwrap_or_default(); + let mut wire = item_to_wire(&item, revision); + if let Some(object) = wire.as_object_mut() { + object.insert("relations".into(), serde_json::json!(relations)); + } + emit_success(wire, revision, None) +} + +fn cmd_work_create(context: &ExecutionContext, flags: &HashMap) -> i32 { + 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), + }; + let Some(title) = flags.get("title").filter(|value| !value.trim().is_empty()) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work create requires --title", + )); + }; + let canonical = serde_json::json!({ + "op": "work.create", + "title": title, + "body": flags.get("body"), + "status": flags.get("status"), + "priority": flags.get("priority"), + }); + let request = work_service::CreateWorkItemRequest { + title: title.clone(), + body: flags.get("body").cloned().unwrap_or_default(), + status: flags.get("status").cloned(), + priority: flags.get("priority").cloned(), + created_by: Some(actor.id.clone()), + ..Default::default() + }; + let scope_for_exec = scope.clone(); + let actor_for_exec = actor.clone(); + let result = guarded( + &actor.id, + "work.create", + &scope, + flags.get("idempotency-key"), + canonical, + move || { + let short_id = pio::allocate_short_id(&scope_for_exec)?; + let item = work_service::create_project_work_item( + &scope_for_exec, + &short_id, + &request, + Some(&actor_for_exec), + )?; + let revision = + work_service::read_project_work_item_revision(&scope_for_exec, &short_id).ok(); + Ok(item_to_wire(&item, revision)) + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + } +} + +fn cmd_work_update( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + 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(), + 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), + }; + if flags.contains_key("status") || flags.contains_key("to") { + // work.update is non-lifecycle by contract; state changes go + // through work.transition (and claim for open -> in_progress). + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "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() + }; + 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) + } + Err(err) => emit_error(CliError::from_service(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 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(session_ref) = context.session_ref.as_ref() else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work claim requires --session-ref (claim records the executing session)", + )); + }; + if let Err(err) = project_management::provider_host::validate_session_ref( + &session_ref.provider, + &session_ref.external_id, + ) { + return emit_error( + CliError::new(ErrorCode::InvalidArgument, err).with_details(serde_json::json!({ + "field": "--session-ref", + "provider": session_ref.provider, + })), + ); + } + let expected_revision = flags + .get("expected-revision") + .and_then(|value| value.parse::().ok()); + + let canonical = serde_json::json!({ + "op": "work.claim", + "shortId": short_id, + "sessionRef": format!("{}:{}", session_ref.provider, session_ref.external_id), + "expectedRevision": expected_revision, + }); + let scope_for_exec = scope.clone(); + let short_id_for_exec = short_id.clone(); + let session_id = session_ref.external_id.clone(); + let actor_for_exec = actor.clone(); + let result = guarded( + &actor.id, + "work.claim", + &scope, + 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( + &scope_for_exec, + &short_id_for_exec, + &session_id, + Some("custom"), + WorkItemExecutionLockReason::ManualStart, + )?; + match work_service::transition_project_work_item( + &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) + } + } + }, + ); + match result { + Ok(wire) => emit_success(wire, None, None), + Err(err) => emit_error(err), + } +} + +fn cmd_work_transition( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + 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), + }; + let actor = match mutation_actor(context) { + Ok(actor) => actor, + Err(err) => return emit_error(err), + }; + let Some(to_state) = flags.get("to") else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work transition requires --to ", + )); + }; + if work_service::WorkItemState::parse(to_state).is_none() { + return emit_error( + CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown state '{}'; expected one of open|in_progress|blocked|completed|failed|cancelled", + to_state + ), + ) + .with_details(serde_json::json!({ "field": "--to", "value": to_state })), + ); + } + if to_state == "in_progress" { + // §9.3: in_progress is only entered via work.claim (or + // blocked -> in_progress resume, which claim also covers). + return emit_error(CliError::new( + ErrorCode::InvalidTransition, + "in_progress is only entered via work claim", + )); + } + let expected_revision = flags + .get("expected-revision") + .and_then(|value| value.parse::().ok()); + let canonical = serde_json::json!({ + "op": "work.transition", + "shortId": short_id, + "to": to_state, + "reason": flags.get("reason"), + "expectedRevision": expected_revision, + }); + let scope_for_exec = scope.clone(); + let short_id_for_exec = short_id.clone(); + let to_state_owned = to_state.clone(); + let reason = flags.get("reason").cloned(); + let actor_for_exec = actor.clone(); + let result = guarded( + &actor.id, + "work.transition", + &scope, + flags.get("idempotency-key"), + canonical, + move || { + let item = work_service::transition_project_work_item( + &scope_for_exec, + &short_id_for_exec, + &to_state_owned, + reason.as_deref(), + Some(&actor_for_exec), + 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_note( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + 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), + }; + 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(body) = flags.get("body").filter(|value| !value.trim().is_empty()) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work note requires --body", + )); + }; + 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 + ), + )); + } + 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)), + } +} + +fn cmd_work_relate( + context: &ExecutionContext, + short_id: Option<&String>, + flags: &HashMap, +) -> i32 { + if let Err(err) = context.require_project_mode("work.relate") { + 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(kind), Some(target)) = (flags.get("type"), flags.get("target")) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "work relate requires --type and --target ", + )); + }; + // session:// targets must name a registered provenance provider in + // the canonical namespace (reference-only validation, §15.6). + if let Some(rest) = target.strip_prefix("session://") { + let (provider, external_id) = rest.split_once('/').unwrap_or((rest, "")); + if let Err(err) = + project_management::provider_host::validate_session_ref(provider, external_id) + { + return emit_error( + CliError::new(ErrorCode::InvalidArgument, err).with_details(serde_json::json!({ + "field": "--target", + "provider": provider, + })), + ); + } + } + match work_service::relate_project_work_item(&scope, &short_id, kind, target, Some(&actor)) { + Ok(()) => emit_success( + serde_json::json!({ "related": true, "kind": kind, "targetRef": target }), + None, + None, + ), + Err(err) => { + if err.contains("is not portable") { + return emit_error( + CliError::new( + ErrorCode::InvalidArgument, + format!( + "Relation kind '{}' is not portable (depends_on|relates_to|duplicates|implements|supersedes|continued_by|generated_by|participated_in)", + kind + ), + ) + .with_details(serde_json::json!({ "field": "--type", "value": kind })), + ); + } + emit_error(CliError::from_service(err)) + } + } +} + +// ============================================ +// Routine commands (§13.3) +// ============================================ + +use project_management::routine_service; + +fn load_spec_file(path: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|err| { + CliError::new( + ErrorCode::InvalidArgument, + format!("Cannot read routine file '{}': {}", path, err), + ) + })?; + // YAML is a superset of JSON here: one parser handles both authoring + // formats; the canonical stored form is always JSON. + serde_yaml::from_str(&raw).map_err(|err| { + CliError::new( + ErrorCode::InvalidArgument, + format!("Routine file '{}' does not match the portable spec: {}", path, err), + ) + }) +} + +fn routine_error(err: String) -> CliError { + if let Some(details) = err.strip_prefix(routine_service::error::SPEC_INVALID) { + let violations: serde_json::Value = + serde_json::from_str(details.trim_start_matches(':')).unwrap_or_default(); + return CliError::new( + ErrorCode::InvalidArgument, + "Routine spec failed validation", + ) + .with_details(serde_json::json!({ "violations": violations })); + } + if let Some(rest) = err.strip_prefix(routine_service::error::INPUTS_INVALID) { + return CliError::new( + ErrorCode::InvalidArgument, + format!("Routine inputs invalid: {}", rest.trim_start_matches(':')), + ); + } + CliError::from_service(err) +} + +pub fn dispatch_routine( + context: &ExecutionContext, + positionals: &[String], + flags: &HashMap, + inputs: &[(String, String)], +) -> i32 { + match positionals.first().map(String::as_str) { + Some("list") => match routine_service::list_routines() { + Ok(rows) => emit_success(serde_json::json!({ "items": rows }), None, None), + Err(err) => emit_error(CliError::from_service(err)), + }, + Some("validate") => { + let Some(path) = flags.get("file") else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "routine validate requires --file ", + )); + }; + let file = match load_spec_file(path) { + Ok(file) => file, + Err(err) => return emit_error(err), + }; + let violations = routine_service::spec::validate(&file); + if violations.is_empty() { + emit_success(serde_json::json!({ "valid": true }), None, None) + } else { + emit_error( + CliError::new(ErrorCode::InvalidArgument, "Routine spec failed validation") + .with_details(serde_json::json!({ + "violations": serde_json::to_value(&violations).unwrap_or_default(), + })), + ) + } + } + Some("apply") => { + if let Err(err) = context.require_project_mode("routine.apply") { + return emit_error(err); + } + let Some(path) = flags.get("file") else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "routine apply requires --file ", + )); + }; + let file = match load_spec_file(path) { + Ok(file) => file, + Err(err) => return emit_error(err), + }; + match routine_service::apply(&file) { + Ok(applied) => emit_success( + serde_json::json!({ + "name": applied.name, + "revision": applied.revision, + "specHash": applied.spec_hash, + "changed": applied.changed, + }), + Some(applied.revision), + None, + ), + Err(err) => emit_error(routine_error(err)), + } + } + Some("run") => { + if let Err(err) = context.require_project_mode("routine.run") { + 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), + }; + let Some(name) = positionals.get(1) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 routine run --input k=v ...", + )); + }; + let input_map: std::collections::BTreeMap = + inputs.iter().cloned().collect(); + match routine_service::invoke(name, &scope, &input_map, Some(&actor)) { + Ok(run) => emit_success( + serde_json::json!({ + "runId": run.run_id, + "rootWorkItemId": run.root_short_id, + "steps": run + .steps + .iter() + .map(|(step, short_id)| serde_json::json!({ + "stepId": step, + "workItemId": short_id, + })) + .collect::>(), + }), + None, + None, + ), + Err(err) => emit_error(routine_error(err)), + } + } + Some("status") => { + let Some(run_id) = positionals.get(1) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + "Usage: org2 routine status ", + )); + }; + match routine_service::run_status(run_id) { + Ok(view) => emit_success(view, None, None), + Err(err) => emit_error(CliError::from_service(err)), + } + } + Some(action @ ("enable" | "disable")) => { + if let Err(err) = context.require_project_mode("routine.set_enabled") { + return emit_error(err); + } + let Some(name) = positionals.get(1) else { + return emit_error(CliError::new( + ErrorCode::InvalidArgument, + format!("Usage: org2 routine {} ", action), + )); + }; + match routine_service::set_enabled(name, action == "enable") { + Ok(()) => emit_success( + serde_json::json!({ "name": name, "enabled": action == "enable" }), + None, + None, + ), + Err(err) => emit_error(CliError::from_service(err)), + } + } + Some("cancel") => emit_error(CliError::new( + ErrorCode::UnsupportedCapability, + "routine cancel lands with the Phase 5 runtime (cancel_requested machinery)", + )), + other => emit_error(CliError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown routine subcommand '{}'; expected list|validate|apply|run|status|enable|disable", + other.unwrap_or("") + ), + )), + } +} + +// ProductMode is re-exported for the unused-import lint when features +// shift; keep the type referenced. +#[allow(dead_code)] +fn _mode_witness(mode: ProductMode) -> &'static str { + mode.as_str() +} diff --git a/src-tauri/crates/orgtrack-pm-cli/src/context.rs b/src-tauri/crates/orgtrack-pm-cli/src/context.rs new file mode 100644 index 000000000..4d9e27b67 --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/src/context.rs @@ -0,0 +1,273 @@ +//! Context resolver (`orgtrack/v1` §8.2, frozen decisions §1/§2/§7). +//! +//! Trusted-local resolution order per field: explicit CLI flags → +//! `ORGII_*` environment → the workspace manifest +//! (`.orgii/orgtrack.json` in the working directory). Nothing is ever +//! inferred from the OS username, git owner or last-used actor. +//! Capabilities are never read from flags/env/manifest — they are the +//! intersection of the mode allowlist with actor/org policy (local +//! trusted mode has no org policy service yet, so policy is the +//! identity; the remote authority classes arrive with the hosted mode). + +use crate::envelope::{CliError, ErrorCode}; +use serde::Serialize; + +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 ALL_CAPABILITIES: &[&str] = &[ + "work.read", + "work.create", + "work.update", + "work.claim", + "work.transition", + "work.note", + "work.relate", + "routine.read", + "routine.apply", + "routine.run", + "routine.cancel", + "routine.set_enabled", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductMode { + Build, + Plan, + Ask, + Project, +} + +impl ProductMode { + pub fn parse(raw: &str) -> Option { + match raw { + "build" => Some(ProductMode::Build), + "plan" => Some(ProductMode::Plan), + "ask" => Some(ProductMode::Ask), + "project" => Some(ProductMode::Project), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + ProductMode::Build => "build", + ProductMode::Plan => "plan", + ProductMode::Ask => "ask", + ProductMode::Project => "project", + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActorRef { + pub kind: String, + pub id: String, +} + +impl ActorRef { + /// Parse `kind:rest-of-id` (the id itself may contain colons). + pub fn parse(raw: &str) -> Option { + let (kind, id) = raw.split_once(':')?; + if id.is_empty() { + return None; + } + match kind { + "human" | "agent" | "service" | "team" => Some(ActorRef { + kind: kind.to_string(), + id: id.to_string(), + }), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRef { + pub provider: String, + pub external_id: String, +} + +impl SessionRef { + /// Parse `provider:external-id`. + pub fn parse(raw: &str) -> Option { + let (provider, external_id) = raw.split_once(':')?; + if provider.is_empty() || external_id.is_empty() { + return None; + } + Some(SessionRef { + provider: provider.to_string(), + external_id: external_id.to_string(), + }) + } +} + +#[derive(Debug, serde::Deserialize)] +struct WorkspaceManifest { + version: u32, + #[serde(rename = "scopeId")] + scope_id: Option, + #[serde(rename = "orgId")] + org_id: Option, +} + +/// Resolved execution context for one CLI invocation. +#[derive(Debug)] +pub struct ExecutionContext { + pub mode: ProductMode, + /// v1 local model: the scope id IS the project slug. + pub scope_id: Option, + pub org_id: Option, + pub actor: Option, + pub session_ref: Option, + pub capabilities: Vec<&'static str>, +} + +impl ExecutionContext { + pub fn capabilities_for(mode: ProductMode) -> Vec<&'static str> { + // Frozen mode-capability matrix (decisions §2): only Project + // exposes the mutation surface; every other mode is context-only. + match mode { + ProductMode::Project => ALL_CAPABILITIES.to_vec(), + _ => Vec::new(), + } + } + + pub fn require_project_mode(&self, operation: &str) -> Result<(), CliError> { + if self.mode != ProductMode::Project { + return Err(CliError::new( + ErrorCode::ProjectModeRequired, + format!( + "{} is a WorkItem/Routine mutation; current mode is '{}'. Switch the session to Project mode or pass --mode project", + operation, + self.mode.as_str() + ), + ) + .with_details(serde_json::json!({ + "operation": operation, + "currentMode": self.mode.as_str(), + }))); + } + Ok(()) + } + + pub fn require_scope(&self) -> Result<&str, CliError> { + self.scope_id.as_deref().ok_or_else(|| { + CliError::new( + ErrorCode::ContextRequired, + "No scope resolved: pass --scope, set ORGII_SCOPE, or run inside an initialized workspace (.orgii/orgtrack.json)", + ) + .with_details(serde_json::json!({ "missing": ["scopeId"] })) + }) + } + + pub fn require_actor(&self) -> Result<&ActorRef, CliError> { + self.actor.as_ref().ok_or_else(|| { + CliError::new( + ErrorCode::ActorRequired, + "No actor resolved: pass --actor or set ORGII_ACTOR (actors are never inferred from OS username or git owner)", + ) + .with_details(serde_json::json!({ "missing": ["actor"] })) + }) + } +} + +fn read_manifest() -> Option { + let path = std::env::current_dir().ok()?.join(".orgii/orgtrack.json"); + let raw = std::fs::read_to_string(path).ok()?; + let manifest: WorkspaceManifest = serde_json::from_str(&raw).ok()?; + // `.orgii/` existing is NOT initialization; only a readable manifest + // with a supported version counts (decisions §7). + if manifest.version != 1 { + return None; + } + Some(manifest) +} + +/// Resolve the context from flags (already extracted by the arg parser), +/// environment, and the workspace manifest — in that order, per field. +pub fn resolve( + flag_mode: Option<&str>, + flag_scope: Option<&str>, + flag_actor: Option<&str>, + flag_session_ref: Option<&str>, +) -> Result { + let manifest = read_manifest(); + + let mode_raw = flag_mode + .map(str::to_string) + .or_else(|| std::env::var(ENV_MODE).ok()) + .unwrap_or_else(|| "build".to_string()); + let mode = ProductMode::parse(&mode_raw).ok_or_else(|| { + CliError::new( + ErrorCode::InvalidArgument, + format!("Unknown mode '{}'; expected build|plan|ask|project", mode_raw), + ) + .with_details(serde_json::json!({ "field": "--mode", "value": mode_raw })) + })?; + + let scope_id = flag_scope + .map(str::to_string) + .or_else(|| std::env::var(ENV_SCOPE).ok()) + .or_else(|| manifest.as_ref().and_then(|m| m.scope_id.clone())); + + let actor = match flag_actor + .map(str::to_string) + .or_else(|| std::env::var(ENV_ACTOR).ok()) + { + Some(raw) => Some(ActorRef::parse(&raw).ok_or_else(|| { + CliError::new( + ErrorCode::InvalidArgument, + format!( + "Invalid actor '{}'; expected :", + raw + ), + ) + .with_details(serde_json::json!({ "field": "--actor", "value": raw })) + })?), + None => None, + }; + + let session_ref = match flag_session_ref + .map(str::to_string) + .or_else(|| std::env::var(ENV_SESSION_REF).ok()) + { + Some(raw) => Some(SessionRef::parse(&raw).ok_or_else(|| { + CliError::new( + ErrorCode::InvalidArgument, + format!("Invalid session ref '{}'; expected :", raw), + ) + .with_details(serde_json::json!({ "field": "--session-ref", "value": raw })) + })?), + None => None, + }; + + let capabilities = ExecutionContext::capabilities_for(mode); + Ok(ExecutionContext { + mode, + scope_id, + org_id: manifest.as_ref().and_then(|m| m.org_id.clone()), + actor, + session_ref, + capabilities, + }) +} + +/// Wire shape of `org2 context` (execution-context.schema.json). +pub fn to_wire(context: &ExecutionContext) -> serde_json::Value { + serde_json::json!({ + "apiVersion": crate::envelope::API_VERSION, + "mode": context.mode.as_str(), + "scopeId": context.scope_id, + "orgId": context.org_id.clone().unwrap_or_else(|| "personal-org".to_string()), + "actor": context.actor, + "sessionRef": context.session_ref, + "runtimeProvider": { "id": "org2", "profiles": ["execution", "provenance"] }, + "activeWorkItemId": serde_json::Value::Null, + "capabilities": context.capabilities, + }) +} diff --git a/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs b/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs new file mode 100644 index 000000000..dae360f21 --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/src/envelope.rs @@ -0,0 +1,292 @@ +//! `orgtrack/v1` CLI response envelopes and stable error codes. +//! +//! Byte-for-byte contract lives in `docs/orgtrack-pm-protocol/` +//! (envelope.schema.json + golden fixtures). stdout carries exactly one +//! JSON envelope; diagnostics go to stderr; exit codes follow the frozen +//! error-to-exit table (decisions.md §3). + +use serde::Serialize; + +pub const API_VERSION: &str = "orgtrack/v1"; + +/// Stable wire error codes (envelope.schema.json enum, frozen Phase 0). +/// The full enum ships from day one even though some codes only fire in +/// later slices (idempotency, providers) — the vocabulary is the contract. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCode { + InvalidArgument, + ContextRequired, + ActorRequired, + ProjectModeRequired, + NotFound, + AlreadyExists, + RevisionConflict, + IdempotencyConflict, + NotReady, + AlreadyClaimed, + InvalidTransition, + ResultSchemaMismatch, + DependencyCycle, + ScopeViolation, + PermissionDenied, + ProviderUnavailable, + UnsupportedCapability, + StoreUnavailable, +} + +impl ErrorCode { + pub fn as_str(self) -> &'static str { + match self { + ErrorCode::InvalidArgument => "INVALID_ARGUMENT", + ErrorCode::ContextRequired => "CONTEXT_REQUIRED", + ErrorCode::ActorRequired => "ACTOR_REQUIRED", + ErrorCode::ProjectModeRequired => "PROJECT_MODE_REQUIRED", + ErrorCode::NotFound => "NOT_FOUND", + ErrorCode::AlreadyExists => "ALREADY_EXISTS", + ErrorCode::RevisionConflict => "REVISION_CONFLICT", + ErrorCode::IdempotencyConflict => "IDEMPOTENCY_CONFLICT", + ErrorCode::NotReady => "NOT_READY", + ErrorCode::AlreadyClaimed => "ALREADY_CLAIMED", + ErrorCode::InvalidTransition => "INVALID_TRANSITION", + ErrorCode::ResultSchemaMismatch => "RESULT_SCHEMA_MISMATCH", + ErrorCode::DependencyCycle => "DEPENDENCY_CYCLE", + ErrorCode::ScopeViolation => "SCOPE_VIOLATION", + ErrorCode::PermissionDenied => "PERMISSION_DENIED", + ErrorCode::ProviderUnavailable => "PROVIDER_UNAVAILABLE", + ErrorCode::UnsupportedCapability => "UNSUPPORTED_CAPABILITY", + ErrorCode::StoreUnavailable => "STORE_UNAVAILABLE", + } + } + + /// Frozen error-to-exit mapping (decisions.md §3). PROJECT_MODE_REQUIRED + /// (5) and PERMISSION_DENIED (8) stay separate so a harness can tell + /// "switch the mode" from "this actor may never do this". + pub fn exit_code(self) -> i32 { + match self { + ErrorCode::InvalidArgument + | ErrorCode::ResultSchemaMismatch + | ErrorCode::DependencyCycle => 2, + ErrorCode::NotFound | ErrorCode::ContextRequired | ErrorCode::ActorRequired => 3, + ErrorCode::RevisionConflict + | ErrorCode::IdempotencyConflict + | ErrorCode::AlreadyClaimed + | ErrorCode::AlreadyExists + | ErrorCode::NotReady + | ErrorCode::InvalidTransition => 4, + ErrorCode::ProjectModeRequired => 5, + ErrorCode::ProviderUnavailable | ErrorCode::StoreUnavailable => 6, + ErrorCode::UnsupportedCapability => 7, + ErrorCode::PermissionDenied | ErrorCode::ScopeViolation => 8, + } + } + + pub fn retryable(self) -> bool { + matches!( + self, + ErrorCode::RevisionConflict + | ErrorCode::NotReady + | ErrorCode::ProviderUnavailable + | ErrorCode::StoreUnavailable + ) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +pub struct CliError { + pub code: ErrorCode, + pub message: String, + pub details: serde_json::Value, +} + +impl CliError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + CliError { + code, + message: message.into(), + details: serde_json::Value::Null, + } + } + + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = details; + self + } + + /// Map the work-service `PM_ERR:` sentinels and common store errors + /// onto stable wire codes. Everything unrecognized is a store error — + /// the CLI never invents new codes. + pub fn from_service(err: String) -> Self { + use project_management::work_service::error as pm; + if let Some(rest) = err.strip_prefix(pm::REVISION_CONFLICT) { + let mut parts = rest.trim_start_matches(':').split(':'); + let expected = parts.next().and_then(|v| v.parse::().ok()); + let current = parts.next().and_then(|v| v.parse::().ok()); + return CliError::new( + ErrorCode::RevisionConflict, + format!( + "resource changed after revision {}", + expected.unwrap_or_default() + ), + ) + .with_details(serde_json::json!({ + "expectedRevision": expected, + "currentRevision": current, + })); + } + if let Some(rest) = err.strip_prefix(pm::IDEMPOTENCY_CONFLICT) { + let mut parts = rest.trim_start_matches(':').split(':'); + let operation = parts.next().unwrap_or_default().to_string(); + let key = parts.next().unwrap_or_default().to_string(); + return CliError::new( + ErrorCode::IdempotencyConflict, + format!( + "Idempotency key '{}' was already used with a different canonical request", + key + ), + ) + .with_details(serde_json::json!({ + "idempotencyKey": key, + "operation": operation, + })); + } + if let Some(rest) = err.strip_prefix(pm::INVALID_TRANSITION) { + let mut parts = rest.trim_start_matches(':').split(':'); + let from = parts.next().unwrap_or_default().to_string(); + let to = parts.next().unwrap_or_default().to_string(); + return CliError::new( + ErrorCode::InvalidTransition, + format!("{} -> {} is not an allowed transition", from, to), + ) + .with_details(serde_json::json!({ "from": from, "to": to })); + } + if err.contains("not found") || err.contains("Not found") { + return CliError::new(ErrorCode::NotFound, err); + } + if err.contains("already has an active execution session") { + return CliError::new(ErrorCode::AlreadyClaimed, err); + } + CliError::new(ErrorCode::StoreUnavailable, err) + } +} + +fn request_id() -> String { + // Monotonic-ish opaque id; not a ULID but unique enough per process. + format!( + "req_{}{:04}", + chrono::Utc::now().format("%Y%m%dT%H%M%S%3f"), + std::process::id() % 10_000 + ) +} + +/// Print the success envelope to stdout and return exit code 0. +pub fn emit_success(data: serde_json::Value, revision: Option, next_cursor: Option) -> i32 { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Success { + api_version: &'static str, + ok: bool, + data: serde_json::Value, + meta: Meta, + } + let envelope = Success { + api_version: API_VERSION, + ok: true, + data, + meta: Meta { + request_id: request_id(), + revision, + next_cursor, + }, + }; + println!( + "{}", + serde_json::to_string_pretty(&envelope).expect("envelope serializes") + ); + 0 +} + +/// Print the error envelope to stdout and return its mapped exit code. +pub fn emit_error(error: CliError) -> i32 { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct ErrorBody { + code: &'static str, + message: String, + retryable: bool, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + details: serde_json::Value, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct ErrorEnvelope { + api_version: &'static str, + ok: bool, + error: ErrorBody, + meta: Meta, + } + let exit = error.code.exit_code(); + let envelope = ErrorEnvelope { + api_version: API_VERSION, + ok: false, + error: ErrorBody { + code: error.code.as_str(), + message: error.message, + retryable: error.code.retryable(), + details: error.details, + }, + meta: Meta { + request_id: request_id(), + revision: None, + next_cursor: None, + }, + }; + println!( + "{}", + serde_json::to_string_pretty(&envelope).expect("envelope serializes") + ); + exit +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_match_frozen_table() { + assert_eq!(ErrorCode::InvalidArgument.exit_code(), 2); + assert_eq!(ErrorCode::ResultSchemaMismatch.exit_code(), 2); + assert_eq!(ErrorCode::NotFound.exit_code(), 3); + assert_eq!(ErrorCode::ContextRequired.exit_code(), 3); + assert_eq!(ErrorCode::RevisionConflict.exit_code(), 4); + assert_eq!(ErrorCode::AlreadyClaimed.exit_code(), 4); + assert_eq!(ErrorCode::ProjectModeRequired.exit_code(), 5); + assert_eq!(ErrorCode::StoreUnavailable.exit_code(), 6); + assert_eq!(ErrorCode::UnsupportedCapability.exit_code(), 7); + assert_eq!(ErrorCode::PermissionDenied.exit_code(), 8); + assert_eq!(ErrorCode::ScopeViolation.exit_code(), 8); + } + + #[test] + fn service_sentinels_map_to_wire_codes() { + let err = CliError::from_service("PM_ERR:REVISION_CONFLICT:7:8".to_string()); + assert_eq!(err.code, ErrorCode::RevisionConflict); + assert_eq!(err.details["expectedRevision"], 7); + assert_eq!(err.details["currentRevision"], 8); + + let err = CliError::from_service("PM_ERR:INVALID_TRANSITION:completed:in_progress".into()); + assert_eq!(err.code, ErrorCode::InvalidTransition); + + let err = CliError::from_service("Work item 'X' not found".into()); + assert_eq!(err.code, ErrorCode::NotFound); + } +} diff --git a/src-tauri/crates/orgtrack-pm-cli/src/main.rs b/src-tauri/crates/orgtrack-pm-cli/src/main.rs new file mode 100644 index 000000000..080685c8e --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/src/main.rs @@ -0,0 +1,145 @@ +//! `org2-pm` — the Orgtrack PM protocol CLI (`orgtrack/v1`). +//! +//! Installed on PATH as `org2` by the distribution (the GUI binary is a +//! `windows_subsystem = "windows"` executable and cannot host a console +//! surface — frozen decision §4). Three entrances only: +//! +//! ```text +//! org2 context +//! org2 work list|show|create|update|claim|transition|note|relate +//! org2 routine ... (Phase 4) +//! ``` +//! +//! Process model (design §13.0): short-lived console process linking the +//! same application crates as the desktop host; SQLite WAL handles the +//! multi-process story and every mutation bumps `pm_change_seq` inside +//! its transaction so the desktop reconciles incrementally. +//! +//! stdout carries exactly one JSON envelope; diagnostics go to stderr. + +mod commands; +mod context; +mod envelope; + +use envelope::{emit_error, CliError, ErrorCode}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let code = run(&args); + std::process::exit(code); +} + +/// Minimal flag parser: `--flag value` pairs plus positionals. `--json` +/// is accepted for wire-compat but JSON is already the only output mode. +/// `--input k=v` repeats and accumulates. +pub struct Parsed { + pub positionals: Vec, + pub flags: std::collections::HashMap, + pub inputs: Vec<(String, String)>, +} + +fn parse_args(args: &[String]) -> Result { + let mut positionals = Vec::new(); + let mut flags = std::collections::HashMap::new(); + let mut inputs = Vec::new(); + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if let Some(name) = arg.strip_prefix("--") { + if name == "json" || name == "ready" { + flags.insert(name.to_string(), "true".to_string()); + i += 1; + continue; + } + if name == "help" { + return Err(CliError::new( + ErrorCode::InvalidArgument, + "org2-pm is JSON-envelope only. Commands: context show | \ + work list|show|create|update|claim|transition|note|relate | \ + routine list|validate|apply|run|status|enable|disable. \ + Common flags: --scope --mode project --actor \ + --session-ref --idempotency-key " + .to_string(), + )); + } + let value = args.get(i + 1).ok_or_else(|| { + CliError::new( + ErrorCode::InvalidArgument, + format!("Flag --{} requires a value", name), + ) + })?; + if name == "input" { + let (key, val) = value.split_once('=').ok_or_else(|| { + CliError::new( + ErrorCode::InvalidArgument, + format!("--input expects key=value, got '{}'", value), + ) + })?; + inputs.push((key.to_string(), val.to_string())); + } else { + flags.insert(name.to_string(), value.clone()); + } + i += 2; + } else { + positionals.push(arg.clone()); + i += 1; + } + } + Ok(Parsed { + positionals, + flags, + inputs, + }) +} + +/// Idempotent schema init on the canonical store path — the same steps +/// the desktop host performs at startup (entry-point init parity, audit +/// Layer 9). Never a fallback to a different database. +fn ensure_schema() -> Result<(), CliError> { + let connection = database::db::get_projects_connection() + .map_err(|err| CliError::new(ErrorCode::StoreUnavailable, err.to_string()))?; + project_management::projects::schema::init_project_tables(&connection) + .map_err(|err| CliError::new(ErrorCode::StoreUnavailable, err.to_string()))?; + Ok(()) +} + +fn run(args: &[String]) -> i32 { + let parsed = match parse_args(args) { + Ok(parsed) => parsed, + Err(err) => return emit_error(err), + }; + let flags = &parsed.flags; + + if let Err(err) = ensure_schema() { + return emit_error(err); + } + + let context = match context::resolve( + flags.get("mode").map(String::as_str), + flags.get("scope").map(String::as_str), + flags.get("actor").map(String::as_str), + flags.get("session-ref").map(String::as_str), + ) { + Ok(context) => context, + Err(err) => return emit_error(err), + }; + + 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("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", + other + ), + )), + None => emit_error(CliError::new( + ErrorCode::InvalidArgument, + "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 new file mode 100644 index 000000000..125613443 --- /dev/null +++ b/src-tauri/crates/orgtrack-pm-cli/tests/cli_e2e.rs @@ -0,0 +1,464 @@ +//! Cross-process E2E for the `org2-pm` binary (Phase 3 checklist): +//! the test process seeds the sandbox store through the shared +//! application crates, the real CLI binary mutates it from a separate +//! process, and the parent verifies the durable effects — including the +//! `pm_change_seq` watermark the desktop host polls to notice external +//! writers. + +use std::process::Command; + +use project_management::projects::io::{write_project, write_work_item}; +use project_management::projects::types::{ProjectMeta, WorkItemFrontmatter}; +use test_helpers::test_env; + +fn project_fixture(id: &str, name: &str) -> ProjectMeta { + ProjectMeta { + id: id.to_string(), + name: name.to_string(), + org_id: "personal-org".to_string(), + status: "active".to_string(), + priority: "none".to_string(), + health: "no_updates".to_string(), + lead: None, + members: vec![], + labels: vec![], + linked_repos: vec![], + start_date: None, + target_date: None, + created_at: String::new(), + updated_at: String::new(), + next_work_item_id: 1, + work_item_prefix: "AAA".to_string(), + work_item_prefix_custom: true, + agent_defaults: None, + } +} + +fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatter { + WorkItemFrontmatter { + id: id.to_string(), + short_id: short_id.to_string(), + title: title.to_string(), + project: None, + status: "backlog".to_string(), + priority: "none".to_string(), + assignee: None, + assignee_type: None, + labels: vec![], + milestone: None, + parent: None, + start_date: None, + target_date: None, + created_by: None, + created_at: String::new(), + updated_at: String::new(), + deleted_at: None, + starred: false, + todos: vec![], + comments: vec![], + history: vec![], + delegations: vec![], + linked_sessions: vec![], + handoff: None, + proof_of_work: None, + orchestrator_config: None, + orchestrator_state: None, + follow_up_items: vec![], + schedule: None, + routine_source: None, + execution_lock: None, + close_out: None, + work_products: vec![], + } +} + +fn seed(slug: &str) { + // The lib's cfg(test) auto-init only fires inside project_management's + // own unit tests; integration tests initialize the sandbox store the + // same way the desktop host and the CLI do. + let connection = database::db::get_projects_connection().expect("projects connection"); + project_management::projects::schema::init_project_tables(&connection).expect("schema"); + drop(connection); + write_project(slug, &project_fixture("p1", "Demo"), "", true).expect("project"); + write_work_item( + slug, + "AAA-0001", + &work_item_fixture("w1", "AAA-0001", "CLI target"), + "body", + ) + .expect("seed work item"); +} + +fn run_cli(args: &[&str]) -> (i32, serde_json::Value) { + let exe = env!("CARGO_BIN_EXE_org2-pm"); + let output = Command::new(exe).args(args).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 must be exactly one JSON envelope: {err}\nstdout: {stdout}\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ) + }); + (output.status.code().unwrap_or(-1), value) +} + +fn change_seq() -> i64 { + let connection = rusqlite_probe(); + connection + .query_row("SELECT seq FROM pm_change_seq WHERE id = 1", [], |row| { + row.get(0) + }) + .expect("pm_change_seq") +} + +fn rusqlite_probe() -> rusqlite::Connection { + // ORGII_HOME IS the orgii root (no extra `.orgii` segment): + // projects_db() = /projects/projects.db (app-paths). + let home = std::env::var("ORGII_HOME").expect("sandbox sets ORGII_HOME"); + let path = std::path::Path::new(&home) + .join("projects") + .join("projects.db"); + rusqlite::Connection::open(path).expect("open projects.db") +} + +#[test] +fn context_defaults_to_build_with_no_capabilities() { + let _sandbox = test_env::sandbox(); + let (exit, envelope) = run_cli(&["context"]); + assert_eq!(exit, 0, "envelope: {envelope}"); + assert_eq!(envelope["ok"], true); + assert_eq!(envelope["data"]["mode"], "build"); + assert_eq!(envelope["data"]["capabilities"], serde_json::json!([])); + assert_eq!(envelope["apiVersion"], "orgtrack/v1"); +} + +#[test] +fn mutations_outside_project_mode_are_gated() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let (exit, envelope) = run_cli(&[ + "work", + "transition", + "AAA-0001", + "--to", + "completed", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + ]); + assert_eq!(exit, 5, "envelope: {envelope}"); + assert_eq!(envelope["error"]["code"], "PROJECT_MODE_REQUIRED"); +} + +#[test] +fn external_shell_agent_completes_a_work_item_end_to_end() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let seq_before = change_seq(); + + let base = [ + "--mode", + "project", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + "--session-ref", + "claude_code:session_e2e_1", + ]; + + // Discover ready work. + let (exit, listed) = run_cli(&[&["work", "list", "--ready"], &base[..]].concat()); + 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()); + assert_eq!(exit, 0, "claim envelope: {claimed}"); + assert_eq!(claimed["data"]["frontmatter"]["status"], "in_progress"); + assert_eq!( + claimed["data"]["frontmatter"]["execution_lock"]["activeSessionId"], + "session_e2e_1" + ); + + // Progress note. + let (exit, noted) = run_cli( + &[ + &["work", "note", "AAA-0001", "--kind", "progress", "--body", "half way"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "note envelope: {noted}"); + + // Relate an external session. + let (exit, related) = run_cli( + &[ + &[ + "work", + "relate", + "AAA-0001", + "--type", + "participated_in", + "--target", + "session://claude_code/session_e2e_1", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "relate envelope: {related}"); + + // Complete. + let (exit, done) = run_cli( + &[ + &["work", "transition", "AAA-0001", "--to", "completed", "--reason", "done"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "transition envelope: {done}"); + assert_eq!(done["data"]["frontmatter"]["status"], "completed"); + assert_eq!(done["data"]["portableState"], "completed"); + + // Cross-process watermark: the desktop host notices external writers + // through pm_change_seq alone. + let seq_after = change_seq(); + assert!( + seq_after >= seq_before + 4, + "each CLI mutation bumps the watermark ({seq_before} -> {seq_after})" + ); + + // Audit trail carries the canonical operations. + let connection = rusqlite_probe(); + let operations: Vec = connection + .prepare("SELECT operation FROM pm_audit_events ORDER BY id") + .expect("prepare") + .query_map([], |row| row.get(0)) + .expect("query") + .collect::>() + .expect("rows"); + for expected in ["work.claim", "work.note", "work.relate", "work.transition"] { + assert!( + operations.iter().any(|op| op == expected), + "audit stream must contain {expected}; got {operations:?}" + ); + } + + // show returns the relation and an OCC revision. + let (exit, shown) = run_cli(&[&["work", "show", "AAA-0001"], &base[..]].concat()); + assert_eq!(exit, 0, "show envelope: {shown}"); + assert!(shown["data"]["revision"].as_i64().unwrap_or(0) >= 2); + assert_eq!(shown["data"]["relations"][0]["kind"], "participated_in"); +} + +#[test] +fn idempotency_replays_and_conflicts() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let base = [ + "--mode", + "project", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + "--session-ref", + "claude_code:session_idem", + ]; + + let claim_args = [ + &["work", "claim", "AAA-0001", "--idempotency-key", "sess:claim"], + &base[..], + ] + .concat(); + let (exit, first) = run_cli(&claim_args); + assert_eq!(exit, 0, "first claim: {first}"); + assert_eq!(first["data"]["frontmatter"]["status"], "in_progress"); + + // Exact replay: returns the stored response instead of re-executing + // (a re-run would fail INVALID_TRANSITION — already in_progress). + let (exit, replay) = run_cli(&claim_args); + assert_eq!(exit, 0, "replayed claim: {replay}"); + assert_eq!(replay["data"]["frontmatter"]["status"], "in_progress"); + + let (exit, done) = run_cli( + &[ + &[ + "work", + "transition", + "AAA-0001", + "--to", + "completed", + "--idempotency-key", + "sess:finish", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "transition: {done}"); + + // Same key, different canonical request -> conflict. + let (exit, conflict) = run_cli( + &[ + &[ + "work", + "transition", + "AAA-0001", + "--to", + "open", + "--idempotency-key", + "sess:finish", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 4, "conflict: {conflict}"); + assert_eq!(conflict["error"]["code"], "IDEMPOTENCY_CONFLICT"); +} + +#[test] +fn routine_lifecycle_runs_through_the_cli() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../docs/orgtrack-pm-protocol/fixtures/routine-spec.json"); + let fixture_arg = fixture_path.to_string_lossy().to_string(); + let base = [ + "--mode", + "project", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + "--session-ref", + "claude_code:session_routine", + ]; + + // validate + apply (idempotent revision). + let (exit, validated) = + run_cli(&[&["routine", "validate", "--file", &fixture_arg], &base[..]].concat()); + assert_eq!(exit, 0, "validate: {validated}"); + let (exit, applied) = + run_cli(&[&["routine", "apply", "--file", &fixture_arg], &base[..]].concat()); + assert_eq!(exit, 0, "apply: {applied}"); + assert_eq!(applied["data"]["revision"], 1); + let (exit, reapplied) = + run_cli(&[&["routine", "apply", "--file", &fixture_arg], &base[..]].concat()); + assert_eq!(exit, 0, "re-apply: {reapplied}"); + assert_eq!(reapplied["data"]["revision"], 1, "same body keeps revision"); + + // run with inputs -> materialized graph. + let (exit, run) = run_cli( + &[ + &[ + "routine", + "run", + "interaction-impact-analysis", + "--input", + "requirement_id=REQ-042", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "run: {run}"); + let run_id = run["data"]["runId"].as_str().expect("runId").to_string(); + assert_eq!(run["data"]["steps"].as_array().map(Vec::len), Some(3)); + + // status: running; the dependent steps are open, the first is ready. + let (exit, status) = run_cli(&[&["routine", "status", &run_id], &base[..]].concat()); + assert_eq!(exit, 0, "status: {status}"); + assert_eq!(status["data"]["status"], "running"); + + // Complete the first step through the portable lifecycle. + let first_step = run["data"]["steps"][0]["workItemId"] + .as_str() + .expect("step id") + .to_string(); + let (exit, claimed) = run_cli(&[&["work", "claim", &first_step], &base[..]].concat()); + assert_eq!(exit, 0, "claim: {claimed}"); + let (exit, done) = run_cli( + &[ + &["work", "transition", &first_step, "--to", "completed"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 0, "transition: {done}"); + + // Projection stays running (downstream became ready), and the step + // shows completed in the durable view. + let (exit, status) = run_cli(&[&["routine", "status", &run_id], &base[..]].concat()); + assert_eq!(exit, 0, "status after completion: {status}"); + assert_eq!(status["data"]["status"], "running"); + let items = status["data"]["workItems"].as_array().expect("workItems"); + let first = items + .iter() + .find(|item| item["shortId"] == first_step.as_str()) + .expect("first step in view"); + assert_eq!(first["portableState"], "completed"); +} + +#[test] +fn wire_validation_maps_to_stable_codes() { + let _sandbox = test_env::sandbox(); + seed("demo"); + let base = [ + "--mode", + "project", + "--scope", + "demo", + "--actor", + "agent:cli-tester", + ]; + + let (exit, envelope) = run_cli( + &[&["work", "transition", "AAA-0001", "--to", "done"], &base[..]].concat(), + ); + assert_eq!(exit, 2, "envelope: {envelope}"); + assert_eq!(envelope["error"]["code"], "INVALID_ARGUMENT"); + + let (exit, envelope) = + run_cli(&[&["work", "show", "AAA-9999"], &base[..]].concat()); + assert_eq!(exit, 3, "envelope: {envelope}"); + assert_eq!(envelope["error"]["code"], "NOT_FOUND"); + + // in_progress is claim-only. + let (exit, envelope) = run_cli( + &[ + &["work", "transition", "AAA-0001", "--to", "in_progress"], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 4, "envelope: {envelope}"); + assert_eq!(envelope["error"]["code"], "INVALID_TRANSITION"); + + // Hook short names are not canonical provider ids (decisions §5). + let (exit, envelope) = run_cli( + &[ + &[ + "work", + "claim", + "AAA-0001", + "--session-ref", + "claude:session_x", + ], + &base[..], + ] + .concat(), + ); + assert_eq!(exit, 2, "envelope: {envelope}"); + assert_eq!(envelope["error"]["code"], "INVALID_ARGUMENT"); + assert!( + envelope["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("claude_code"), + "message points at the canonical id: {envelope}" + ); +} diff --git a/src-tauri/crates/project-management/src/lib.rs b/src-tauri/crates/project-management/src/lib.rs index 6321884d5..710b5309e 100644 --- a/src-tauri/crates/project-management/src/lib.rs +++ b/src-tauri/crates/project-management/src/lib.rs @@ -12,8 +12,11 @@ pub mod lineage; pub mod orchestrator; pub mod projects; +pub mod provider_host; +pub mod routine_service; pub mod sync; pub mod team_inbox; +pub mod work_service; #[cfg(test)] mod test_support; 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 4e2a75f67..b0043e4a7 100644 --- a/src-tauri/crates/project-management/src/orchestrator/state_machine.rs +++ b/src-tauri/crates/project-management/src/orchestrator/state_machine.rs @@ -292,7 +292,17 @@ pub fn mutate_work_item( short_id: &str, mutator: impl FnOnce(&mut WorkItemFrontmatter) -> TransitionResult, ) -> Result { - io::update_work_item_atomic(project_slug, short_id, |frontmatter, _body| { + // Orchestrator session-terminal handling is the DEFAULT COMPLETION + // POLICY of the Orgtrack migration (design §17): the status change is + // audited as an explicit work.transition with a policy reason, not a + // silent side effect. FSM stays flag-only here — orchestrator flows + // legitimately move through the legacy vocabulary until Phase 7. + let service = io::AtomicServiceOptions { + operation: Some("work.transition"), + reason: Some("completion policy: orchestrator session terminal".to_string()), + ..Default::default() + }; + io::update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { let result = mutator(frontmatter); frontmatter.updated_at = chrono::Utc::now().to_rfc3339(); Ok(result) @@ -310,4 +320,7 @@ pub enum TransitionResult { Failed, CreateFollowUp, AwaitingUser, + /// Stale terminal signal from a session that no longer owns the + /// item's execution claim — the mutation was skipped entirely. + Ignored, } diff --git a/src-tauri/crates/project-management/src/projects/commands/routines.rs b/src-tauri/crates/project-management/src/projects/commands/routines.rs index 9d011c786..853807f5d 100644 --- a/src-tauri/crates/project-management/src/projects/commands/routines.rs +++ b/src-tauri/crates/project-management/src/projects/commands/routines.rs @@ -39,3 +39,28 @@ pub async fn project_list_routine_fires(routine_id: String) -> Result, + limit: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + crate::routine_service::list_runs(scope_id.as_deref(), limit.unwrap_or(100)) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + +/// Durable run-status projection for one routine run: the run row plus +/// each generated WorkItem's portable state (orgtrack/v1 §11 ordered +/// decision procedure). +#[tauri::command] +pub async fn project_routine_run_status(run_id: String) -> Result { + tokio::task::spawn_blocking(move || crate::routine_service::run_status(&run_id)) + .await + .map_err(|err| format!("Task join error: {}", err))? +} 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 f95fe75f7..8011e7c7e 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 @@ -215,6 +215,71 @@ pub async fn project_purge_expired_deleted_work_items( .map_err(|err| format!("Task join error: {}", err))? } +/// Canonical `work.create` for a project-scoped item: the caller +/// supplies a creation DTO plus a pre-allocated short id (collab orgs +/// mint ids server-side, design §16.5); frontmatter construction is +/// service-owned. Replaces UI-side `WorkItemFrontmatter` literals fed +/// into the whole-row write. +#[tauri::command] +pub async fn project_create_work_item( + project_slug: String, + short_id: String, + request: crate::work_service::CreateWorkItemRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::work_service::create_project_work_item(&project_slug, &short_id, &request, None) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + +/// Canonical `work.create` for an org-scoped standalone item. +#[tauri::command] +pub async fn work_item_create_standalone( + org_id: Option, + short_id: String, + request: crate::work_service::CreateWorkItemRequest, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::work_service::create_standalone_work_item( + org_id.as_deref(), + &short_id, + &request, + None, + ) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + +/// Strict, audited status transition through the work application +/// service (`work.transition`, design §9.3/§13.2): portable-FSM +/// validation is a hard reject here, `expected_revision` enables +/// optimistic concurrency against `local_version`, and the reason is +/// recorded in the audit stream. Non-lifecycle fields stay on the +/// partial-update path. +#[tauri::command] +pub async fn project_transition_work_item( + project_slug: String, + short_id: String, + to_status: String, + reason: Option, + expected_revision: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::work_service::transition_project_work_item( + &project_slug, + &short_id, + &to_status, + reason.as_deref(), + None, + expected_revision, + ) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + /// Atomic read-modify-write for a single field-set patch. Runs /// inside a `BEGIN IMMEDIATE` transaction so concurrent partial /// updates serialize at the SQLite level. Returns the *enriched* diff --git a/src-tauri/crates/project-management/src/projects/io/helpers.rs b/src-tauri/crates/project-management/src/projects/io/helpers.rs index a3cc46219..8b59db081 100644 --- a/src-tauri/crates/project-management/src/projects/io/helpers.rs +++ b/src-tauri/crates/project-management/src/projects/io/helpers.rs @@ -12,7 +12,7 @@ use database::db::get_projects_connection; /// handles concurrent access cheaply, and SQLite's connection cost is /// dominated by the file-open syscall — negligible for an interactive /// app. -pub(super) fn conn() -> Result { +pub(crate) fn conn() -> Result { let connection = get_projects_connection().map_err(|err| format!("DB error: {}", err))?; #[cfg(test)] crate::projects::schema::init_project_tables(&connection) @@ -24,7 +24,7 @@ pub(super) fn conn() -> Result { /// `updated_at` columns, which are stored as integers (the legacy file /// layer used ISO-8601 strings; the wire types continue to expose /// strings via `to_iso8601`). -pub(super) fn now_ms() -> i64 { +pub(crate) fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|dur| dur.as_millis() as i64) 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 fb6be8a5e..e47356dc7 100644 --- a/src-tauri/crates/project-management/src/projects/io/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/mod.rs @@ -6,7 +6,7 @@ mod assets; mod git_folder_sync; -mod helpers; +pub(crate) mod helpers; mod labels; mod members; mod milestones; @@ -38,6 +38,7 @@ pub use routines::{ create_routine_fire, create_routine_fire_for_policy, create_routine_fire_for_policy_with_key, delete_routine, disable_routine, find_started_fire_by_session, find_started_fire_by_work_item, list_enabled_routines, list_routine_fires, list_routines, mark_routine_fire_failed, + read_pm_change_seq, mark_routine_fire_started, mark_routine_fire_succeeded, mark_routine_fire_work_item_created, mark_routine_fire_work_item_started, read_routine, take_next_queued_fire, update_routine_schedule_marks, upsert_routine, @@ -56,10 +57,11 @@ 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_partial, update_work_item_atomic, + update_standalone_work_item_atomic, 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, - write_standalone_work_item, write_work_item, FieldRevision, SyncMetadata, + write_standalone_work_item, write_work_item, AtomicServiceOptions, FieldRevision, SyncMetadata, REVISION_SOURCE_LOCAL, }; pub(crate) use work_items::{purge_work_item, write_work_item_remote}; 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 e0a0db3fb..b03095391 100644 --- a/src-tauri/crates/project-management/src/projects/io/routines.rs +++ b/src-tauri/crates/project-management/src/projects/io/routines.rs @@ -129,6 +129,21 @@ pub fn list_routines() -> Result, String> { Ok(routines) } +/// Current cross-process PM change watermark (design §13.0). External +/// writers (the org2 PM CLI) bump this inside every mutation +/// transaction; the desktop host polls it to notice foreign commits. +pub fn read_pm_change_seq() -> Result { + let connection = super::helpers::conn()?; + connection + .query_row("SELECT seq FROM pm_change_seq WHERE id = 1", [], |row| { + row.get(0) + }) + .or_else(|err| match err { + rusqlite::Error::QueryReturnedNoRows => Ok(0), + other => Err(format!("pm_change_seq: {other}")), + }) +} + /// List enabled routines for scheduler evaluation. pub fn list_enabled_routines() -> Result, String> { let connection = conn()?; 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 5a48e6017..575a09cd3 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 @@ -28,6 +28,25 @@ enum AtomicWorkItemScope<'a> { Standalone { org_id: &'a str }, } +/// Work-service options threaded into the atomic RMW choke point +/// (`orgtrack/v1` Phase 2a). Legacy callers use `Default` — no OCC +/// precondition, flag-only FSM validation, generic `work.patch` audit +/// label. The application service (`crate::work_service`) passes explicit +/// options for strict transitions. +#[derive(Default)] +pub struct AtomicServiceOptions { + /// Optimistic concurrency: reject with `PM_ERR:REVISION_CONFLICT` + /// when the row's `local_version` differs before the mutator runs. + pub expected_local_version: Option, + /// Canonical operation label for the audit event (default `work.patch`). + pub operation: Option<&'static str>, + /// Reject portable-FSM violations instead of recording them as + /// flagged audit metadata. + pub strict_fsm: bool, + /// Human-supplied reason (transition/reopen/release), audited. + pub reason: Option, +} + /// Sync-relevant fields whose mutations are tracked in /// `workitem_extras.field_revisions`. The names match /// [`crate::sync::adapter::EntityField::as_local_name`] @@ -174,10 +193,72 @@ where short_id, override_revisions, actor, + AtomicServiceOptions::default(), mutator, ) } +/// Application-service entry: same transactional semantics as +/// [`update_work_item_atomic_as`] (outbox emission included) plus the +/// service options — OCC precondition, strict FSM, audit label/reason. +pub fn update_work_item_atomic_serviced( + project_slug: &str, + short_id: &str, + actor: Option<&crate::projects::types::WorkItemMutationActor>, + service: AtomicServiceOptions, + mutator: F, +) -> Result +where + F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, +{ + let (value, changed_fields, payload_tail_changed) = + update_work_item_atomic_with_revisions_scoped( + AtomicWorkItemScope::Project(project_slug), + short_id, + HashMap::new(), + actor, + service, + mutator, + )?; + if !changed_fields.is_empty() { + let data = super::crud::read_work_item(project_slug, short_id)?; + let payload = changed_fields_payload(&data, &changed_fields); + crate::sync::io::record_local_update(project_slug, short_id, &changed_fields, &payload)?; + } else if payload_tail_changed { + crate::sync::collab_bridge::record_work_item_payload_touch(project_slug, short_id)?; + } + Ok(value) +} + +/// Closure-form atomic RMW for a standalone (org-scoped) work item — +/// the standalone counterpart to [`update_work_item_atomic`]. Shares the +/// same `BEGIN IMMEDIATE` boundary, history writer, audit + watermark +/// emission, and collab-bridge push as the partial-update path, so +/// callers stop doing client-side read-modify-write + whole-row writes +/// (the lost-update race). +pub fn update_standalone_work_item_atomic( + org_id: Option<&str>, + 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))?; + 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( + org_id, + None, + &data.frontmatter.id, + data.frontmatter.deleted_at.is_some(), + )?; + } + Ok(value) +} + pub(super) fn update_standalone_work_item_atomic_as( org_id: &str, short_id: &str, @@ -192,6 +273,7 @@ where short_id, HashMap::new(), actor, + AtomicServiceOptions::default(), mutator, ) } @@ -201,6 +283,7 @@ fn update_work_item_atomic_with_revisions_scoped( short_id: &str, override_revisions: HashMap, actor: Option<&crate::projects::types::WorkItemMutationActor>, + service: AtomicServiceOptions, mutator: F, ) -> Result<(T, Vec<&'static str>, bool), String> where @@ -273,6 +356,21 @@ where } .ok_or_else(|| format!("Work item '{}' not found", short_id))?; + // OCC precondition (service callers only): the caller read revision N + // and asked to mutate iff the row is still at N. Checked inside the + // IMMEDIATE tx, so a concurrent writer either committed before us + // (mismatch -> conflict) or queues behind us. + if let Some(expected) = service.expected_local_version { + if expected != core.local_version { + return Err(format!( + "{}:{}:{}", + crate::work_service::error::REVISION_CONFLICT, + expected, + core.local_version + )); + } + } + // Read labels + extras inside the same tx so the snapshot is // strictly consistent with the row we just locked. let labels = read_labels_in_tx(&tx, &core.work_item_id)?; @@ -329,6 +427,26 @@ where let result = mutator(&mut frontmatter, &mut body)?; + // Portable-FSM validation on status changes (design §9.3). Strict + // callers (the application service) get a hard reject; legacy paths + // run flag-only so current UI flows keep working while the violation + // is still visible in the audit stream. + let status_changed = core.status != frontmatter.status; + let mut fsm_violation: Option = None; + if status_changed { + if let Err(violation) = + crate::work_service::state::validate_legacy_transition(&core.status, &frontmatter.status) + { + if service.strict_fsm { + return Err(crate::work_service::error::invalid_transition( + &core.status, + &frontmatter.status, + )); + } + fsm_violation = Some(violation); + } + } + let changed_fields = before.diff(&frontmatter, &body); let assignment_changed = core.assignee != frontmatter.assignee || core.assignee_type != frontmatter.assignee_type; @@ -514,6 +632,42 @@ where params![&core.work_item_id, next_extras_json], ))?; + // Audit + cross-process watermark, same transaction as the mutation + // (frozen persistence invariant, design §19). Every RMW path funnels + // through here, so UI patches, agent tools, sync merges and the + // future CLI are all audited without per-caller wiring. + let seq = crate::work_service::audit::bump_change_seq(&tx)?; + let mut audit_payload = serde_json::json!({ + "changed_fields": changed_fields, + }); + if status_changed { + audit_payload["status_from"] = serde_json::Value::String(core.status.clone()); + audit_payload["status_to"] = serde_json::Value::String(frontmatter.status.clone()); + } + if let Some(violation) = &fsm_violation { + audit_payload["fsm_violation"] = serde_json::Value::String(violation.clone()); + } + if let Some(reason) = &service.reason { + audit_payload["reason"] = serde_json::Value::String(reason.clone()); + } + crate::work_service::audit::append_audit_event( + &tx, + &crate::work_service::audit::AuditEventRow { + operation: service.operation.unwrap_or("work.patch"), + entity_type: "work_item", + entity_id: &core.work_item_id, + project_slug: match scope { + AtomicWorkItemScope::Project(slug) => Some(slug), + AtomicWorkItemScope::Standalone { .. } => None, + }, + org_id: Some(&next_org_id), + actor, + revision: next_version, + seq, + payload: audit_payload, + }, + )?; + map_db(tx.commit())?; if scheduler_changed { crate::projects::events::notify_work_item_schedule_changed(); @@ -730,6 +884,7 @@ fn update_work_item_partial_scoped( short_id, override_revisions, updates.actor.as_ref(), + AtomicServiceOptions::default(), |fm, body| { let now_iso = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); 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 f5eefce7a..317ed3990 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 @@ -10,7 +10,7 @@ use crate::projects::types::{ }; use core_types::session::PENDING_SESSION_PLACEHOLDER; -use super::atomic::update_work_item_atomic; +use super::atomic::{update_work_item_atomic_serviced, AtomicServiceOptions}; pub fn acquire_execution_lock( project_slug: &str, @@ -19,7 +19,13 @@ pub fn acquire_execution_lock( agent_role: Option<&str>, reason: WorkItemExecutionLockReason, ) -> Result<(), String> { - update_work_item_atomic(project_slug, short_id, |frontmatter, _body| { + // Audited as the claim operation: the execution lock IS the local + // claim record the portable `work.claim` op absorbs (design §9.4). + let service = AtomicServiceOptions { + operation: Some("work.claim"), + ..AtomicServiceOptions::default() + }; + update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { 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 { @@ -95,7 +101,11 @@ pub fn release_execution_lock( short_id: &str, session_id: &str, ) -> Result<(), String> { - update_work_item_atomic(project_slug, short_id, |frontmatter, _body| { + let service = AtomicServiceOptions { + operation: Some("work.release"), + ..AtomicServiceOptions::default() + }; + update_work_item_atomic_serviced(project_slug, short_id, None, service, |frontmatter, _body| { if frontmatter .execution_lock .as_ref() 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 cd324cad1..e7375eacc 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,9 +55,10 @@ mod workspace; pub(crate) use atomic::update_standalone_work_item_partial_with_revisions; pub use atomic::{ - update_standalone_work_item_partial, update_work_item_atomic, update_work_item_atomic_as, + update_standalone_work_item_atomic, 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, + update_work_item_partial_with_revisions, AtomicServiceOptions, }; pub use batch::{batch_delete_work_items, batch_update_work_items}; pub(crate) use crud::purge_work_item; 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 6f23d27f7..2dd73119c 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 @@ -172,6 +172,25 @@ pub fn find_by_external_ref( return Ok(None); }; + // Fast path: the relational binding table (Orgtrack Phase 6). Rows + // are dual-written by apply_remote_merge and lazily backfilled below + // when the legacy blob scan still finds a pre-migration binding. + let indexed: Option = map_db( + connection + .query_row( + "SELECT w.short_id + FROM pm_provider_bindings b + JOIN workitems w ON w.id = b.work_item_id + WHERE b.provider = ?1 AND b.external_id = ?2 AND w.project_id = ?3", + params![adapter_id, external_id, &project_id], + |row| row.get(0), + ) + .optional(), + )?; + if indexed.is_some() { + return Ok(indexed); + } + let mut stmt = map_db(connection.prepare( "SELECT w.short_id, e.extras_json FROM workitems w @@ -198,6 +217,26 @@ pub fn find_by_external_ref( } }; if extras.external_refs.get(adapter_id) == Some(&external_id.to_string()) { + // Lazy backfill: promote the legacy blob binding into the + // indexed table so the next lookup takes the fast path. + let work_item_id: Option = map_db( + connection + .query_row( + "SELECT id FROM workitems WHERE project_id = ?1 AND short_id = ?2", + params![&project_id, &short_id], + |row| row.get(0), + ) + .optional(), + )?; + if let Some(work_item_id) = work_item_id { + let now = crate::projects::io::helpers::now_ms(); + let _ = connection.execute( + "INSERT OR IGNORE INTO pm_provider_bindings + (work_item_id, provider, external_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4)", + params![&work_item_id, adapter_id, external_id, now], + ); + } return Ok(Some(short_id)); } } @@ -286,6 +325,19 @@ pub fn apply_remote_merge( extras.field_revisions.insert(field, revision); } if let Some((adapter_id, external_id)) = external_ref { + // Dual-write: legacy blob (still read by exports/collab payloads) + // plus the indexed relational binding (Orgtrack Phase 6) in the + // SAME transaction, so identity can never split-brain. + let now = crate::projects::io::helpers::now_ms(); + map_db(tx.execute( + "INSERT INTO pm_provider_bindings + (work_item_id, provider, external_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4) + ON CONFLICT(work_item_id, provider) DO UPDATE SET + external_id = excluded.external_id, + updated_at = excluded.updated_at", + params![&work_item_id, &adapter_id, &external_id, now], + ))?; extras.external_refs.insert(adapter_id, external_id); } diff --git a/src-tauri/crates/project-management/src/projects/schema.rs b/src-tauri/crates/project-management/src/projects/schema.rs index 4656afd92..492a815a6 100644 --- a/src-tauri/crates/project-management/src/projects/schema.rs +++ b/src-tauri/crates/project-management/src/projects/schema.rs @@ -44,6 +44,123 @@ pub fn init_project_tables(conn: &Connection) -> SqliteResult<()> { init_import_progress_table(conn)?; init_outbox_conflicts_table(conn)?; init_linear_metadata_cache_table(conn)?; + init_pm_service_tables(conn)?; + Ok(()) +} + +/// Work application service tables (`orgtrack/v1` Phase 2a). +/// +/// - `pm_change_seq`: single-row cross-process change watermark. Every PM +/// mutation bumps it in the same transaction; desktop hosts poll it (or +/// watch the db file) to detect commits from other processes such as +/// the PM CLI, then reconcile incrementally. +/// - `pm_audit_events`: append-only audit stream. NOT the legacy +/// `extras_json.history` array (which is rewritten wholesale per +/// mutation) — this table is insert-only and queryable. +/// - `pm_idempotency`: idempotency records scoped by +/// `(actor, operation, scope, key)` per the frozen wire contract §14.4. +pub fn init_pm_service_tables(conn: &Connection) -> SqliteResult<()> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS pm_change_seq ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seq INTEGER NOT NULL + ); + INSERT OR IGNORE INTO pm_change_seq (id, seq) VALUES (1, 0); + + CREATE TABLE IF NOT EXISTS pm_audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at INTEGER NOT NULL, -- unix ms + actor_kind TEXT, -- protocol ActorRef.kind (Phase 3+) + actor_id TEXT, + actor_name TEXT, + operation TEXT NOT NULL, -- canonical op, e.g. work.transition + entity_type TEXT NOT NULL, -- work_item | routine | routine_run + entity_id TEXT NOT NULL, + project_slug TEXT, + org_id TEXT, + revision INTEGER, -- entity revision after the mutation + seq INTEGER, -- pm_change_seq value at commit + payload_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pm_audit_entity + ON pm_audit_events(entity_type, entity_id); + CREATE INDEX IF NOT EXISTS idx_pm_audit_seq + ON pm_audit_events(seq); + + CREATE TABLE IF NOT EXISTS pm_routines ( + name TEXT PRIMARY KEY, -- portable unique name + routine_id TEXT NOT NULL, -- metadata.id (stable) + spec_json TEXT NOT NULL, -- canonical portable spec + spec_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, -- gates automatic activations only + -- Host-local execution binding (NOT part of the portable spec + -- or its hash): the project scope scheduled invokes run in. + default_scope TEXT, + -- Scheduler watermarks (unix ms). + last_evaluated_at INTEGER, + next_fire_at INTEGER, + created_at INTEGER NOT NULL, -- unix ms + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pm_routine_runs ( + id TEXT PRIMARY KEY, -- run_ + routine_name TEXT NOT NULL, + routine_revision INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, -- immutable canonical spec + snapshot_hash TEXT NOT NULL, + scope_id TEXT NOT NULL, -- project slug (v1 local) + status TEXT NOT NULL, -- ordered projection, design §11 + inputs_json TEXT, + root_work_item_id TEXT, + created_by TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_pm_routine_runs_routine + ON pm_routine_runs(routine_name); + + CREATE TABLE IF NOT EXISTS pm_provider_bindings ( + work_item_id TEXT NOT NULL, -- workitems.id + provider TEXT NOT NULL, -- adapter id (linear/github/...) + external_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'primary', + authority TEXT NOT NULL DEFAULT 'provider', + provider_revision TEXT, + sync_state TEXT NOT NULL DEFAULT 'clean', + created_at INTEGER NOT NULL, -- unix ms + updated_at INTEGER NOT NULL, + PRIMARY KEY (work_item_id, provider) + ); + CREATE INDEX IF NOT EXISTS idx_pm_bindings_external + ON pm_provider_bindings(provider, external_id); + + CREATE TABLE IF NOT EXISTS pm_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, -- work_item + entity_id TEXT NOT NULL, -- store id (short_id scoped) + kind TEXT NOT NULL, -- portable relation kind + target_ref TEXT NOT NULL, -- e.g. session://codex_app/abc + created_at INTEGER NOT NULL, -- unix ms + actor_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pm_relations_entity + ON pm_relations(entity_type, entity_id); + + CREATE TABLE IF NOT EXISTS pm_idempotency ( + actor_id TEXT NOT NULL, + operation TEXT NOT NULL, + scope_id TEXT NOT NULL, + idem_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + response_json TEXT, + created_at INTEGER NOT NULL, -- unix ms + PRIMARY KEY (actor_id, operation, scope_id, idem_key) + ); + "#, + )?; Ok(()) } diff --git a/src-tauri/crates/project-management/src/provider_host/mod.rs b/src-tauri/crates/project-management/src/provider_host/mod.rs new file mode 100644 index 000000000..19d8a1df0 --- /dev/null +++ b/src-tauri/crates/project-management/src/provider_host/mod.rs @@ -0,0 +1,151 @@ +//! Provider capability profiles and the bundled registry +//! (`orgtrack/v1` §15, frozen decisions §5). +//! +//! Three profiles, contracts never mixed: +//! - **Planning** — WorkItem projection/sync. Implemented today by the +//! pluggable sync framework (`crate::sync::adapter::SyncAdapter` + +//! `AdapterDescriptor` are the planning-profile host interfaces; the +//! Phase 8 rename aligns the words, this module aligns the model). +//! - **Execution** — starts/attaches/cancels agent sessions. `org2` is +//! the bundled implementation; the underlying harness (claude_code, +//! codex_app, …) is session METADATA, never the provider id. +//! - **Provenance** — resolves SessionRefs to metadata/links/replay. +//! `org2` resolves its own sessions against the local EventStore; +//! external CLI providers are `reference-only`: they validate opaque +//! ids and offer metadata/links, no transcript, no replay. + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderProfile { + Planning, + Execution, + Provenance, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDescriptor { + pub id: String, + pub profiles: &'static [ProviderProfile], + /// Provenance depth: `true` = validates ids + metadata/links only. + pub reference_only: bool, + pub bundled: bool, +} + +/// Canonical external provenance provider ids — the importer-side source +/// namespace frozen in decisions §5 (hook short names like `claude` / +/// `codex` map onto these and never appear on the wire). +pub const EXTERNAL_PROVENANCE_PROVIDERS: &[&str] = &[ + "claude_code", + "codex_app", + "cursor_ide", + "cursor_cli", + "opencode", + "cline", + "copilot", + "kimi", + "qwen_code", + "droid", + "antigravity", + "zcode", + "warp", + "trae", + "qoder", + "windsurf", +]; + +/// The full provider registry: the bundled `org2` runtime provider, the +/// planning adapters currently registered with the sync framework, and +/// the reference-only external provenance providers. +pub fn registered_providers() -> Vec { + let mut providers = vec![ProviderDescriptor { + id: "org2".to_string(), + profiles: &[ProviderProfile::Execution, ProviderProfile::Provenance], + reference_only: false, + bundled: true, + }]; + for descriptor in crate::sync::adapters::list_descriptors() { + providers.push(ProviderDescriptor { + id: descriptor.id, + profiles: &[ProviderProfile::Planning], + reference_only: false, + bundled: false, + }); + } + for id in EXTERNAL_PROVENANCE_PROVIDERS { + providers.push(ProviderDescriptor { + id: (*id).to_string(), + profiles: &[ProviderProfile::Provenance], + reference_only: true, + bundled: false, + }); + } + providers +} + +/// Reference-only SessionRef validation (§15.6): the provider must be a +/// registered provenance provider and the opaque id non-empty. This is +/// the whole contract for reference-only providers — no transcript +/// fetch, no liveness probe. +pub fn validate_session_ref(provider: &str, external_id: &str) -> Result<(), String> { + if external_id.trim().is_empty() { + return Err("session ref external id must not be empty".to_string()); + } + let known = registered_providers() + .into_iter() + .any(|p| p.id == provider && p.profiles.contains(&ProviderProfile::Provenance)); + if !known { + return Err(format!( + "'{provider}' is not a registered provenance provider (hook short names like 'claude'/'codex' map to canonical ids like 'claude_code'/'codex_app')" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn org2_is_the_bundled_execution_and_provenance_provider() { + let providers = registered_providers(); + let org2 = providers.iter().find(|p| p.id == "org2").expect("org2"); + assert!(org2.bundled); + assert!(org2.profiles.contains(&ProviderProfile::Execution)); + assert!(org2.profiles.contains(&ProviderProfile::Provenance)); + assert!(!org2.reference_only); + } + + #[test] + fn planning_adapters_surface_with_the_planning_profile_only() { + let providers = registered_providers(); + let github = providers.iter().find(|p| p.id == "github").expect("github"); + assert_eq!(github.profiles, &[ProviderProfile::Planning]); + let linear = providers.iter().find(|p| p.id == "linear").expect("linear"); + assert_eq!(linear.profiles, &[ProviderProfile::Planning]); + } + + #[test] + fn external_cli_providers_are_reference_only_provenance() { + let providers = registered_providers(); + let claude = providers + .iter() + .find(|p| p.id == "claude_code") + .expect("claude_code"); + assert!(claude.reference_only); + assert_eq!(claude.profiles, &[ProviderProfile::Provenance]); + } + + #[test] + fn session_ref_validation_enforces_the_canonical_namespace() { + assert!(validate_session_ref("claude_code", "session_abc").is_ok()); + assert!(validate_session_ref("org2", "session_abc").is_ok()); + // Hook short names are not wire ids. + assert!(validate_session_ref("claude", "session_abc").is_err()); + // Planning-only providers own no sessions. + assert!(validate_session_ref("linear", "session_abc").is_err()); + assert!(validate_session_ref("claude_code", " ").is_err()); + } +} diff --git a/src-tauri/crates/project-management/src/routine_service/convert.rs b/src-tauri/crates/project-management/src/routine_service/convert.rs new file mode 100644 index 000000000..7041717f8 --- /dev/null +++ b/src-tauri/crates/project-management/src/routine_service/convert.rs @@ -0,0 +1,273 @@ +//! One-way conversion of legacy `RoutineDefinition` rows into portable +//! Routine specs (Phase 4 migration). +//! +//! The conversion is additive: portable definitions land in +//! `pm_routines` while legacy rows stay untouched until the Phase 5 +//! runtime unification deletes the legacy scheduler — running both +//! stores side by side cannot double-fire because the portable runtime's +//! scheduler does not exist yet. +//! +//! What is expressible and what is not: +//! - `CreateWorkItem` and `DirectSession` routines become single-step +//! portable routines (the prompt is the step instruction). The +//! model/account/workspace/harness resources on the legacy template +//! are NOT portable by design — they are reported as required +//! execution bindings for the operator to configure. +//! - `UpdateExistingWorkItem` routines target an existing work item; +//! the portable equivalent (`routine run --root-work`) is not wired +//! yet, so those definitions are reported as `skipped` and keep +//! running on the legacy path until Phase 5. +//! - `OneTime` triggers have no portable activation (schedule requires +//! cron); the portable spec gets a manual activation and the report +//! notes the dropped one-shot timestamp. + +use serde::Serialize; +use std::collections::BTreeMap; + +use crate::projects::types::{ + RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineDefinition, RoutineOutputMode, + RoutineRunTarget, RoutineTrigger, RoutineWorkspaceTarget, +}; + +use super::spec::{ + Activation, ActivationPolicies, CatchUpPolicy, ConcurrencyPolicy, RootWorkTemplate, + RoutineMetadata, RoutineSpec, RoutineSpecFile, StepSpec, +}; + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversionReport { + /// name -> new portable revision. + pub converted: Vec, + /// Definitions the portable model cannot express yet. + pub skipped: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConvertedRoutine { + pub legacy_id: String, + pub name: String, + pub revision: i64, + /// Non-portable knowledge the operator must re-express as execution + /// bindings (model/account/harness/workspace) or accept as dropped + /// (one-shot trigger timestamps). + pub warnings: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkippedRoutine { + pub legacy_id: String, + pub name: String, + pub reason: String, +} + +fn slugify(name: &str) -> String { + let mut slug = String::new(); + for c in name.chars() { + if c.is_ascii_alphanumeric() { + slug.push(c.to_ascii_lowercase()); + } else if !slug.ends_with('-') && !slug.is_empty() { + slug.push('-'); + } + } + let trimmed = slug.trim_matches('-').to_string(); + if trimmed.is_empty() { + "legacy-routine".to_string() + } else { + trimmed + } +} + +fn map_policies(definition: &RoutineDefinition) -> (ActivationPolicies, Vec) { + let mut warnings = Vec::new(); + let concurrency = match definition.output_policy.concurrency_policy { + RoutineConcurrencyPolicy::CoalesceIfActive => ConcurrencyPolicy::Coalesce, + RoutineConcurrencyPolicy::SkipIfActive => ConcurrencyPolicy::Skip, + RoutineConcurrencyPolicy::QueueIfActive => ConcurrencyPolicy::Queue, + RoutineConcurrencyPolicy::AlwaysCreate => { + warnings.push( + "concurrency 'always_create' has no portable equivalent; mapped to 'queue'" + .to_string(), + ); + ConcurrencyPolicy::Queue + } + }; + let catch_up = match definition.output_policy.catch_up_policy { + RoutineCatchUpPolicy::SkipMissed => CatchUpPolicy::None, + RoutineCatchUpPolicy::RunOnce => CatchUpPolicy::FireOnce, + RoutineCatchUpPolicy::RunAllLimited => { + warnings.push(format!( + "catch-up 'run_all_limited' (max {}) has no portable equivalent; mapped to 'fire_once'", + definition.output_policy.max_catch_up_runs + )); + CatchUpPolicy::FireOnce + } + }; + ( + ActivationPolicies { + concurrency_policy: Some(concurrency), + catch_up: Some(catch_up), + }, + warnings, + ) +} + +/// Convert one legacy definition. `Ok(Err(reason))` means "valid input, +/// not expressible portably". +pub fn convert_definition( + definition: &RoutineDefinition, +) -> Result<(RoutineSpecFile, Vec), String> { + if definition.output_policy.mode == RoutineOutputMode::UpdateExistingWorkItem { + return Err(format!( + "targets existing work item {:?} — portable --root-work runs land in Phase 5", + definition.output_policy.update_work_item_short_id + )); + } + + let mut warnings = Vec::new(); + let (policies, policy_warnings) = map_policies(definition); + warnings.extend(policy_warnings); + + // Resources are the boundary the portable model enforces. + let resources = &definition.run_template.resources; + if resources.model.is_some() + || resources.account_id.is_some() + || resources.key_source.is_some() + || resources.native_harness_type.is_some() + { + warnings.push( + "model/account/harness selection dropped from the portable spec; re-express as an execution binding" + .to_string(), + ); + } + 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(), + ); + } + match &definition.run_template.target { + RoutineRunTarget::AgentDefinition { + agent_definition_id: Some(id), + } => warnings.push(format!( + "agent target '{id}' dropped; bind role 'worker' to it in operator setup" + )), + RoutineRunTarget::AgentOrg { agent_org_id } => warnings.push(format!( + "agent org target '{agent_org_id}' dropped; bind role 'worker' to it in operator setup" + )), + _ => {} + } + + let activation = match &definition.trigger { + RoutineTrigger::Cron { cron } => Activation::Schedule { + cron: cron.clone(), + timezone: "UTC".to_string(), + policies, + }, + RoutineTrigger::OneTime { at } => { + warnings.push(format!( + "one-shot trigger at '{at}' has no portable activation; converted to manual" + )); + Activation::Manual { policies } + } + }; + + let root_title = definition + .output_policy + .create_work_item_title + .clone() + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| definition.name.clone()); + let root_body = definition + .output_policy + .create_work_item_body + .clone() + .filter(|body| !body.trim().is_empty()) + .unwrap_or_else(|| definition.description.clone()); + + let file = RoutineSpecFile { + api_version: "orgtrack/v1".to_string(), + kind: "Routine".to_string(), + metadata: RoutineMetadata { + id: format!("routine_{}", slugify(&definition.name)), + name: slugify(&definition.name), + revision: None, + }, + spec: RoutineSpec { + inputs: BTreeMap::new(), + root_work: RootWorkTemplate { + title: root_title, + body: Some(root_body), + priority: None, + labels: vec![], + }, + steps: vec![StepSpec { + id: "execute".to_string(), + title: definition + .run_template + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| definition.name.clone()), + needs: vec![], + actor: Some(super::spec::ActorRequirement { + role: "worker".to_string(), + requires: vec![], + }), + instruction: Some(definition.run_template.prompt.clone()), + inputs: BTreeMap::new(), + outputs: BTreeMap::new(), + }], + activations: vec![activation], + }, + }; + Ok((file, warnings)) +} + +/// Convert every legacy definition currently in the store, applying the +/// 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. +pub fn convert_all(disable_converted_legacy: bool) -> Result { + let definitions = crate::projects::io::list_routines()?; + let mut report = ConversionReport::default(); + for definition in &definitions { + match convert_definition(definition) { + Ok((file, warnings)) => { + let applied = super::apply(&file)?; + // Host-local scope binding: scheduled invokes need a + // target project. CreateWorkItem routines carried it on + // the legacy policy; DirectSession ones did not — those + // stay manual-only until the operator binds a scope. + if let Some(scope) = definition + .output_policy + .create_work_item_project_slug + .as_deref() + { + super::set_default_scope(&applied.name, scope)?; + } + if disable_converted_legacy && definition.enabled { + crate::projects::io::disable_routine(&definition.id)?; + } + report.converted.push(ConvertedRoutine { + legacy_id: definition.id.clone(), + name: applied.name, + revision: applied.revision, + warnings, + }); + } + Err(reason) => report.skipped.push(SkippedRoutine { + legacy_id: definition.id.clone(), + name: definition.name.clone(), + reason, + }), + } + } + Ok(report) +} diff --git a/src-tauri/crates/project-management/src/routine_service/mod.rs b/src-tauri/crates/project-management/src/routine_service/mod.rs new file mode 100644 index 000000000..ff75ac2a4 --- /dev/null +++ b/src-tauri/crates/project-management/src/routine_service/mod.rs @@ -0,0 +1,698 @@ +//! Routine application service (`orgtrack/v1` Phase 4). +//! +//! Owns the portable Routine domain: spec validation/canonicalization +//! ([`spec`]), versioned definitions with immutable per-run snapshots, +//! and RoutineRun materialization into generated WorkItems through the +//! same `work.create` handler every other entry point uses. +//! +//! Storage: `pm_routines` (current definition + revision) and +//! `pm_routine_runs` (immutable occurrence: revision, snapshot, hash, +//! status projection inputs). The legacy `routine_definitions` / +//! `routine_fires` tables stay readable until the Phase 4 conversion +//! completes; conversion is one-way and disables definitions it cannot +//! express portably, with a written report. + +pub mod convert; +pub mod spec; + +use crate::projects::io as project_io; +use crate::work_service; + +/// Compute the immutable snapshot hash for a canonical spec body. +pub fn snapshot_hash(canonical: &str) -> String { + // FNV-1a 64 over the canonical bytes, doubled for width. Not + // cryptographic — the hash pins run provenance, it does not defend + // against adversaries; swap for sha256 when a crypto dep lands in + // this crate for other reasons. + fn fnv1a(bytes: &[u8], seed: u64) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ seed; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01B3); + } + hash + } + let a = fnv1a(canonical.as_bytes(), 0); + let b = fnv1a(canonical.as_bytes(), 0x9E37_79B9_7F4A_7C15); + format!("fnv1a:{a:016x}{b:016x}") +} + +/// `routine.apply` (§12.1): validate, canonicalize, then create or bump +/// the definition. Same canonical body → same revision (idempotent); +/// changed body → revision + 1. Historic runs are never touched. +pub fn apply(spec_file: &spec::RoutineSpecFile) -> Result { + let violations = spec::validate(spec_file); + if !violations.is_empty() { + let details = serde_json::to_string(&violations).unwrap_or_default(); + return Err(format!("{}:{}", error::SPEC_INVALID, details)); + } + let canonical = spec::canonicalize(spec_file)?; + let hash = snapshot_hash(&canonical); + + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction() + .map_err(|err| format!("routine apply tx: {err}"))?; + + let existing: Option<(i64, String)> = tx + .query_row( + "SELECT revision, spec_hash FROM pm_routines WHERE name = ?1", + rusqlite::params![spec_file.metadata.name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map(Some) + .or_else(|err| match err { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(format!("routine apply: {other}")), + })?; + + let (revision, changed) = match existing { + Some((revision, ref stored_hash)) if stored_hash == &hash => (revision, false), + Some((revision, _)) => { + let next = revision + 1; + tx.execute( + "UPDATE pm_routines + SET spec_json = ?2, spec_hash = ?3, revision = ?4, updated_at = ?5 + WHERE name = ?1", + rusqlite::params![ + spec_file.metadata.name, + canonical, + hash, + next, + chrono::Utc::now().timestamp_millis(), + ], + ) + .map_err(|err| format!("routine apply: {err}"))?; + (next, true) + } + None => { + tx.execute( + "INSERT INTO pm_routines + (name, routine_id, spec_json, spec_hash, revision, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 1, 1, ?5, ?5)", + rusqlite::params![ + spec_file.metadata.name, + spec_file.metadata.id, + canonical, + hash, + chrono::Utc::now().timestamp_millis(), + ], + ) + .map_err(|err| format!("routine apply: {err}"))?; + (1, true) + } + }; + + if changed { + let seq = work_service::audit::bump_change_seq(&tx)?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation: "routine.apply", + entity_type: "routine", + entity_id: &spec_file.metadata.name, + project_slug: None, + org_id: None, + actor: None, + revision, + seq, + payload: serde_json::json!({ "specHash": hash }), + }, + )?; + } + tx.commit() + .map_err(|err| format!("routine apply commit: {err}"))?; + + Ok(AppliedRoutine { + name: spec_file.metadata.name.clone(), + revision, + spec_hash: hash, + changed, + }) +} + +#[derive(Debug)] +pub struct AppliedRoutine { + pub name: String, + pub revision: i64, + pub spec_hash: String, + pub changed: bool, +} + +/// Typed error sentinels for the routine domain. +pub mod error { + pub const SPEC_INVALID: &str = "PM_ERR:ROUTINE_SPEC_INVALID"; + pub const INPUTS_INVALID: &str = "PM_ERR:ROUTINE_INPUTS_INVALID"; +} + +/// Substitute `{{ inputs. }}` template markers (with or without +/// inner spaces) in root-work templates. Declarative only. +fn substitute_inputs(template: &str, inputs: &std::collections::BTreeMap) -> String { + let mut result = template.to_string(); + for (name, value) in inputs { + for marker in [ + format!("{{{{ inputs.{} }}}}", name), + format!("{{{{inputs.{}}}}}", name), + ] { + result = result.replace(&marker, value); + } + } + result +} + +#[derive(Debug)] +pub struct InvokedRun { + pub run_id: String, + pub root_short_id: String, + /// step id -> generated child short id, in spec order. + pub steps: Vec<(String, String)>, +} + +/// `routine.invoke` (§12.2): snapshot the current revision, create the +/// RoutineRun, materialize the root WorkItem and one generated child per +/// step through the canonical `work.create` handler, and record the +/// dependency edges as durable `depends_on` relations. Scheduler and +/// manual invocations share this single entry point. +pub fn invoke( + routine_name: &str, + scope_project_slug: &str, + inputs: &std::collections::BTreeMap, + created_by: Option<&crate::projects::types::WorkItemMutationActor>, +) -> Result { + // 1. Load the current definition. + let connection = project_io::helpers::conn()?; + let (spec_json, spec_hash, revision): (String, String, i64) = connection + .query_row( + "SELECT spec_json, spec_hash, revision FROM pm_routines WHERE name = ?1", + rusqlite::params![routine_name], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|err| match err { + rusqlite::Error::QueryReturnedNoRows => { + format!("Routine '{}' not found", routine_name) + } + other => format!("routine invoke: {other}"), + })?; + drop(connection); + 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)); + } + } + for name in inputs.keys() { + if !snapshot.spec.inputs.contains_key(name) { + return Err(format!("{}:unknown input '{}'", error::INPUTS_INVALID, name)); + } + } + + let now = chrono::Utc::now().timestamp_millis(); + let run_id = format!("run_{}{:05}", now, std::process::id() % 100_000); + + // 3. Materialize the work graph through the canonical create handler. + let root_short_id = project_io::allocate_short_id(scope_project_slug)?; + let root_request = work_service::CreateWorkItemRequest { + title: substitute_inputs(&snapshot.spec.root_work.title, inputs), + body: snapshot + .spec + .root_work + .body + .as_deref() + .map(|body| substitute_inputs(body, inputs)) + .unwrap_or_default(), + priority: snapshot.spec.root_work.priority.clone(), + labels: snapshot.spec.root_work.labels.clone(), + 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)?; + + 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 mut body = step + .instruction + .as_deref() + .map(|instruction| substitute_inputs(instruction, inputs)) + .unwrap_or_default(); + if !step.inputs.is_empty() { + body.push_str("\n\n## Inputs\n"); + for (name, expression) in &step.inputs { + body.push_str(&format!("- {}: {}\n", name, expression)); + } + } + if let Some(actor_requirement) = &step.actor { + body.push_str(&format!( + "\n## Actor requirement\n- role: {}\n- requires: {}\n", + actor_requirement.role, + actor_requirement.requires.join(", ") + )); + } + let child_request = work_service::CreateWorkItemRequest { + title: substitute_inputs(&step.title, inputs), + body, + parent: Some(root_short_id.clone()), + 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, + )?; + 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(); + 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, + 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, + )?; + } + + // 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, + scope_id, status, inputs_json, root_work_item_id, created_by, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'running', ?7, ?8, ?9, ?10, ?10)", + rusqlite::params![ + run_id, + routine_name, + revision, + spec_json, + spec_hash, + scope_project_slug, + serde_json::to_string(inputs).unwrap_or_default(), + root_short_id, + created_by.map(|actor| actor.id.as_str()), + now, + ], + ) + .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 { + operation: "routine.invoke", + entity_type: "routine_run", + entity_id: &run_id, + project_slug: Some(scope_project_slug), + org_id: None, + actor: created_by, + revision, + seq, + payload: serde_json::json!({ + "routine": routine_name, + "snapshotHash": spec_hash, + "rootWorkItemId": root_short_id, + }), + }, + )?; + tx.commit() + .map_err(|err| format!("routine invoke commit: {err}"))?; + + Ok(InvokedRun { + run_id, + root_short_id, + steps: step_ids, + }) +} + +/// Set the host-local default scope binding used by scheduled invokes. +/// Deliberately outside the portable spec/hash — scope is deployment +/// configuration, not work-method knowledge. +pub fn set_default_scope(name: &str, scope: &str) -> Result<(), String> { + let connection = project_io::helpers::conn()?; + let changed = connection + .execute( + "UPDATE pm_routines SET default_scope = ?2 WHERE name = ?1", + rusqlite::params![name, scope], + ) + .map_err(|err| format!("routine set_default_scope: {err}"))?; + if changed == 0 { + return Err(format!("Routine '{}' not found", name)); + } + Ok(()) +} + +/// One schedule-activation candidate for the host scheduler tick. +#[derive(Debug)] +pub struct ScheduledCandidate { + pub name: String, + pub cron: String, + pub timezone: String, + pub concurrency: spec::ConcurrencyPolicy, + pub catch_up: spec::CatchUpPolicy, + pub default_scope: Option, + pub last_evaluated_at: Option, +} + +/// Enabled routines with schedule activations, for the host scheduler. +pub fn scheduled_candidates() -> Result, String> { + let connection = project_io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT name, spec_json, default_scope, last_evaluated_at + FROM pm_routines WHERE enabled = 1", + ) + .map_err(|err| format!("scheduled candidates: {err}"))?; + let rows: Vec<(String, String, Option, Option)> = statement + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(|err| format!("scheduled candidates: {err}"))? + .collect::, _>>() + .map_err(|err| format!("scheduled candidates: {err}"))?; + let mut candidates = Vec::new(); + for (name, spec_json, default_scope, last_evaluated_at) in rows { + let Ok(file) = serde_json::from_str::(&spec_json) else { + continue; + }; + for activation in &file.spec.activations { + if let spec::Activation::Schedule { + cron, + timezone, + policies, + } = activation + { + candidates.push(ScheduledCandidate { + name: name.clone(), + cron: cron.clone(), + timezone: timezone.clone(), + concurrency: policies + .concurrency_policy + .unwrap_or(spec::ConcurrencyPolicy::Skip), + catch_up: policies.catch_up.unwrap_or(spec::CatchUpPolicy::None), + default_scope: default_scope.clone(), + last_evaluated_at, + }); + } + } + } + Ok(candidates) +} + +/// Persist the scheduler watermark after an evaluation pass. +pub fn mark_evaluated(name: &str, evaluated_at: i64, next_fire_at: Option) -> Result<(), String> { + let connection = project_io::helpers::conn()?; + connection + .execute( + "UPDATE pm_routines SET last_evaluated_at = ?2, next_fire_at = ?3 WHERE name = ?1", + rusqlite::params![name, evaluated_at, next_fire_at], + ) + .map_err(|err| format!("routine mark_evaluated: {err}"))?; + Ok(()) +} + +/// True when the routine has a non-terminal run (running or pending). +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 + 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) +} + +/// Audit a suppressed automatic fire (skip/coalesce/queue while active). +pub fn audit_suppressed_fire(name: &str, policy: &str, scheduled_at: i64) -> Result<(), String> { + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction() + .map_err(|err| format!("suppressed fire tx: {err}"))?; + let seq = work_service::audit::bump_change_seq(&tx)?; + work_service::audit::append_audit_event( + &tx, + &work_service::audit::AuditEventRow { + operation: "routine.fire_suppressed", + entity_type: "routine", + entity_id: name, + project_slug: None, + org_id: None, + actor: None, + revision: 0, + seq, + payload: serde_json::json!({ "policy": policy, "scheduledAt": scheduled_at }), + }, + )?; + tx.commit() + .map_err(|err| format!("suppressed fire commit: {err}")) +} + +/// List routine definitions (name, revision, enabled, hash). +pub fn list_routines() -> Result, String> { + let connection = project_io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT name, routine_id, revision, enabled, spec_hash, updated_at + FROM pm_routines ORDER BY name", + ) + .map_err(|err| format!("routine list: {err}"))?; + let rows = statement + .query_map([], |row| { + Ok(serde_json::json!({ + "name": row.get::<_, String>(0)?, + "routineId": row.get::<_, String>(1)?, + "revision": row.get::<_, i64>(2)?, + "enabled": row.get::<_, i64>(3)? != 0, + "specHash": row.get::<_, String>(4)?, + "updatedAt": row.get::<_, i64>(5)?, + })) + }) + .map_err(|err| format!("routine list: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine list: {err}"))?; + Ok(rows) +} + +/// Enable/disable automatic activations. Manual `routine run` stays +/// available on disabled routines by contract. +pub fn set_enabled(name: &str, enabled: bool) -> Result<(), String> { + let connection = project_io::helpers::conn()?; + let changed = connection + .execute( + "UPDATE pm_routines SET enabled = ?2, updated_at = ?3 WHERE name = ?1", + rusqlite::params![name, enabled as i64, chrono::Utc::now().timestamp_millis()], + ) + .map_err(|err| format!("routine set_enabled: {err}"))?; + if changed == 0 { + return Err(format!("Routine '{}' not found", name)); + } + Ok(()) +} + +/// List routine runs, newest first, optionally filtered to one scope. +/// Row-level listing for the Runs surface — per-run WorkItem projection +/// stays in [`run_status`], which the UI calls on expand. +pub fn list_runs( + scope_id: Option<&str>, + limit: usize, +) -> Result, String> { + let connection = project_io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT id, routine_name, routine_revision, scope_id, status, + root_work_item_id, created_by, created_at, updated_at + FROM pm_routine_runs + WHERE (?1 IS NULL OR scope_id = ?1) + ORDER BY created_at DESC, id DESC + LIMIT ?2", + ) + .map_err(|err| format!("routine list_runs: {err}"))?; + let rows = statement + .query_map(rusqlite::params![scope_id, limit as i64], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, String>(0)?, + "routineName": row.get::<_, String>(1)?, + "routineRevision": row.get::<_, i64>(2)?, + "scopeId": row.get::<_, String>(3)?, + "status": row.get::<_, String>(4)?, + "rootWorkItemId": row.get::<_, Option>(5)?, + "createdBy": row.get::<_, Option>(6)?, + "createdAt": row.get::<_, i64>(7)?, + "updatedAt": row.get::<_, i64>(8)?, + })) + }) + .map_err(|err| format!("routine list_runs: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine list_runs: {err}"))?; + Ok(rows) +} + +/// Durable run-status view: the run row plus each generated WorkItem's +/// state, with the overall status recomputed by the ordered decision +/// procedure from design §11 (cancel machinery lands in Phase 5, so the +/// cancel rules short-circuit to the stored status for now). +pub fn run_status(run_id: &str) -> Result { + let connection = project_io::helpers::conn()?; + let (routine_name, revision, snapshot_hash, scope_id, stored_status, root_id): ( + String, + i64, + String, + String, + String, + Option, + ) = connection + .query_row( + "SELECT routine_name, routine_revision, snapshot_hash, scope_id, status, + root_work_item_id + FROM pm_routine_runs WHERE id = ?1", + rusqlite::params![run_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + ) + .map_err(|err| match err { + rusqlite::Error::QueryReturnedNoRows => format!("Run '{}' not found", run_id), + other => format!("routine status: {other}"), + })?; + + // Generated children: reverse lookup on the generated_by relation. + 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 status: {err}"))?; + let child_ids: Vec = statement + .query_map(rusqlite::params![format!("run://{run_id}")], |row| { + row.get(0) + }) + .map_err(|err| format!("routine status: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine status: {err}"))?; + drop(statement); + drop(connection); + + let mut items = Vec::new(); + let mut portable_states = Vec::new(); + for child_id in &child_ids { + let item = project_io::read_work_item(&scope_id, child_id)?; + let portable = work_service::state::map_legacy_status(&item.frontmatter.status); + portable_states.push(portable); + items.push(serde_json::json!({ + "shortId": child_id, + "title": item.frontmatter.title, + "status": item.frontmatter.status, + "portableState": portable.map(|state| state.as_str()), + })); + } + + let status = project_run_status(&stored_status, &portable_states, &child_ids, &scope_id)?; + + Ok(serde_json::json!({ + "apiVersion": "orgtrack/v1", + "kind": "RoutineRun", + "id": run_id, + "routineName": routine_name, + "routineRevision": revision, + "snapshotHash": snapshot_hash, + "scopeId": scope_id, + "status": status, + "rootWorkItemId": root_id, + "workItems": items, + })) +} + +/// Ordered first-match projection (§11). Rules 1-3 (queue pending / +/// cancel) short-circuit to the stored status until Phase 5 lands the +/// cancel machinery; rules 4-7 compute from the generated items. +fn project_run_status( + stored: &str, + portable_states: &[Option], + child_ids: &[String], + scope_id: &str, +) -> Result { + use work_service::WorkItemState::*; + if stored == "pending" || stored.starts_with("cancel") || stored == "cancelled" { + return Ok(stored.to_string()); + } + if portable_states.iter().any(|s| *s == Some(Failed)) { + return Ok("failed".into()); + } + if !portable_states.is_empty() && portable_states.iter().all(|s| *s == Some(Completed)) { + return Ok("succeeded".into()); + } + let any_in_progress = portable_states.iter().any(|s| *s == Some(InProgress)); + if any_in_progress { + return Ok("running".into()); + } + // Ready open work: open with all dependencies completed. + let connection = project_io::helpers::conn()?; + for (index, child_id) in child_ids.iter().enumerate() { + if portable_states[index] != Some(Open) { + continue; + } + let mut statement = connection + .prepare( + "SELECT target_ref FROM pm_relations + WHERE kind = 'depends_on' AND entity_type = 'work_item' AND entity_id = ?1", + ) + .map_err(|err| format!("routine status: {err}"))?; + let dependencies: Vec = statement + .query_map(rusqlite::params![child_id], |row| row.get(0)) + .map_err(|err| format!("routine status: {err}"))? + .collect::, _>>() + .map_err(|err| format!("routine status: {err}"))?; + let all_done = dependencies.iter().all(|target| { + target + .strip_prefix(&format!("work://{scope_id}/")) + .map(|dep_id| { + child_ids + .iter() + .position(|c| c == dep_id) + .map(|position| portable_states[position] == Some(Completed)) + .unwrap_or(true) + }) + .unwrap_or(true) + }); + if all_done { + return Ok("running".into()); + } + } + Ok("blocked".into()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/src-tauri/crates/project-management/src/routine_service/spec.rs b/src-tauri/crates/project-management/src/routine_service/spec.rs new file mode 100644 index 000000000..42c7676fb --- /dev/null +++ b/src-tauri/crates/project-management/src/routine_service/spec.rs @@ -0,0 +1,467 @@ +//! Portable Routine spec (`orgtrack/v1` §10, routine.schema.json). +//! +//! The spec describes WHAT a repeatable work graph is — inputs, root +//! work template, executable steps with dependencies, actor +//! role/capability requirements, activations. It deliberately cannot +//! express model, account, credential, workspace path or session +//! targets: those live in execution bindings (operator setup), which is +//! the core boundary the legacy `RoutineDefinition` violated. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RoutineSpecFile { + #[serde(rename = "apiVersion")] + pub api_version: String, + pub kind: String, + pub metadata: RoutineMetadata, + pub spec: RoutineSpec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RoutineMetadata { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RoutineSpec { + /// BTreeMap: canonicalization requires deterministic ordering. + #[serde(default)] + pub inputs: BTreeMap, + #[serde(rename = "rootWork")] + pub root_work: RootWorkTemplate, + pub steps: Vec, + #[serde(default)] + pub activations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputDecl { + #[serde(rename = "type")] + pub input_type: InputType, + #[serde(default)] + pub required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InputType { + String, + Number, + Boolean, + Path, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RootWorkTemplate { + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub labels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StepSpec { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub needs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instruction: Option, + /// Mapping expressions (`${steps..outputs.}` / + /// `${inputs.}`) — declarative only, never code. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub inputs: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub outputs: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActorRequirement { + pub role: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutputDecl { + #[serde(rename = "type")] + pub output_type: OutputType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OutputType { + #[serde(rename = "artifact")] + Artifact, + #[serde(rename = "artifact-list")] + ArtifactList, + #[serde(rename = "reference")] + Reference, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type")] +pub enum Activation { + #[serde(rename = "manual")] + Manual { + #[serde(flatten)] + policies: ActivationPolicies, + }, + #[serde(rename = "schedule")] + Schedule { + cron: String, + timezone: String, + #[serde(flatten)] + policies: ActivationPolicies, + }, + #[serde(rename = "provider_event")] + ProviderEvent { + provider: String, + #[serde(rename = "eventKind")] + event_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + filter: Option, + #[serde(flatten)] + policies: ActivationPolicies, + }, +} + +/// Concurrency + catch-up carried by every activation. Defaults preserve +/// the legacy `routine_fires` semantics (skip, no catch-up) — the frozen +/// no-regression requirement. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ActivationPolicies { + #[serde( + rename = "concurrencyPolicy", + default, + skip_serializing_if = "Option::is_none" + )] + pub concurrency_policy: Option, + #[serde(rename = "catchUp", default, skip_serializing_if = "Option::is_none")] + pub catch_up: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConcurrencyPolicy { + Coalesce, + Skip, + Queue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CatchUpPolicy { + None, + FireOnce, +} + +/// Structured validation failure — stable shape for the CLI error +/// envelope's details. +#[derive(Debug, Clone, Serialize)] +pub struct SpecViolation { + pub path: String, + pub message: String, +} + +/// Validate everything the schema cannot: id shapes, graph acyclicity, +/// `needs` referencing real steps, input-mapping expressions resolving to +/// declared inputs/outputs, and schedule shape. +pub fn validate(file: &RoutineSpecFile) -> Vec { + let mut violations = Vec::new(); + let push = |violations: &mut Vec, path: &str, message: String| { + violations.push(SpecViolation { + path: path.to_string(), + message, + }); + }; + + if file.api_version != "orgtrack/v1" { + push( + &mut violations, + "apiVersion", + format!("expected orgtrack/v1, got '{}'", file.api_version), + ); + } + if file.kind != "Routine" { + push( + &mut violations, + "kind", + format!("expected Routine, got '{}'", file.kind), + ); + } + if file.metadata.name.trim().is_empty() { + push(&mut violations, "metadata.name", "must not be empty".into()); + } + if file.spec.steps.is_empty() { + push(&mut violations, "spec.steps", "at least one step".into()); + } + + // Step ids: shape + uniqueness. + let mut step_ids = HashSet::new(); + for (index, step) in file.spec.steps.iter().enumerate() { + let path = format!("spec.steps[{index}].id"); + let valid_shape = !step.id.is_empty() + && step + .id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !step.id.starts_with('-'); + if !valid_shape { + push( + &mut violations, + &path, + format!("'{}' must match ^[a-z0-9][a-z0-9-]*$", step.id), + ); + } + if !step_ids.insert(step.id.clone()) { + push(&mut violations, &path, format!("duplicate step id '{}'", step.id)); + } + } + + // needs: known ids, no self-reference, acyclic (Kahn). + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); + for step in &file.spec.steps { + in_degree.entry(step.id.as_str()).or_insert(0); + for need in &step.needs { + let path = format!("spec.steps[{}].needs", step.id); + if need == &step.id { + push(&mut violations, &path, "step cannot need itself".into()); + continue; + } + if !step_ids.contains(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()); + } + } + let mut queue: Vec<&str> = in_degree + .iter() + .filter(|(_, degree)| **degree == 0) + .map(|(id, _)| *id) + .collect(); + let mut visited = 0usize; + while let Some(id) = queue.pop() { + visited += 1; + if let Some(children) = dependents.get(id) { + for child in children { + let degree = in_degree.get_mut(child).expect("child tracked"); + *degree -= 1; + if *degree == 0 { + queue.push(child); + } + } + } + } + if visited != in_degree.len() { + push( + &mut violations, + "spec.steps", + "dependency graph contains a cycle".into(), + ); + } + + // Input mapping expressions: ${inputs.x} or ${steps..outputs.} + // where the referenced step is a declared dependency with that output. + for step in &file.spec.steps { + for (input_name, expression) in &step.inputs { + let path = format!("spec.steps[{}].inputs.{}", step.id, input_name); + let Some(inner) = expression + .strip_prefix("${") + .and_then(|rest| rest.strip_suffix('}')) + else { + push( + &mut violations, + &path, + format!("'{expression}' is not a ${{...}} mapping expression"), + ); + continue; + }; + if let Some(name) = inner.strip_prefix("inputs.") { + if !file.spec.inputs.contains_key(name) { + push(&mut violations, &path, format!("unknown routine input '{name}'")); + } + continue; + } + if let Some(rest) = inner.strip_prefix("steps.") { + let parts: Vec<&str> = rest.split('.').collect(); + if parts.len() == 3 && parts[1] == "outputs" { + let (source_id, output_name) = (parts[0], parts[2]); + let source = file.spec.steps.iter().find(|s| s.id == source_id); + match source { + None => push( + &mut violations, + &path, + format!("unknown source step '{source_id}'"), + ), + Some(source_step) => { + if !step.needs.iter().any(|need| need == source_id) { + push( + &mut violations, + &path, + format!("step must declare '{source_id}' in needs to consume its outputs"), + ); + } + if !source_step.outputs.contains_key(output_name) { + push( + &mut violations, + &path, + format!("step '{source_id}' declares no output '{output_name}'"), + ); + } + } + } + continue; + } + push( + &mut violations, + &path, + format!("'{inner}' must be steps..outputs."), + ); + continue; + } + push( + &mut violations, + &path, + format!("'{inner}' must reference inputs.* or steps.*.outputs.*"), + ); + } + } + + // Activations. + for (index, activation) in file.spec.activations.iter().enumerate() { + if let Activation::Schedule { cron, timezone, .. } = activation { + let path = format!("spec.activations[{index}]"); + if cron.split_whitespace().count() != 5 { + push( + &mut violations, + &path, + format!("cron '{cron}' must have 5 fields"), + ); + } + if timezone.trim().is_empty() { + push(&mut violations, &path, "timezone is required".into()); + } + } + } + + violations +} + +/// Canonical JSON used for the immutable snapshot hash: serde with +/// BTreeMaps gives deterministic key order; whitespace-free encoding. +pub fn canonicalize(file: &RoutineSpecFile) -> Result { + serde_json::to_string(file).map_err(|err| format!("canonicalize routine: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> RoutineSpecFile { + let raw = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../docs/orgtrack-pm-protocol/fixtures/routine-spec.json"), + ) + .expect("frozen fixture readable"); + serde_json::from_str(&raw).expect("frozen fixture parses") + } + + #[test] + fn frozen_fixture_is_valid() { + let file = fixture(); + let violations = validate(&file); + assert!(violations.is_empty(), "{violations:?}"); + } + + #[test] + fn cycle_is_rejected() { + let mut file = fixture(); + file.spec.steps[0].needs = vec!["archive-and-notify".to_string()]; + let violations = validate(&file); + assert!( + violations.iter().any(|v| v.message.contains("cycle")), + "{violations:?}" + ); + } + + #[test] + fn unknown_need_is_rejected() { + let mut file = fixture(); + 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:?}" + ); + } + + #[test] + fn consuming_outputs_without_needs_is_rejected() { + let mut file = fixture(); + file.spec.steps[1].needs = vec![]; + let violations = validate(&file); + assert!( + violations + .iter() + .any(|v| v.message.contains("must declare")), + "{violations:?}" + ); + } + + #[test] + fn arbitrary_expression_is_rejected() { + let mut file = fixture(); + file.spec.steps[1] + .inputs + .insert("evil".into(), "$(rm -rf /)".into()); + let violations = validate(&file); + assert!( + violations + .iter() + .any(|v| v.message.contains("mapping expression")), + "{violations:?}" + ); + } + + #[test] + fn canonicalization_is_deterministic() { + let a = canonicalize(&fixture()).unwrap(); + let b = canonicalize(&fixture()).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn model_account_workspace_fields_cannot_parse() { + // The frozen boundary: runtime resources are not expressible. + let mut raw: serde_json::Value = serde_json::to_value(fixture()).unwrap(); + raw["spec"]["model"] = serde_json::json!("gpt-x"); + let parsed: Result = serde_json::from_value(raw); + assert!(parsed.is_err(), "deny_unknown_fields must reject model"); + } +} diff --git a/src-tauri/crates/project-management/src/routine_service/tests.rs b/src-tauri/crates/project-management/src/routine_service/tests.rs new file mode 100644 index 000000000..2fe5693eb --- /dev/null +++ b/src-tauri/crates/project-management/src/routine_service/tests.rs @@ -0,0 +1,188 @@ +//! Integration tests for the routine application service (Phase 4): +//! apply idempotency, revision bumps, and spec-boundary rejection. + +use super::*; +use test_helpers::test_env; + +fn fixture() -> spec::RoutineSpecFile { + let raw = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../docs/orgtrack-pm-protocol/fixtures/routine-spec.json"), + ) + .expect("frozen fixture readable"); + serde_json::from_str(&raw).expect("frozen fixture parses") +} + +#[test] +fn apply_is_idempotent_for_identical_canonical_bodies() { + let _sandbox = test_env::sandbox(); + let file = fixture(); + + let first = apply(&file).expect("first apply"); + assert_eq!(first.revision, 1); + assert!(first.changed); + + let second = apply(&file).expect("second apply"); + assert_eq!(second.revision, 1, "same canonical body keeps the revision"); + assert!(!second.changed); + assert_eq!(first.spec_hash, second.spec_hash); +} + +#[test] +fn apply_bumps_revision_when_the_body_changes() { + let _sandbox = test_env::sandbox(); + let mut file = fixture(); + let first = apply(&file).expect("first apply"); + + file.spec.root_work.title = "改标题:{{ inputs.requirement_id }}".to_string(); + let second = apply(&file).expect("second apply"); + assert_eq!(second.revision, first.revision + 1); + assert!(second.changed); + assert_ne!(first.spec_hash, second.spec_hash); +} + +#[test] +fn invoke_materializes_the_work_graph_with_durable_edges() { + 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).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); + + // 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())); + } + + // 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 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) + }); + assert!(has_dep, "{relations:?}"); + let has_run = relations + .iter() + .any(|r| r["kind"] == "generated_by" && r["targetRef"] == format!("run://{}", run.run_id)); + assert!(has_run, "{relations:?}"); + + // The run row is durable with the immutable snapshot pinned. + let connection = crate::projects::io::helpers::conn().expect("conn"); + let (status, revision, root_id): (String, i64, String) = connection + .query_row( + "SELECT status, routine_revision, root_work_item_id FROM pm_routine_runs WHERE id = ?1", + rusqlite::params![run.run_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("run row"); + assert_eq!(status, "running"); + assert_eq!(revision, 1); + assert_eq!(root_id, run.root_short_id); +} + +#[test] +fn invoke_validates_inputs_against_the_snapshot_contract() { + let _sandbox = test_env::sandbox(); + crate::work_service::tests_support::seed_project("demo", "p1"); + let file = fixture(); + apply(&file).expect("apply"); + + let missing = invoke(&file.metadata.name, "demo", &Default::default(), 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"); + assert!(unknown.starts_with(error::INPUTS_INVALID), "{unknown}"); +} + +#[test] +fn legacy_conversion_expresses_create_and_direct_modes_and_skips_updates() { + use crate::projects::types::{ + RoutineCatchUpPolicy, RoutineConcurrencyPolicy, RoutineDefinition, RoutineOutputMode, + RoutineOutputPolicy, RoutineResourceSelection, RoutineRunTarget, RoutineRunTemplate, + RoutineTrigger, RoutineWorkspaceTarget, + }; + let _sandbox = test_env::sandbox(); + + let legacy = |mode: RoutineOutputMode, name: &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(), + }, + 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: Some("acct-1".to_string()), + model: Some("some-model".to_string()), + native_harness_type: None, + }, + workspace: RoutineWorkspaceTarget::None, + mode: None, + name: None, + }, + output_policy: RoutineOutputPolicy { + mode, + concurrency_policy: RoutineConcurrencyPolicy::QueueIfActive, + catch_up_policy: RoutineCatchUpPolicy::RunOnce, + ..RoutineOutputPolicy::default() + }, + last_evaluated_at: None, + next_fire_at: None, + created_at: String::new(), + updated_at: String::new(), + }; + + // Expressible: single-step portable routine with binding warnings. + let (file, warnings) = + convert::convert_definition(&legacy(RoutineOutputMode::CreateWorkItem, "Daily Sync")) + .expect("convertible"); + assert!(spec::validate(&file).is_empty()); + assert_eq!(file.spec.steps.len(), 1); + assert!(warnings.iter().any(|w| w.contains("execution binding"))); + assert!(warnings.iter().any(|w| w.contains("agent target"))); + let applied = apply(&file).expect("apply converted"); + assert_eq!(applied.revision, 1); + + // Not expressible yet: UpdateExistingWorkItem. + let mut updater = legacy(RoutineOutputMode::UpdateExistingWorkItem, "Refresher"); + updater.output_policy.update_work_item_short_id = Some("AAA-0009".to_string()); + let reason = convert::convert_definition(&updater).expect_err("must skip"); + assert!(reason.contains("Phase 5"), "{reason}"); +} + +#[test] +fn apply_rejects_invalid_specs_with_structured_violations() { + let _sandbox = test_env::sandbox(); + let mut file = fixture(); + file.spec.steps[0].needs = vec!["archive-and-notify".to_string()]; + + let err = apply(&file).expect_err("cycle must be rejected"); + assert!( + err.starts_with(error::SPEC_INVALID), + "typed sentinel expected: {err}" + ); + assert!(err.contains("cycle"), "violation payload rides along: {err}"); +} diff --git a/src-tauri/crates/project-management/src/work_service/audit.rs b/src-tauri/crates/project-management/src/work_service/audit.rs new file mode 100644 index 000000000..fc9549e4f --- /dev/null +++ b/src-tauri/crates/project-management/src/work_service/audit.rs @@ -0,0 +1,81 @@ +//! In-transaction audit + change-watermark helpers. +//! +//! Both helpers take the caller's open transaction so audit rows, the +//! `pm_change_seq` bump and the entity mutation commit atomically — the +//! frozen persistence invariant from the v1 design (§19). They are called +//! from the single atomic RMW choke point in +//! `projects::io::work_items::atomic`, which means every work-item +//! mutation (UI patch, agent tool, sync merge, future CLI) is audited and +//! watermarked without per-caller wiring. + +use rusqlite::{params, Transaction}; + +use crate::projects::types::WorkItemMutationActor; + +fn map_db(result: rusqlite::Result) -> Result { + result.map_err(|err| format!("pm audit: {}", err)) +} + +/// Bump the single-row cross-process change watermark and return the new +/// sequence value. Desktop hosts poll this cheaply (or watch the db file) +/// to learn that an external process — e.g. the PM CLI — committed a +/// mutation, then run incremental reconciliation. +pub(crate) fn bump_change_seq(tx: &Transaction<'_>) -> Result { + map_db(tx.execute( + "INSERT INTO pm_change_seq (id, seq) VALUES (1, 1) + ON CONFLICT(id) DO UPDATE SET seq = seq + 1", + [], + ))?; + map_db(tx.query_row("SELECT seq FROM pm_change_seq WHERE id = 1", [], |row| { + row.get(0) + })) +} + +pub(crate) struct AuditEventRow<'a> { + pub operation: &'a str, + pub entity_type: &'a str, + pub entity_id: &'a str, + pub project_slug: Option<&'a str>, + pub org_id: Option<&'a str>, + pub actor: Option<&'a WorkItemMutationActor>, + pub revision: i64, + pub seq: i64, + pub payload: serde_json::Value, +} + +/// Append one row to the append-only `pm_audit_events` table. +/// +/// `actor_kind` is reserved for the protocol ActorRef kind (human/agent/ +/// service/team) that arrives with the Phase 3 CLI context; the legacy +/// `WorkItemMutationActor` only carries id + display name. +pub(crate) fn append_audit_event( + tx: &Transaction<'_>, + event: &AuditEventRow<'_>, +) -> Result<(), String> { + let (actor_id, actor_name) = match event.actor { + Some(actor) => (Some(actor.id.as_str()), Some(actor.name.as_str())), + None => (None, None), + }; + let payload_json = serde_json::to_string(&event.payload) + .map_err(|err| format!("pm audit: serialize payload: {}", err))?; + map_db(tx.execute( + "INSERT INTO pm_audit_events ( + occurred_at, actor_kind, actor_id, actor_name, operation, entity_type, + entity_id, project_slug, org_id, revision, seq, payload_json + ) VALUES (?1, NULL, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + chrono::Utc::now().timestamp_millis(), + actor_id, + actor_name, + event.operation, + event.entity_type, + event.entity_id, + event.project_slug, + event.org_id, + event.revision, + event.seq, + payload_json, + ], + ))?; + Ok(()) +} diff --git a/src-tauri/crates/project-management/src/work_service/mod.rs b/src-tauri/crates/project-management/src/work_service/mod.rs new file mode 100644 index 000000000..e39b616c8 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_service/mod.rs @@ -0,0 +1,507 @@ +//! Work application service (`orgtrack/v1` Phase 2a). +//! +//! Single business layer above the atomic store choke point. Every entry +//! point (Tauri commands, agent tools, the future PM CLI, schedulers, +//! sync adapters) is expected to mutate work items through here — not by +//! assembling `WorkItemFrontmatter` rows directly. +//! +//! What lands in 2a: +//! - the portable [`state::WorkItemState`] FSM with legacy-status mapping; +//! - append-only audit + `pm_change_seq` watermark on EVERY atomic +//! mutation (wired inside `projects::io::work_items::atomic`); +//! - optimistic concurrency (`expected_revision` against `local_version`) +//! and strict-FSM transitions via [`transition_project_work_item`]. +//! +//! Error contract: typed sentinels with the `PM_ERR:` prefix +//! ([`error::REVISION_CONFLICT`], [`error::INVALID_TRANSITION`]) so the +//! Phase 3 CLI layer can map them onto the stable wire error codes +//! without string-guessing. Everything else is an opaque store error. + +pub mod audit; +pub mod state; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +/// Shared seeding helpers for sibling service test modules. +#[cfg(test)] +pub mod tests_support { + use crate::projects::io::write_project; + use crate::projects::types::ProjectMeta; + + pub fn seed_project(slug: &str, id: &str) { + let meta = ProjectMeta { + id: id.to_string(), + name: "Demo".to_string(), + org_id: "personal-org".to_string(), + status: "active".to_string(), + priority: "none".to_string(), + health: "no_updates".to_string(), + lead: None, + members: vec![], + labels: vec![], + linked_repos: vec![], + start_date: None, + target_date: None, + created_at: String::new(), + updated_at: String::new(), + next_work_item_id: 1, + work_item_prefix: "AAA".to_string(), + work_item_prefix_custom: true, + agent_defaults: None, + }; + write_project(slug, &meta, "", true).expect("seed project"); + } +} + +pub use state::WorkItemState; + +use crate::projects::io as project_io; +use crate::projects::types::{ + LinkedSession, OrchestratorConfig, TodoEntry, WorkItemData, WorkItemFrontmatter, + WorkItemHandoff, WorkItemMutationActor, WorkItemSchedule, +}; + +/// Typed error sentinels understood by upper layers. +pub mod error { + pub const PREFIX: &str = "PM_ERR:"; + 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 fn revision_conflict(expected: i64, current: i64) -> String { + format!("{}:{}:{}", REVISION_CONFLICT, expected, current) + } + + pub fn invalid_transition(from: &str, to: &str) -> String { + format!("{}:{}:{}", INVALID_TRANSITION, from, to) + } +} + +/// Outcome of an idempotency-guarded operation. +pub enum IdempotencyOutcome { + /// The operation executed now. + Fresh(serde_json::Value), + /// Same key + same canonical request seen before: the stored + /// response is returned and the operation did NOT run again. + Replayed(serde_json::Value), +} + +/// Idempotency guard over `(actor, operation, scope, key)` per the frozen +/// 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. +pub fn run_idempotent( + actor_id: &str, + operation: &str, + scope_id: &str, + key: &str, + canonical_request: &serde_json::Value, + execute: impl FnOnce() -> Result, +) -> Result { + 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}")), + })?; + + if let Some((stored_request, stored_response)) = existing { + if stored_request != canonical { + return Err(format!( + "{}:{}:{}", + error::IDEMPOTENCY_CONFLICT, + operation, + key + )); + } + let response = stored_response + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or(serde_json::Value::Null); + return Ok(IdempotencyOutcome::Replayed(response)); + } + + 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)) +} + +/// Strict, audited status transition for a project-scoped work item. +/// +/// This is the `work.transition` application operation from the frozen +/// contract: it validates the portable FSM (hard reject, not flag-only), +/// honors `expected_revision`, clears the execution lock when the target +/// maps to portable `open` (the release edge), and records the reason in +/// the audit payload. Lifecycle-only: non-lifecycle fields are patch +/// territory. +pub fn transition_project_work_item( + project_slug: &str, + short_id: &str, + to_status: &str, + reason: Option<&str>, + actor: Option<&WorkItemMutationActor>, + expected_revision: Option, +) -> Result { + let to_status_owned = to_status.to_string(); + let reason_owned = reason.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.transition"), + strict_fsm: true, + reason: reason_owned, + }, + move |frontmatter, _body| { + 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 { + // Release edge (§9.3): entering portable `open` clears the + // active execution claim so the item is re-claimable. + frontmatter.execution_lock = None; + } + Ok(()) + }, + )?; + project_io::read_work_item(project_slug, short_id) +} + +/// Creation DTO for the canonical `work.create` application operation. +/// +/// Deliberately NOT the 32-field `WorkItemFrontmatter`: callers describe +/// the work; the service owns row construction. Short-id allocation stays +/// with the caller because collab-synced orgs mint ids on the server +/// (design §16.5) and that allocator currently lives client-side. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateWorkItemRequest { + pub title: String, + #[serde(default)] + pub body: String, + pub project_id: Option, + pub status: Option, + pub priority: Option, + pub assignee: Option, + pub assignee_type: Option, + #[serde(default)] + pub labels: Vec, + pub milestone: Option, + pub parent: Option, + pub start_date: Option, + pub target_date: Option, + pub created_by: Option, + #[serde(default)] + pub starred: bool, + pub schedule: Option, + pub orchestrator_config: Option, + /// Optional parsed checklist written atomically with creation. + #[serde(default)] + pub todos: Vec, + /// Optional human handoff written atomically with initial assignment. + pub handoff: Option, + /// Durable session provenance written in the same operation. + #[serde(default)] + pub linked_sessions: Vec, +} + +fn build_frontmatter(short_id: &str, request: &CreateWorkItemRequest) -> WorkItemFrontmatter { + let now = chrono::Utc::now().to_rfc3339(); + WorkItemFrontmatter { + id: short_id.to_string(), + short_id: short_id.to_string(), + title: request.title.clone(), + project: request.project_id.clone(), + status: request + .status + .clone() + .unwrap_or_else(|| "backlog".to_string()), + priority: request + .priority + .clone() + .unwrap_or_else(|| "none".to_string()), + assignee: request.assignee.clone(), + assignee_type: request.assignee_type.clone(), + labels: request.labels.clone(), + milestone: request.milestone.clone(), + parent: request.parent.clone(), + start_date: request.start_date.clone(), + target_date: request.target_date.clone(), + created_by: request.created_by.clone(), + created_at: now.clone(), + updated_at: now, + deleted_at: None, + starred: request.starred, + todos: request.todos.clone(), + comments: vec![], + history: vec![], + delegations: vec![], + linked_sessions: request.linked_sessions.clone(), + handoff: request.handoff.clone(), + proof_of_work: None, + orchestrator_config: request.orchestrator_config.clone(), + orchestrator_state: None, + follow_up_items: vec![], + schedule: request.schedule.clone(), + routine_source: None, + execution_lock: None, + close_out: None, + work_products: vec![], + } +} + +/// 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( + 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)?; + audit::append_audit_event( + &tx, + &audit::AuditEventRow { + operation: "work.create", + entity_type: "work_item", + entity_id, + project_slug, + org_id, + actor, + revision: 0, + 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. +pub fn create_project_work_item( + project_slug: &str, + short_id: &str, + request: &CreateWorkItemRequest, + 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)?; + project_io::read_work_item(project_slug, short_id) +} + +/// Current OCC revision (`local_version`) of a project-scoped item — +/// surfaced through `work show` so callers can supply +/// `--expected-revision` on the next mutation. +pub fn read_project_work_item_revision( + project_slug: &str, + short_id: &str, +) -> Result { + let connection = project_io::helpers::conn()?; + connection + .query_row( + "SELECT w.local_version FROM workitems w + JOIN projects p ON p.id = w.project_id + WHERE p.slug = ?1 AND w.short_id = ?2", + rusqlite::params![project_slug, short_id], + |row| row.get(0), + ) + .map_err(|err| match err { + rusqlite::Error::QueryReturnedNoRows => { + format!("Work item '{}' not found", short_id) + } + other => format!("DB error: {}", other), + }) +} + +/// Canonical `work.note` (`work.update.append`): append-only comment on +/// the item, audited under its own operation label. +pub fn note_project_work_item( + project_slug: &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 { + // Portable note kinds (comment|progress|blocker|decision|handoff| + // review) ride in the comment text until comments grow a kind + // column; the audit payload carries the kind losslessly. + format!("[{}] {}", kind, body) + }; + let reason = Some(kind.to_string()); + let body_owned = note_body; + project_io::update_work_item_atomic_serviced( + project_slug, + short_id, + actor, + project_io::AtomicServiceOptions { + operation: Some("work.note"), + reason, + ..Default::default() + }, + 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: body_owned, + created_at: now, + mentioned_user_ids: vec![], + }); + Ok(()) + }, + ) +} + +const PORTABLE_RELATION_KINDS: &[&str] = &[ + "depends_on", + "relates_to", + "duplicates", + "implements", + "supersedes", + "continued_by", + "generated_by", + "participated_in", +]; + +/// Canonical `work.relate` (`work.relation.add`): typed semantic edge in +/// the `pm_relations` table, audited + watermarked in one transaction. +pub fn relate_project_work_item( + project_slug: &str, + short_id: &str, + kind: &str, + target_ref: &str, + actor: Option<&WorkItemMutationActor>, +) -> Result<(), String> { + if !PORTABLE_RELATION_KINDS.contains(&kind) { + return Err(format!( + "{}:relation kind '{}' is not portable", + error::PREFIX, kind + )); + } + // Existence check outside the tx (short id is scope-stable). + let _ = read_project_work_item_revision(project_slug, short_id)?; + let mut connection = project_io::helpers::conn()?; + let tx = connection + .transaction() + .map_err(|err| format!("pm relate tx: {}", err))?; + 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![ + short_id, + kind, + target_ref, + chrono::Utc::now().timestamp_millis(), + actor.map(|a| a.id.as_str()), + ], + ) + .map_err(|err| format!("pm relate: {}", err))?; + let seq = audit::bump_change_seq(&tx)?; + audit::append_audit_event( + &tx, + &audit::AuditEventRow { + operation: "work.relate", + entity_type: "work_item", + entity_id: short_id, + project_slug: Some(project_slug), + org_id: None, + actor, + revision: 0, + seq, + payload: serde_json::json!({ "kind": kind, "targetRef": target_ref }), + }, + )?; + tx.commit().map_err(|err| format!("pm relate commit: {}", err)) +} + +/// Read the typed relations of a project-scoped item. +pub fn list_work_item_relations(short_id: &str) -> Result, String> { + let connection = project_io::helpers::conn()?; + let mut statement = connection + .prepare( + "SELECT kind, target_ref, created_at FROM pm_relations + WHERE entity_type = 'work_item' AND entity_id = ?1 + ORDER BY id", + ) + .map_err(|err| format!("pm relations: {}", err))?; + let rows = statement + .query_map(rusqlite::params![short_id], |row| { + Ok(serde_json::json!({ + "kind": row.get::<_, String>(0)?, + "targetRef": row.get::<_, String>(1)?, + "createdAt": row.get::<_, i64>(2)?, + })) + }) + .map_err(|err| format!("pm relations: {}", err))? + .collect::, _>>() + .map_err(|err| format!("pm relations: {}", err))?; + Ok(rows) +} + +/// Canonical `work.create` for an org-scoped standalone item. +pub fn create_standalone_work_item( + org_id: Option<&str>, + short_id: &str, + request: &CreateWorkItemRequest, + actor: Option<&WorkItemMutationActor>, +) -> Result { + 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)?; + project_io::read_standalone_work_item(org_id, short_id) +} diff --git a/src-tauri/crates/project-management/src/work_service/state.rs b/src-tauri/crates/project-management/src/work_service/state.rs new file mode 100644 index 000000000..1b9104fa5 --- /dev/null +++ b/src-tauri/crates/project-management/src/work_service/state.rs @@ -0,0 +1,186 @@ +//! Portable WorkItem state machine (`orgtrack/v1` design §9.3). +//! +//! The store still persists the legacy status vocabulary +//! (`backlog`/`planned`/`in_progress`/`in_review`/…) until the UI and CLI +//! switch to the portable states; this module owns the mapping and the +//! transition legality matrix so every mutation path validates against ONE +//! source of truth. Strict enforcement is opt-in per call site +//! (`AtomicServiceOptions::strict_fsm`) — legacy UI paths run in flag-only +//! mode until Phase 7 flips them. + +/// Portable states from the frozen v1 contract +/// (`docs/orgtrack-pm-protocol/schemas/common.schema.json`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkItemState { + Open, + InProgress, + Blocked, + Completed, + Failed, + Cancelled, +} + +impl WorkItemState { + pub fn as_str(self) -> &'static str { + match self { + WorkItemState::Open => "open", + WorkItemState::InProgress => "in_progress", + WorkItemState::Blocked => "blocked", + WorkItemState::Completed => "completed", + WorkItemState::Failed => "failed", + WorkItemState::Cancelled => "cancelled", + } + } + + pub fn parse(raw: &str) -> Option { + match raw { + "open" => Some(WorkItemState::Open), + "in_progress" => Some(WorkItemState::InProgress), + "blocked" => Some(WorkItemState::Blocked), + "completed" => Some(WorkItemState::Completed), + "failed" => Some(WorkItemState::Failed), + "cancelled" => Some(WorkItemState::Cancelled), + _ => None, + } + } +} + +/// Map a legacy store status onto the portable state it represents. +/// +/// Returns `None` for statuses with no portable meaning (custom user +/// schemes); those bypass FSM validation entirely rather than guessing. +pub fn map_legacy_status(raw: &str) -> Option { + match raw { + "open" | "backlog" | "planned" => Some(WorkItemState::Open), + "in_progress" | "in_review" => Some(WorkItemState::InProgress), + "blocked" => Some(WorkItemState::Blocked), + "completed" | "done" | "closed" => Some(WorkItemState::Completed), + "failed" => Some(WorkItemState::Failed), + "cancelled" | "canceled" | "duplicate" => Some(WorkItemState::Cancelled), + _ => None, + } +} + +/// Transition legality per design §9.3, including the explicit +/// `in_progress -> open` release edge and the reopen edges. +pub fn is_transition_allowed(from: WorkItemState, to: WorkItemState) -> bool { + use WorkItemState::*; + match (from, to) { + (Open, InProgress) | (Open, Cancelled) => true, + (InProgress, Open) + | (InProgress, Blocked) + | (InProgress, Completed) + | (InProgress, Failed) + | (InProgress, Cancelled) => true, + (Blocked, Open) | (Blocked, InProgress) | (Blocked, Cancelled) => true, + (Completed, Open) => true, + (Failed, Open) | (Failed, Cancelled) => true, + (Cancelled, Open) => true, + _ => false, + } +} + +/// Validate a legacy-status change against the portable FSM. +/// +/// Relabels within the same portable state (e.g. `backlog -> planned`, +/// `in_progress -> in_review`) are always legal. Changes involving an +/// unmapped custom status are permitted (no portable semantics to +/// enforce). Everything else must be an allowed portable edge. +pub fn validate_legacy_transition(from_raw: &str, to_raw: &str) -> Result<(), String> { + let (Some(from), Some(to)) = (map_legacy_status(from_raw), map_legacy_status(to_raw)) else { + return Ok(()); + }; + if from == to { + return Ok(()); + } + if is_transition_allowed(from, to) { + return Ok(()); + } + Err(format!( + "portable FSM forbids {} -> {} (mapped from '{}' -> '{}')", + from.as_str(), + to.as_str(), + from_raw, + to_raw + )) +} + +#[cfg(test)] +mod tests { + use super::WorkItemState::*; + use super::*; + + const ALL: [WorkItemState; 6] = [Open, InProgress, Blocked, Completed, Failed, Cancelled]; + + #[test] + fn transition_matrix_matches_contract() { + let allowed: &[(WorkItemState, WorkItemState)] = &[ + (Open, InProgress), + (Open, Cancelled), + (InProgress, Open), + (InProgress, Blocked), + (InProgress, Completed), + (InProgress, Failed), + (InProgress, Cancelled), + (Blocked, Open), + (Blocked, InProgress), + (Blocked, Cancelled), + (Completed, Open), + (Failed, Open), + (Failed, Cancelled), + (Cancelled, Open), + ]; + for from in ALL { + for to in ALL { + let expected = allowed.contains(&(from, to)); + assert_eq!( + is_transition_allowed(from, to), + expected, + "{:?} -> {:?}", + from, + to + ); + } + } + } + + #[test] + fn no_self_transitions() { + for state in ALL { + assert!(!is_transition_allowed(state, state), "{:?}", state); + } + } + + #[test] + fn legacy_relabel_within_same_portable_state_is_legal() { + assert!(validate_legacy_transition("backlog", "planned").is_ok()); + assert!(validate_legacy_transition("in_progress", "in_review").is_ok()); + } + + #[test] + fn legacy_reopen_is_legal() { + assert!(validate_legacy_transition("completed", "backlog").is_ok()); + assert!(validate_legacy_transition("cancelled", "planned").is_ok()); + } + + #[test] + fn legacy_skip_to_completed_is_flagged() { + // backlog (open) -> completed skips the claim/in_progress edge. + assert!(validate_legacy_transition("backlog", "completed").is_err()); + assert!(validate_legacy_transition("completed", "in_review").is_err()); + } + + #[test] + fn unmapped_custom_statuses_bypass_validation() { + assert!(validate_legacy_transition("triage", "completed").is_ok()); + assert!(validate_legacy_transition("backlog", "someday").is_ok()); + } + + #[test] + fn parse_round_trips() { + for state in ALL { + assert_eq!(WorkItemState::parse(state.as_str()), Some(state)); + } + assert_eq!(WorkItemState::parse("in_review"), None); + } +} diff --git a/src-tauri/crates/project-management/src/work_service/tests.rs b/src-tauri/crates/project-management/src/work_service/tests.rs new file mode 100644 index 000000000..86e9bd5cf --- /dev/null +++ b/src-tauri/crates/project-management/src/work_service/tests.rs @@ -0,0 +1,223 @@ +//! Integration tests for the work application service (Phase 2a): +//! strict FSM transitions, optimistic concurrency, and the audit + +//! `pm_change_seq` trail emitted by the atomic choke point. + +use super::*; +use crate::projects::io::helpers::conn; +use crate::projects::io::{ + acquire_execution_lock, read_work_item, update_work_item_partial, write_project, + write_work_item, +}; +use crate::projects::types::{ + ProjectMeta, WorkItemExecutionLockReason, WorkItemFrontmatter, WorkItemPartialUpdate, +}; +use test_helpers::test_env; + +fn project_fixture(id: &str, name: &str) -> ProjectMeta { + ProjectMeta { + id: id.to_string(), + name: name.to_string(), + org_id: "personal-org".to_string(), + status: "active".to_string(), + priority: "none".to_string(), + health: "no_updates".to_string(), + lead: None, + members: vec![], + labels: vec![], + linked_repos: vec![], + start_date: None, + target_date: None, + created_at: String::new(), + updated_at: String::new(), + next_work_item_id: 1, + work_item_prefix: "AAA".to_string(), + work_item_prefix_custom: true, + agent_defaults: None, + } +} + +fn work_item_fixture(id: &str, short_id: &str, title: &str) -> WorkItemFrontmatter { + WorkItemFrontmatter { + id: id.to_string(), + short_id: short_id.to_string(), + title: title.to_string(), + project: None, + status: "backlog".to_string(), + priority: "none".to_string(), + assignee: None, + assignee_type: None, + labels: vec![], + milestone: None, + parent: None, + start_date: None, + target_date: None, + created_by: None, + created_at: String::new(), + updated_at: String::new(), + deleted_at: None, + starred: false, + todos: vec![], + comments: vec![], + history: vec![], + delegations: vec![], + linked_sessions: vec![], + handoff: None, + proof_of_work: None, + orchestrator_config: None, + orchestrator_state: None, + follow_up_items: vec![], + schedule: None, + routine_source: None, + execution_lock: None, + close_out: None, + work_products: vec![], + } +} + +fn seed(slug: &str, project_id: &str) { + write_project(slug, &project_fixture(project_id, "Demo"), "", true).expect("project"); + let fm = work_item_fixture("w1", "AAA-0001", "Initial"); + write_work_item(slug, "AAA-0001", &fm, "body v1").expect("seed work item"); +} + +fn change_seq() -> i64 { + conn() + .expect("conn") + .query_row("SELECT seq FROM pm_change_seq WHERE id = 1", [], |row| { + row.get(0) + }) + .expect("pm_change_seq row") +} + +fn last_audit_row() -> (String, i64, String) { + conn() + .expect("conn") + .query_row( + "SELECT operation, revision, payload_json FROM pm_audit_events + ORDER BY id DESC LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("audit row") +} + +#[test] +fn strict_transition_rejects_illegal_portable_edge() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + + // backlog maps to open; open -> completed skips the claim edge. + let err = transition_project_work_item("demo", "AAA-0001", "completed", None, None, None) + .expect_err("must reject"); + assert!( + err.starts_with(error::INVALID_TRANSITION), + "unexpected error: {err}" + ); + + let unchanged = read_work_item("demo", "AAA-0001").expect("read"); + assert_eq!(unchanged.frontmatter.status, "backlog"); +} + +#[test] +fn strict_transition_applies_and_audits_legal_edge() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + let seq_before = change_seq(); + + let data = transition_project_work_item( + "demo", + "AAA-0001", + "in_progress", + Some("starting work"), + None, + Some(0), + ) + .expect("legal transition"); + assert_eq!(data.frontmatter.status, "in_progress"); + + assert_eq!(change_seq(), seq_before + 1, "watermark bumps per mutation"); + let (operation, revision, payload_json) = last_audit_row(); + assert_eq!(operation, "work.transition"); + assert_eq!(revision, 1); + let payload: serde_json::Value = serde_json::from_str(&payload_json).expect("payload json"); + assert_eq!(payload["status_from"], "backlog"); + assert_eq!(payload["status_to"], "in_progress"); + assert_eq!(payload["reason"], "starting work"); + assert!(payload.get("fsm_violation").is_none()); +} + +#[test] +fn expected_revision_mismatch_is_a_typed_conflict() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + + let err = transition_project_work_item("demo", "AAA-0001", "in_progress", None, None, Some(7)) + .expect_err("stale revision must conflict"); + assert!( + err.starts_with(error::REVISION_CONFLICT), + "unexpected error: {err}" + ); + assert!(err.ends_with(":7:0"), "carries expected/current: {err}"); + + let unchanged = read_work_item("demo", "AAA-0001").expect("read"); + assert_eq!(unchanged.frontmatter.status, "backlog"); +} + +#[test] +fn release_to_open_clears_execution_lock() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + + transition_project_work_item("demo", "AAA-0001", "in_progress", None, None, None) + .expect("claim edge"); + acquire_execution_lock( + "demo", + "AAA-0001", + "session-1", + Some("coding"), + WorkItemExecutionLockReason::ManualStart, + ) + .expect("lock"); + let locked = read_work_item("demo", "AAA-0001").expect("read"); + assert!(locked.frontmatter.execution_lock.is_some()); + + // in_progress -> backlog maps to the in_progress -> open release edge. + let released = transition_project_work_item( + "demo", + "AAA-0001", + "backlog", + Some("agent died"), + None, + None, + ) + .expect("release"); + assert_eq!(released.frontmatter.status, "backlog"); + assert!( + released.frontmatter.execution_lock.is_none(), + "release edge must clear the claim record" + ); +} + +#[test] +fn legacy_paths_flag_violations_without_blocking() { + let _sandbox = test_env::sandbox(); + seed("demo", "p1"); + + // The legacy partial-update path (UI board drag) skips the claim + // edge; it must keep working but leave an audited violation flag. + let updates = WorkItemPartialUpdate { + status: Some("completed".to_string()), + ..Default::default() + }; + update_work_item_partial("demo", "AAA-0001", &updates).expect("legacy path stays fail-open"); + + let after = read_work_item("demo", "AAA-0001").expect("read"); + assert_eq!(after.frontmatter.status, "completed"); + let (operation, _, payload_json) = last_audit_row(); + assert_eq!(operation, "work.patch"); + let payload: serde_json::Value = serde_json::from_str(&payload_json).expect("payload json"); + assert!( + payload.get("fsm_violation").is_some(), + "violation must be visible in the audit stream: {payload}" + ); +} diff --git a/src-tauri/src/agent_sessions/session_directory/aggregation.rs b/src-tauri/src/agent_sessions/session_directory/aggregation.rs index b3eee224b..4aa38a18a 100644 --- a/src-tauri/src/agent_sessions/session_directory/aggregation.rs +++ b/src-tauri/src/agent_sessions/session_directory/aggregation.rs @@ -1462,6 +1462,7 @@ mod tests { agent_icon_id: None, agent_display_name: None, agent_exec_mode: None, + product_mode: None, draft_text: None, reply_target_event_id: None, pinned: false, diff --git a/src-tauri/src/agent_sessions/session_directory/conversion.rs b/src-tauri/src/agent_sessions/session_directory/conversion.rs index db4f34ab0..82d5ebd4e 100644 --- a/src-tauri/src/agent_sessions/session_directory/conversion.rs +++ b/src-tauri/src/agent_sessions/session_directory/conversion.rs @@ -164,6 +164,9 @@ 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, draft_text: session.draft_text, reply_target_event_id: session.reply_target_event_id, pinned: session.pinned, @@ -240,6 +243,7 @@ pub fn imported_history_to_aggregate_record( agent_icon_id: None, agent_display_name: Some(source_label.to_string()), agent_exec_mode: None, + product_mode: None, draft_text: None, reply_target_event_id: None, pinned: false, @@ -298,6 +302,7 @@ pub fn cursor_ide_history_to_aggregate_record( agent_icon_id: None, agent_display_name: Some(source_label.to_string()), agent_exec_mode: None, + product_mode: None, draft_text: None, reply_target_event_id: None, pinned: false, @@ -373,6 +378,7 @@ pub fn sde_session_to_aggregate_record( agent_icon_id, agent_display_name, agent_exec_mode: session.agent_exec_mode, + product_mode: session.product_mode, draft_text: session.draft_text, reply_target_event_id: session.reply_target_event_id, pinned: session.pinned, @@ -441,6 +447,7 @@ pub fn os_session_to_aggregate_record( agent_icon_id, agent_display_name, agent_exec_mode: session.agent_exec_mode, + product_mode: session.product_mode, draft_text: session.draft_text, reply_target_event_id: session.reply_target_event_id, pinned: session.pinned, @@ -509,6 +516,7 @@ pub fn human_session_to_aggregate_record( agent_icon_id: Some("clipboard-list".to_string()), agent_display_name: Some("Human".to_string()), agent_exec_mode: None, + product_mode: None, draft_text: None, reply_target_event_id: None, pinned: session.pinned, diff --git a/src-tauri/src/agent_sessions/session_directory/display.rs b/src-tauri/src/agent_sessions/session_directory/display.rs index 0557ed237..56e562f32 100644 --- a/src-tauri/src/agent_sessions/session_directory/display.rs +++ b/src-tauri/src/agent_sessions/session_directory/display.rs @@ -166,6 +166,7 @@ mod tests { agent_icon_id: None, agent_display_name: None, agent_exec_mode: None, + product_mode: None, draft_text: None, reply_target_event_id: None, pinned: false, diff --git a/src-tauri/src/agent_sessions/session_directory/patch.rs b/src-tauri/src/agent_sessions/session_directory/patch.rs index 9b22670d8..3609e77ea 100644 --- a/src-tauri/src/agent_sessions/session_directory/patch.rs +++ b/src-tauri/src/agent_sessions/session_directory/patch.rs @@ -101,6 +101,11 @@ pub struct SessionPatch { /// Per-session execution mode. Only legal for `agent_sessions` /// rows; rejected for CLI sessions. pub agent_exec_mode: Option, + /// Persistent product mode (`orgtrack/v1` §5.2): + /// `build | plan | ask | project`. Only legal for `agent_sessions` + /// rows; validated against the closed enum. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub product_mode: Option, /// Per-session unsent draft text (P3). Three-state — see the /// "three-state fields" section in module docs. #[serde( @@ -223,6 +228,7 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), if patch.name.is_none() && patch.model.is_none() && patch.agent_exec_mode.is_none() + && patch.product_mode.is_none() && patch.draft_text.is_none() && patch.reply_target_event_id.is_none() && patch.pinned.is_none() @@ -298,6 +304,27 @@ pub fn apply_session_patch(session_id: &str, patch: &SessionPatch) -> Result<(), } } + if let Some(product_mode) = patch.product_mode.as_deref() { + // Closed enum (orgtrack/v1 §5.2); a typo must not silently + // grant or drop the Project mutation surface. + if !matches!(product_mode, "build" | "plan" | "ask" | "project") { + return Err(format!( + "session_patch: unknown product_mode '{product_mode}' (expected build|plan|ask|project)" + )); + } + match location { + SessionLocation::Agent => { + session_persistence::update_product_mode(session_id, product_mode) + .map_err(|err| format!("session_patch update product_mode (agent): {err}"))?; + } + SessionLocation::Cli | SessionLocation::Imported => { + return Err( + "session_patch: only agent sessions carry a product_mode".to_string() + ); + } + } + } + // Three-state writes (P3). The outer `Option` tells us whether the // frontend touched the field at all; the inner `Option` carries the // actual value (`None` → SQL NULL = clear). We route to whichever @@ -403,6 +430,7 @@ pub async fn session_patch( patch: SessionPatch, ) -> Result<(), String> { let identity_changed = patch.model.is_some(); + let switched_to_project = patch.product_mode.as_deref() == Some("project"); let renamed = patch .name .as_deref() @@ -425,6 +453,27 @@ pub async fn session_patch( if identity_changed { state.invalidate_session(&patched_session_id).await; } + if switched_to_project { + // Convert to Project (orgtrack/v1 §7.2): entering the Project + // product mode must invalidate Plan mode's snapshot/restore + // state, otherwise the pending-approval restore path would + // bounce a later turn back to the pre-Plan exec mode. + if let Some(session) = state.get_session(&patched_session_id).await { + let had_slot = session.plan_slot_cache.get(&patched_session_id).is_some(); + let _ = session.pre_plan_mode_cache.take(&patched_session_id); + session.plan_slot_cache.clear(&patched_session_id); + if had_slot { + agent_core::bus::broadcast_event( + "agent:exit_plan_mode", + serde_json::json!({ + "sessionId": &patched_session_id, + "source": "convert_to_project", + "nextMode": agent_core::session::AgentExecMode::Build.as_str(), + }), + ); + } + } + } if let Some(name) = renamed.as_deref() { agent_core::lifecycle::emit_session_renamed( state.app_handle.as_ref(), diff --git a/src-tauri/src/agent_sessions/session_directory/types.rs b/src-tauri/src/agent_sessions/session_directory/types.rs index a98952bd5..accb343ee 100644 --- a/src-tauri/src/agent_sessions/session_directory/types.rs +++ b/src-tauri/src/agent_sessions/session_directory/types.rs @@ -136,6 +136,11 @@ pub struct SessionAggregateRecord { /// commits a value. #[serde(skip_serializing_if = "Option::is_none")] pub agent_exec_mode: Option, + /// Persistent product mode (`orgtrack/v1` §5.2): + /// `build | plan | ask | project`. Source of truth for whether the + /// session may mutate WorkItems/Routines. `None` = build. + #[serde(skip_serializing_if = "Option::is_none")] + pub product_mode: Option, /// Per-session unsent draft text. The contents the user has /// typed into the chat composer for this session but not yet sent. /// Persisted across navigation and app restarts. `None` means "no diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index 90f3a6c91..d4f241aa1 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -310,6 +310,7 @@ pub async fn test_agent_org_launch_coordinator( apply_agent_org_member_overrides_for_future: false, isolate: false, mode: None, + product_mode: None, org_id: None, project_id: None, project_name: None, diff --git a/src-tauri/src/api/agent/test/workspace.rs b/src-tauri/src/api/agent/test/workspace.rs index 566d2d1e0..d6a55019d 100644 --- a/src-tauri/src/api/agent/test/workspace.rs +++ b/src-tauri/src/api/agent/test/workspace.rs @@ -294,6 +294,7 @@ pub async fn test_session_launch_seed_only( apply_agent_org_member_overrides_for_future: false, isolate: false, mode, + product_mode: None, org_id: None, project_id: None, project_name: None, diff --git a/src-tauri/src/benchmark/launch.rs b/src-tauri/src/benchmark/launch.rs index 541532a47..82279a34c 100644 --- a/src-tauri/src/benchmark/launch.rs +++ b/src-tauri/src/benchmark/launch.rs @@ -42,6 +42,7 @@ pub(super) fn benchmark_launch_params( .apply_agent_org_member_overrides_for_future, isolate: launch.isolate, mode: launch.mode.clone(), + product_mode: None, org_id: None, project_id: None, project_name: None, diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index e00bdc350..b8b4ec105 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -593,6 +593,9 @@ project_management::projects::commands::work_item_write_standalone_item, project_management::projects::commands::project_delete_work_item, project_management::projects::commands::project_restore_work_item, project_management::projects::commands::project_purge_expired_deleted_work_items, +project_management::projects::commands::project_create_work_item, +project_management::projects::commands::work_item_create_standalone, +project_management::projects::commands::project_transition_work_item, project_management::projects::commands::project_update_work_item_partial, project_management::projects::commands::work_item_update_standalone_partial, project_management::projects::commands::project_transition_work_item_handoff, @@ -613,6 +616,8 @@ project_management::projects::commands::project_read_routine, project_management::projects::commands::project_upsert_routine, project_management::projects::commands::project_delete_routine, project_management::projects::commands::project_list_routine_fires, +project_management::projects::commands::project_list_routine_runs, +project_management::projects::commands::project_routine_run_status, agent_core::state::commands::project_fire_routine, project_management::projects::commands::project_save_asset, project_management::projects::commands::project_delete_asset, @@ -939,13 +944,6 @@ agent_core::state::commands::session::org_tasks::agent_org_send_group_chat_messa agent_core::state::commands::session::org_tasks::agent_org_send_user_message_to_member, agent_core::state::commands::session::org_tasks::agent_org_pause_run, agent_core::state::commands::session::org_tasks::agent_org_resume_run, -// Automation (trigger->action rules) -agent_core::state::commands::automation::agent_automation_list_rules, -agent_core::state::commands::automation::agent_automation_add_rule, -agent_core::state::commands::automation::agent_automation_update_rule, -agent_core::state::commands::automation::agent_automation_remove_rule, -agent_core::state::commands::automation::agent_automation_get_status, -agent_core::state::commands::automation::agent_automation_fire_webhook, agent_core::specialization::policies::policies_list, agent_core::specialization::policies::policies_read, agent_core::specialization::policies::policies_create, @@ -1219,6 +1217,7 @@ agent_core::state::commands::agent_check_snapshot_changes, agent_core::state::commands::agent_update_session_status, agent_core::state::commands::agent_save_session, agent_core::state::commands::agent_link_session_to_work_item, +agent_core::state::commands::agent_track_session_as_project, agent_core::state::commands::agent_get_session_workspace_path, // Unified Agent Interaction commands agent_core::state::commands::agent_question_response, @@ -1248,7 +1247,6 @@ agent_core::state::commands::agent_restore_snapshot, agent_core::state::commands::agent_revert_file_review, agent_core::state::commands::agent_revert_file, agent_core::state::commands::agent_get_todos, -agent_core::state::commands::agent_list_modes, agent_core::state::commands::agent_resolve_review, agent_core::state::commands::agent_save_file_resolution, agent_core::state::commands::agent_get_file_resolutions, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55ff73d94..544b86398 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -771,11 +771,75 @@ pub fn run() { err ), } + // Orgtrack migration: convert legacy RoutineDefinitions + // into portable pm_routines specs. Converted legacy rows + // are disabled in the same pass so the legacy scheduler + // can never double-fire them; the written report lands + // next to the store for the operator. + match tokio::task::spawn_blocking(|| { + project_management::routine_service::convert::convert_all(true) + }) + .await + { + Ok(Ok(report)) => { + if !report.converted.is_empty() || !report.skipped.is_empty() { + tracing::info!( + "[routine-migration] converted {} legacy routines, skipped {}", + report.converted.len(), + report.skipped.len() + ); + let path = app_paths::orgii_root() + .join("routine-conversion-report.json"); + if let Ok(raw) = serde_json::to_string_pretty(&report) { + let _ = std::fs::write(path, raw); + } + } + } + Ok(Err(err)) => tracing::warn!( + "[routine-migration] legacy routine conversion failed: {}", + err + ), + Err(err) => tracing::warn!( + "[routine-migration] conversion join error: {}", + err + ), + } agent_core::coordination::routine_scheduler::spawn(routine_handle); tracing::info!("[scheduler] Routine scheduler started"); }); } + // Cross-process PM change watermark poller: external writers + // (the org2 PM CLI) bump pm_change_seq inside every mutation + // transaction; the desktop notices via this cheap single-row + // poll and refreshes the UI (design 13.0). + { + let watermark_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + use tauri::Emitter; + let mut last_seq: i64 = -1; + loop { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let seq = tokio::task::spawn_blocking( + project_management::projects::io::read_pm_change_seq, + ) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(-1); + if seq >= 0 && last_seq >= 0 && seq != last_seq { + let _ = watermark_handle.emit( + project_management::projects::events::DATA_CHANGED_EVENT, + serde_json::json!({ "source": "pm-watermark" }), + ); + } + if seq >= 0 { + last_seq = seq; + } + } + }); + } + // Spawn pluggable sync worker. Drains `outbox_entries` // rows on the configured push tick and runs a pull cycle // on the longer pull tick. The AppHandle is stashed via diff --git a/src/api/http/project/client.ts b/src/api/http/project/client.ts index 59aa882ec..8bde180a9 100644 --- a/src/api/http/project/client.ts +++ b/src/api/http/project/client.ts @@ -457,6 +457,67 @@ export async function readStandaloneWorkItem( }); } +/** + * Creation DTO for the canonical `work.create` service operation. + * Mirrors Rust `work_service::CreateWorkItemRequest` (camelCase wire). + */ +export interface WorkItemCreateRequest { + title: string; + body?: string; + projectId?: string; + status?: string; + priority?: string; + assignee?: string; + assigneeType?: string; + labels?: string[]; + milestone?: string; + parent?: string; + startDate?: string; + targetDate?: string; + createdBy?: string; + starred?: boolean; + schedule?: WorkItemFrontmatter["schedule"]; + orchestratorConfig?: WorkItemFrontmatter["orchestrator_config"]; + todos?: WorkItemFrontmatter["todos"]; + handoff?: WorkItemFrontmatter["handoff"]; + linkedSessions?: WorkItemFrontmatter["linked_sessions"]; +} + +/** + * Canonical `work.create`: the service owns frontmatter construction; + * callers describe the work and supply a pre-allocated short id (collab + * orgs mint ids server-side). Prefer this over `writeWorkItem` for new + * items — the whole-row write is reserved for sync/merge internals. + */ +export async function createWorkItem( + projectSlug: string, + shortId: string, + request: WorkItemCreateRequest +): Promise { + const result = await invoke("project_create_work_item", { + projectSlug, + shortId, + request, + }); + invalidateCache(); + return result; +} + +/** Canonical `work.create` for an org-scoped standalone item. */ +export async function createStandaloneWorkItem( + shortId: string, + request: WorkItemCreateRequest, + options?: ProjectScopeOptions +): Promise { + const result = await invoke("work_item_create_standalone", { + ...scopeInvokePayload(options), + shortId, + request, + }); + invalidateCache(); + return result; +} + export async function writeWorkItem( projectSlug: string, shortId: string, @@ -692,6 +753,54 @@ export async function listRoutineFires( ); } +/** A row from `pm_routine_runs` (portable Routine domain, orgtrack/v1). */ +export interface RoutineRunSummary { + id: string; + routineName: string; + routineRevision: number; + scopeId: string; + status: string; + rootWorkItemId?: string | null; + createdBy?: string | null; + createdAt: number; + updatedAt: number; +} + +/** Per-run projection: run row + generated WorkItems' portable states. */ +export interface RoutineRunStatus { + id: string; + routineName: string; + routineRevision: number; + snapshotHash: string; + scopeId: string; + status: string; + rootWorkItemId?: string | null; + workItems: Array<{ + shortId: string; + title: string; + status: string; + portableState?: string | null; + }>; +} + +/** List portable routine runs, newest first. Uncached: run status moves + * with work-item transitions, and the surface refetches on focus. */ +export async function listRoutineRuns(options?: { + scopeId?: string; + limit?: number; +}): Promise { + return invoke("project_list_routine_runs", { + scopeId: options?.scopeId ?? null, + limit: options?.limit, + }); +} + +export async function routineRunStatus( + runId: string +): Promise { + return invoke("project_routine_run_status", { runId }); +} + export async function fireRoutine( routineId: string ): Promise { diff --git a/src/api/http/project/index.ts b/src/api/http/project/index.ts index 1e512ddf3..0ca4bc3b3 100644 --- a/src/api/http/project/index.ts +++ b/src/api/http/project/index.ts @@ -18,6 +18,8 @@ import * as client from "./client"; export * from "./types"; export type { ProjectScopeOptions, + RoutineRunStatus, + RoutineRunSummary, WorkItemReadBucket, WorkItemsReadOptions, WorkItemsViewOptions, @@ -86,6 +88,8 @@ export const projectApi = { readWorkItemsEnriched: client.readWorkItemsEnriched, readWorkspaceWorkItemsData: client.readWorkspaceWorkItemsData, readWorkItemsViewData: client.readWorkItemsViewData, + createWorkItem: client.createWorkItem, + createStandaloneWorkItem: client.createStandaloneWorkItem, writeWorkItem: client.writeWorkItem, writeStandaloneWorkItem: client.writeStandaloneWorkItem, deleteWorkItem: client.deleteWorkItem, @@ -106,6 +110,8 @@ export const projectApi = { deleteRoutine: client.deleteRoutine, listRoutineFires: client.listRoutineFires, fireRoutine: client.fireRoutine, + listRoutineRuns: client.listRoutineRuns, + routineRunStatus: client.routineRunStatus, // Batch batchDeleteWorkItems: client.batchDeleteWorkItems, batchUpdateWorkItems: client.batchUpdateWorkItems, diff --git a/src/api/tauri/agent/automation.ts b/src/api/tauri/agent/automation.ts index 2467ee993..5b502a787 100644 --- a/src/api/tauri/agent/automation.ts +++ b/src/api/tauri/agent/automation.ts @@ -1,12 +1,14 @@ /** - * Agent Automation API + * Agent desktop-permission and desktop-config API. * - * Desktop permissions, automation rules, and webhook triggers. + * The automation-rule wrappers that used to live here (list/add/update/ + * remove rule, engine status, webhook fire) were removed in Phase 1 of the + * Orgtrack PM protocol migration — they had no production caller and their + * TS payload shape was incompatible with the Rust `AutomationRule` schema. */ -import type { AutomationStatus } from "@src/modules/MainApp/Integrations/RulesMemoryEvolution/types"; import { invokeTauri } from "@src/util/platform/tauri/init"; -import type { AutomationRule, DesktopPermission } from "./types"; +import type { DesktopPermission } from "./types"; export async function checkDesktopPermissions(): Promise { return invokeTauri("agent_check_desktop_permissions"); @@ -21,38 +23,6 @@ export async function requestDesktopPermissions( ); } -export async function listAutomationRules(): Promise { - return invokeTauri("agent_automation_list_rules"); -} - -export async function getAutomationStatus(): Promise { - return invokeTauri("agent_automation_get_status"); -} - -export async function addAutomationRule( - rule: Omit -): Promise { - const ruleJson = JSON.stringify(rule); - return invokeTauri("agent_automation_add_rule", { ruleJson }); -} - -export async function updateAutomationRule( - rule: AutomationRule -): Promise { - const ruleJson = JSON.stringify(rule); - return invokeTauri("agent_automation_update_rule", { ruleJson }); -} - -export async function removeAutomationRule(ruleId: string): Promise { - return invokeTauri("agent_automation_remove_rule", { ruleId }); -} - -export async function fireAutomationWebhook(route: string): Promise { - return invokeTauri("agent_automation_fire_webhook", { - route, - }); -} - // ── Desktop sub-gates ─────────────────────────────────────────────── export interface DesktopConfig { diff --git a/src/api/tauri/agent/session.ts b/src/api/tauri/agent/session.ts index 7bbaa31e0..843828ff2 100644 --- a/src/api/tauri/agent/session.ts +++ b/src/api/tauri/agent/session.ts @@ -12,7 +12,6 @@ import type { WorkspaceSnapshot } from "@src/services/context/workspaceSnapshot" import type { SessionStatus } from "@src/types/session/session"; import type { - AgentExecModeConfig, AgentStatusInfo, DeleteSessionReceipt, FileResolution, @@ -188,6 +187,18 @@ export async function linkSessionToWorkItem(input: { return rpc.agentSession.linkSessionToWorkItem(input); } +/** Track this / Convert to Project (orgtrack/v1 §7.2): switch the + * session to the Project product mode, invalidate any pending Plan + * snapshot, and create-or-replay the root WorkItem from the recorded + * first user input. */ +export async function trackSessionAsProject(sessionId: string): Promise<{ + productMode: string; + agentExecMode: string; + workItemId?: string | null; +}> { + return rpc.agentSession.trackSessionAsProject({ sessionId }); +} + export async function respondQuestion( sessionId: string, requestId: string, @@ -400,10 +411,6 @@ export async function getTodos(sessionId: string): Promise { return rpc.agentSession.getTodos({ sessionId }); } -export async function listModes(): Promise { - return rpc.agentSession.listModes(); -} - export async function resolveReview(sessionId: string): Promise { return rpc.agentSession.resolveReview({ sessionId }); } diff --git a/src/api/tauri/agent/types.ts b/src/api/tauri/agent/types.ts index 3a6cbec49..425631093 100644 --- a/src/api/tauri/agent/types.ts +++ b/src/api/tauri/agent/types.ts @@ -28,13 +28,6 @@ export type PlanApprovalChoice = "approve" | "approve_with_edits" | "reject"; export type FileResolutionValue = "accepted" | "rejected" | "reverted"; -/** Metadata row from `agent_list_modes` (Rust `AgentExecMode` catalog). */ -export interface AgentExecModeConfig { - id: string; - name: string; - description: string; -} - export interface AgentStatusInfo { running: boolean; gatewayRunning: boolean; diff --git a/src/api/tauri/rpc/procedures/agentSession.ts b/src/api/tauri/rpc/procedures/agentSession.ts index 7e533f4a8..84b496a21 100644 --- a/src/api/tauri/rpc/procedures/agentSession.ts +++ b/src/api/tauri/rpc/procedures/agentSession.ts @@ -81,6 +81,10 @@ export const agentSession = { .input(schemas.agentSession.LinkSessionToWorkItemInput) .output(schemas.agentSession.SessionMetaSchema) .build(), + trackSessionAsProject: defineProcedure("agent_track_session_as_project") + .input(schemas.agentSession.SessionIdInput) + .output(schemas.agentSession.TrackSessionAsProjectResult) + .build(), respondQuestion: defineProcedure("agent_question_response") .input(schemas.agentSession.QuestionResponseInput) .build(), @@ -148,9 +152,6 @@ export const agentSession = { .input(schemas.agentSession.SessionIdInput) .output(z.array(schemas.agentSession.TodoItemSchema)) .build(), - listModes: defineProcedure("agent_list_modes") - .output(z.array(schemas.agentSession.AgentExecModeConfigSchema)) - .build(), resolveReview: defineProcedure("agent_resolve_review") .input(schemas.agentSession.SessionIdInput) .output(z.number()) diff --git a/src/api/tauri/rpc/schemas/agentSession.ts b/src/api/tauri/rpc/schemas/agentSession.ts index beccc8411..6d39e25a5 100644 --- a/src/api/tauri/rpc/schemas/agentSession.ts +++ b/src/api/tauri/rpc/schemas/agentSession.ts @@ -1,7 +1,6 @@ import { z } from "zod/v4"; import type { - AgentExecModeConfig, AgentStatusInfo, DeleteSessionReceipt, FileResolution, @@ -197,6 +196,12 @@ export const LinkSessionToWorkItemInput = z.object({ agentRole: z.string().optional(), }); +export const TrackSessionAsProjectResult = z.object({ + productMode: z.string(), + agentExecMode: z.string(), + workItemId: z.string().nullable().optional(), +}); + export const QuestionResponseInput = z.object({ sessionId: z.string(), requestId: z.string(), @@ -323,12 +328,6 @@ export const TodoItemSchema = z.object({ status: z.enum(["pending", "in_progress", "completed", "cancelled"]), }) as z.ZodType; -export const AgentExecModeConfigSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string(), -}) as z.ZodType; - export const FileResolutionInput = z.object({ sessionId: z.string(), filePath: z.string(), @@ -375,6 +374,7 @@ const SessionLaunchParamsSchema = z projectId: z.string().optional(), projectName: z.string().optional(), workItemId: z.string().optional(), + productMode: z.string().optional(), agentRole: z.string().optional(), worktreePath: z.string().optional(), projectSlug: z.string().optional(), diff --git a/src/api/tauri/rpc/schemas/sessionAggregate.ts b/src/api/tauri/rpc/schemas/sessionAggregate.ts index ea281a620..be7b33a08 100644 --- a/src/api/tauri/rpc/schemas/sessionAggregate.ts +++ b/src/api/tauri/rpc/schemas/sessionAggregate.ts @@ -159,6 +159,9 @@ export const SessionPatchInput = z.object({ model: z.string().optional(), accountId: z.string().optional(), agentExecMode: z.string().optional(), + // Product mode (orgtrack/v1 §5.2): build|plan|ask|project. + // Validated as a closed enum on the Rust side. + productMode: z.string().optional(), // `.nullable().optional()` is the zod equivalent of the Rust // `Option>`: undefined = leave alone, null = clear, // string = set. @@ -172,6 +175,7 @@ export const SessionPatchInput = z.object({ p.name !== undefined || p.model !== undefined || p.agentExecMode !== undefined || + p.productMode !== undefined || p.draftText !== undefined || p.replyTargetEventId !== undefined || p.pinned !== undefined, @@ -234,6 +238,9 @@ export const SessionAggregateRecordSchema = z.object({ // strict enum) so the wire format tolerates new modes added on the // Rust side without a coordinated frontend release. agentExecMode: z.string().optional(), + // Persistent product mode (orgtrack/v1 §5.2): build|plan|ask|project. + // Absent = build. Source of truth for the Project mutation surface. + productMode: z.string().optional(), // Per-session unsent draft text (P3). The chat composer mirrors this // into ComposerInput on session activation. Cleared on send. Persisted via // debounced `session_patch` calls — see `useSessionDraftField`. diff --git a/src/api/tauri/session/index.ts b/src/api/tauri/session/index.ts index 4d25d26ed..0234797f4 100644 --- a/src/api/tauri/session/index.ts +++ b/src/api/tauri/session/index.ts @@ -173,6 +173,7 @@ export function toFrontendSession(record: SessionAggregateRecord): Session { agentIconId: importedSource?.iconId ?? record.agentIconId, agentDisplayName: importedSource?.displayName ?? record.agentDisplayName, agentExecMode: normalizeAgentExecMode(record.agentExecMode) ?? undefined, + productMode: record.productMode, draftText: record.draftText, replyTargetEventId: record.replyTargetEventId, pinned: record.pinned, diff --git a/src/app/root/E2EBootstrap.tsx b/src/app/root/E2EBootstrap.tsx index f820fd0e0..d6563a45b 100644 --- a/src/app/root/E2EBootstrap.tsx +++ b/src/app/root/E2EBootstrap.tsx @@ -118,9 +118,6 @@ export const E2EBootstrap: FC = () => { getSettingsRegistryKeys, getDesktopConfig, setDesktopConfig, - listAutomationRules, - addAutomationRule, - removeAutomationRule, } = createConfigHelpers(); const refreshAgentDefs = async (): Promise> => { @@ -411,9 +408,6 @@ export const E2EBootstrap: FC = () => { runWorkItemSchedulerOnce, getDesktopConfig, setDesktopConfig, - listAutomationRules, - addAutomationRule, - removeAutomationRule, listPolicies, createPolicy, readPolicy, diff --git a/src/app/root/e2e/helpers/config.ts b/src/app/root/e2e/helpers/config.ts index 3ea534846..21b951f49 100644 --- a/src/app/root/e2e/helpers/config.ts +++ b/src/app/root/e2e/helpers/config.ts @@ -227,41 +227,6 @@ export function createConfigHelpers() { } }; - const listAutomationRules = async (): Promise> => { - try { - const rules = (await invoke("agent_automation_list_rules")) as Json[]; - return { ok: true, rules }; - } catch (err) { - return asError(err); - } - }; - - const addAutomationRule = async ( - ruleJson: string - ): Promise> => { - try { - const ruleId = (await invoke("agent_automation_add_rule", { - ruleJson, - })) as string; - return { ok: true, ruleId }; - } catch (err) { - return asError(err); - } - }; - - const removeAutomationRule = async ( - ruleId: string - ): Promise> => { - try { - const removed = (await invoke("agent_automation_remove_rule", { - ruleId, - })) as boolean; - return { ok: true, removed }; - } catch (err) { - return asError(err); - } - }; - return { getAgentDef, updateAgentDefPatch, @@ -280,8 +245,5 @@ export function createConfigHelpers() { getSettingsRegistryKeys, getDesktopConfig, setDesktopConfig, - listAutomationRules, - addAutomationRule, - removeAutomationRule, }; } diff --git a/src/app/root/e2e/helpers/sessions.ts b/src/app/root/e2e/helpers/sessions.ts index 01ff8bc36..2fed1c21f 100644 --- a/src/app/root/e2e/helpers/sessions.ts +++ b/src/app/root/e2e/helpers/sessions.ts @@ -394,6 +394,9 @@ export function createSessionHelpers(store: E2EStore) { launchParams.agentDefinitionId ?? launchParams.agent_definition_id, agent_org_id: launchParams.agentOrgId ?? launchParams.agent_org_id, work_item_id: launchParams.workItemId ?? launchParams.work_item_id, + // The wire name for the exec mode is `mode`; specs historically + // pass `agentExecMode`, which serde would silently drop. + mode: launchParams.mode ?? launchParams.agentExecMode, agent_role: launchParams.agentRole ?? launchParams.agent_role, worktree_path: launchParams.worktreePath ?? launchParams.worktree_path, project_slug: launchParams.projectSlug ?? launchParams.project_slug, diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index f16f6b095..7a1d43dac 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -346,11 +346,6 @@ export interface E2EHelpers { >; getDesktopConfig: () => Promise>; setDesktopConfig: (config: Json) => Promise<{ ok: true } | Err>; - listAutomationRules: () => Promise>; - addAutomationRule: (ruleJson: string) => Promise>; - removeAutomationRule: ( - ruleId: string - ) => Promise>; listPolicies: ( workspacePath?: string ) => Promise>; diff --git a/src/components/GlobalDragDrop/useGlobalDragDrop/useBrowserDragDrop.ts b/src/components/GlobalDragDrop/useGlobalDragDrop/useBrowserDragDrop.ts index ecaa343e5..cb7cc3545 100644 --- a/src/components/GlobalDragDrop/useGlobalDragDrop/useBrowserDragDrop.ts +++ b/src/components/GlobalDragDrop/useGlobalDragDrop/useBrowserDragDrop.ts @@ -81,7 +81,6 @@ export interface UseBrowserDragDropOptions { ) => void; setIsDragging: (dragging: boolean) => void; dragDepthRef: MutableRefObject; - workflowDragActiveRef: MutableRefObject; internalFileTreeDragRef: MutableRefObject; } @@ -96,13 +95,11 @@ export function useBrowserDragDrop(options: UseBrowserDragDropOptions): void { handleBrowserFileDrop, setIsDragging, dragDepthRef, - workflowDragActiveRef, internalFileTreeDragRef, } = options; useEffect(() => { - const isInternalDragFn = (e: Event) => - isInternalDrag(e, workflowDragActiveRef); + const isInternalDragFn = (e: Event) => isInternalDrag(e); const preventDefaults = createPreventDefaults(isInternalDragFn); @@ -424,7 +421,6 @@ export function useBrowserDragDrop(options: UseBrowserDragDropOptions): void { handleBrowserFileDrop, setIsDragging, dragDepthRef, - workflowDragActiveRef, internalFileTreeDragRef, t, ]); diff --git a/src/components/GlobalDragDrop/useGlobalDragDrop/useGlobalDragDrop.ts b/src/components/GlobalDragDrop/useGlobalDragDrop/useGlobalDragDrop.ts index b2a570f09..8cfedced6 100644 --- a/src/components/GlobalDragDrop/useGlobalDragDrop/useGlobalDragDrop.ts +++ b/src/components/GlobalDragDrop/useGlobalDragDrop/useGlobalDragDrop.ts @@ -4,11 +4,8 @@ * Main orchestrating hook for GlobalDragDrop component. * Composes sub-hooks for different drag-drop scenarios. */ -import { useAtomValue } from "jotai"; import React, { useState } from "react"; -import { workflowDragActiveAtom } from "@src/store/ui/workflowEditorAtom"; - import type { UseGlobalDragDropReturn } from "./types"; import { useBrowserDragDrop } from "./useBrowserDragDrop"; import { useFileHandlers } from "./useFileHandlers"; @@ -17,18 +14,11 @@ import { useTauriDragDrop } from "./useTauriDragDrop"; export function useGlobalDragDrop(): UseGlobalDragDropReturn { // Core state const [isDragging, setIsDragging] = useState(false); - // Track internal workflow drags. - const workflowDragActive = useAtomValue(workflowDragActiveAtom); // Shared refs const dragDepthRef = React.useRef(0); - const workflowDragActiveRef = React.useRef(false); const internalFileTreeDragRef = React.useRef(false); - React.useEffect(() => { - workflowDragActiveRef.current = workflowDragActive; - }, [workflowDragActive]); - // Sub-hooks const { handleIdeFileDrop, handleBrowserFileDrop } = useFileHandlers(); @@ -37,7 +27,6 @@ export function useGlobalDragDrop(): UseGlobalDragDropReturn { handleBrowserFileDrop, setIsDragging, dragDepthRef, - workflowDragActiveRef, internalFileTreeDragRef, }); diff --git a/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts b/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts index 2436e29cd..72bae60ef 100644 --- a/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts +++ b/src/components/GlobalDragDrop/useGlobalDragDrop/utils/dragDetection.ts @@ -3,8 +3,6 @@ * * Detects internal vs external drag operations */ -import type { MutableRefObject } from "react"; - import { reorderActiveRef } from "@src/engines/ChatPanel/InputArea/components/QueuedMessages"; import { isInternalFileTreeDragActive, @@ -15,10 +13,7 @@ import { getNativeFrameScale } from "@src/util/platform/tauri/nativeFrame"; /** * Check if a drag event is internal (from our app) vs external (from OS/IDE) */ -export function isInternalDrag( - event: Event, - workflowDragActiveRef: MutableRefObject -): boolean { +export function isInternalDrag(event: Event): boolean { const dragEvent = event as DragEvent; // Queue reorder drag is always internal @@ -26,10 +21,9 @@ export function isInternalDrag( return true; } - // Workflow canvas drags are internal. - if (workflowDragActiveRef.current) { - return true; - } + // (The old workflow-canvas drag flag is gone: the visual workflow + // editor that set it was removed in Phase 1 of the Orgtrack migration, + // so the atom could never become true again.) // Check global flag for internal file tree drags (reliable in Tauri WebView // where custom MIME types may not appear in dataTransfer.types) diff --git a/src/config/sessionCreatorConfig.ts b/src/config/sessionCreatorConfig.ts index e55a3b0a0..f8fab9fa8 100644 --- a/src/config/sessionCreatorConfig.ts +++ b/src/config/sessionCreatorConfig.ts @@ -8,6 +8,7 @@ import { Infinity, Cloud, + FolderKanban, Laptop, ListPlus, ListTodo, @@ -124,6 +125,48 @@ export function getAgentExecModeEntry(id: string): AgentExecModeEntry { return AGENT_EXEC_MODES.find((mode) => mode.id === id) ?? AGENT_EXEC_MODES[0]; } +// ============================================ +// Composer modes (product-mode axis, orgtrack/v1 §5.2) +// ============================================ + +/** + * The one user-visible mode selector writes the PRODUCT mode + * (`build | plan | ask | project`); the runtime exec mode is derived + * (identity for build/plan/ask, `project → build`). `project` is NOT an + * `AgentExecMode` — it never reaches the exec-mode wire enum; it flips + * the persistent `session.productMode` axis that gates the + * WorkItem/Routine mutation surface. + */ +export const PRODUCT_MODE_PROJECT = "project" as const; + +export interface ComposerModeEntry { + id: AgentExecMode | typeof PRODUCT_MODE_PROJECT; + icon: typeof Infinity; + i18nKey: string; + name: string; + description: string; +} + +/** Picker list for the composer ModePill: exec modes + Project. */ +export const COMPOSER_MODES: ComposerModeEntry[] = [ + ...AGENT_EXEC_MODES, + { + id: PRODUCT_MODE_PROJECT, + icon: FolderKanban, + i18nKey: "planner.modes.project", + name: "Project", + description: + "Plan and execute inside the persistent work graph — sessions link to Work Items", + }, +]; + +/** Runtime exec mode a composer selection maps to (§5.2 default map). */ +export function execModeForComposerSelection( + id: ComposerModeEntry["id"] +): AgentExecMode { + return id === PRODUCT_MODE_PROJECT ? "build" : id; +} + // ============================================ // Running location // ============================================ diff --git a/src/engines/ChatPanel/ChatPanelContent.tsx b/src/engines/ChatPanel/ChatPanelContent.tsx index bc460f4d2..0202d9b59 100644 --- a/src/engines/ChatPanel/ChatPanelContent.tsx +++ b/src/engines/ChatPanel/ChatPanelContent.tsx @@ -4,6 +4,7 @@ import type { SessionContinuation } from "@src/store/session/sessionTabPlacement import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanelAtom"; import SessionContentView from "./SessionContentView"; +import SessionContextBar from "./components/SessionContextBar"; import type { SessionViewMode } from "./hooks/useSessionViewMode"; const BenchmarkPanel = React.lazy(() => @@ -67,6 +68,10 @@ export function ChatPanelContent({ alternateActive ? "hidden" : "flex" }`} > + {/* Repo/branch context + active WorkItem for Project + sessions (orgtrack/v1 §7.2). Renders null when the + session has neither. */} + { + openOrFocusSessionTab({ sessionId: info.sessionId }); + }, + [openOrFocusSessionTab] + ); const renderWorkItemCreator = (showInlineAiModePanel: boolean) => { return ( @@ -226,11 +241,16 @@ export function ChatPanelEmptyContent({ launchMode={SESSION_CREATOR_LAUNCH_MODE.START_BACKGROUND} onOpenCliTerminal={handleOpenCliTerminal} onRegionNoticeChange={handleRegionNoticeChange} + onSessionStart={handleProjectCreatorSessionStart} workItemContext={{ orgId: projectDraftOrgId ?? createProjectContext?.orgId ?? STORY_PERSONAL_ORG_FILTER_ID, + // The whole flow is "create a project via manage_project" — + // without a workItemId the resolver would default to build + // and the PM tools would be policy-denied (§5.2 deny-delta). + productMode: PRODUCT_MODE_PROJECT, }} /> ) : null; diff --git a/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts b/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts index 5882a5d89..6512fefeb 100644 --- a/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts +++ b/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts @@ -9,7 +9,10 @@ */ import { respondModeSwitch } from "@src/api/tauri/agent"; import { rpc } from "@src/api/tauri/rpc"; -import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; +import { + ALL_AGENT_EXEC_MODES, + type AgentExecMode, +} from "@src/config/sessionCreatorConfig"; import { beginOptimisticTurn, failOptimisticTurn, @@ -54,13 +57,16 @@ function markResolved(eventId: string, status: ModeSwitchResolution) { // Mode labels // ============================================ -export const MODE_LABELS: Record = { - build: "Build", - ask: "Ask", - plan: "Plan", - debug: "Debug", - review: "Review", -}; +// Derived from the canonical wire-value set so this file can never hold +// a divergent fifth mode catalog (Orgtrack migration, mode convergence). +// Labels are the capitalized wire values; the picker's richer copy lives +// with AGENT_EXEC_MODES in sessionCreatorConfig.ts. +export const MODE_LABELS: Record = Object.fromEntries( + [...ALL_AGENT_EXEC_MODES].map((mode) => [ + mode, + mode.charAt(0).toUpperCase() + mode.slice(1), + ]) +); // ============================================ // Actions diff --git a/src/engines/ChatPanel/InputArea/components/ModePill.tsx b/src/engines/ChatPanel/InputArea/components/ModePill.tsx index 5f409152a..ae87b88de 100644 --- a/src/engines/ChatPanel/InputArea/components/ModePill.tsx +++ b/src/engines/ChatPanel/InputArea/components/ModePill.tsx @@ -33,12 +33,19 @@ import SelectorPill from "@src/components/SelectorPill"; import { AGENT_EXEC_MODES, type AgentExecMode, + COMPOSER_MODES, + type ComposerModeEntry, DEFAULT_AGENT_EXEC_MODE, + PRODUCT_MODE_PROJECT, + execModeForComposerSelection, normalizeAgentExecMode, } from "@src/config/sessionCreatorConfig"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; import { useDropdownEngine } from "@src/hooks/dropdown"; -import { useSessionExecModeField } from "@src/hooks/session/useSessionPatch"; +import { + useSessionExecModeField, + useSessionProductModeField, +} from "@src/hooks/session/useSessionPatch"; import { creatorDefaultExecModeAtom } from "@src/store/session/creatorDefaultExecModeAtom"; import { isAgentSession, @@ -82,6 +89,9 @@ const ModePill: React.FC = memo( const setCreatorDefault = useSetAtom(creatorDefaultExecModeAtom); const { agentExecMode: sessionMode, setMode: setSessionMode } = useSessionExecModeField(sessionId ?? ""); + const { productMode, setProductMode } = useSessionProductModeField( + sessionId ?? "" + ); const isInSessionMode = !isControlled && !forceVisible && Boolean(sessionId); @@ -91,12 +101,29 @@ const ModePill: React.FC = memo( ? (normalizeAgentExecMode(sessionMode) ?? creatorDefault) : creatorDefault; - const currentOption = - AGENT_EXEC_MODES.find((opt) => opt.id === mode) ?? AGENT_EXEC_MODES[0]; + // 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. + const isProjectSession = + isInSessionMode && productMode === PRODUCT_MODE_PROJECT; + const pickerModes: ComposerModeEntry[] = isInSessionMode + ? COMPOSER_MODES + : AGENT_EXEC_MODES; + + const currentOption = isProjectSession + ? (COMPOSER_MODES.find((opt) => opt.id === PRODUCT_MODE_PROJECT) ?? + AGENT_EXEC_MODES[0]) + : (AGENT_EXEC_MODES.find((opt) => opt.id === mode) ?? + AGENT_EXEC_MODES[0]); const CurrentIcon = currentOption.icon; - const currentLabel = t(currentOption.i18nKey); - const toneClassName = - mode === "plan" + const currentLabel = t(currentOption.i18nKey, { + defaultValue: currentOption.name, + }); + const toneClassName = isProjectSession + ? "mode-pill-tone-plan" + : mode === "plan" ? "mode-pill-tone-plan" : mode === "ask" ? "mode-pill-tone-ask" @@ -117,33 +144,36 @@ const ModePill: React.FC = memo( }); const setModeValue = useCallback( - (selected: AgentExecMode) => { + (selected: ComposerModeEntry["id"]) => { + const derivedExecMode = execModeForComposerSelection(selected); if (!isControlled) { if (isInSessionMode) { - // Fire-and-forget: useSessionExecModeField does the - // optimistic store write before awaiting the RPC, so the - // pill repaints with the new value on the same frame. - // Errors are surfaced via the hook's own state; we - // intentionally don't await here so the dropdown closes - // without waiting on IPC. - void setSessionMode(selected); + // §5.2: the selector writes the PRODUCT mode; the runtime + // exec mode is derived (project → build, identity + // 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); } else { - setCreatorDefault(selected); + setCreatorDefault(derivedExecMode); } } - onModeChange?.(selected); + onModeChange?.(derivedExecMode); }, [ isControlled, isInSessionMode, setSessionMode, + setProductMode, setCreatorDefault, onModeChange, ] ); const handleSelect = useCallback( - (selected: AgentExecMode) => { + (selected: ComposerModeEntry["id"]) => { setModeValue(selected); close(); }, @@ -151,13 +181,24 @@ const ModePill: React.FC = memo( ); const handleTriggerClick = useCallback(() => { - if (resetToDefaultOnClick && mode !== DEFAULT_AGENT_EXEC_MODE) { + if ( + resetToDefaultOnClick && + !isProjectSession && + mode !== DEFAULT_AGENT_EXEC_MODE + ) { setModeValue(DEFAULT_AGENT_EXEC_MODE); close(); return; } toggle(); - }, [resetToDefaultOnClick, mode, setModeValue, close, toggle]); + }, [ + resetToDefaultOnClick, + isProjectSession, + mode, + setModeValue, + close, + toggle, + ]); const isVisible = forceVisible || @@ -165,7 +206,7 @@ const ModePill: React.FC = memo( if ( !isVisible || (sessionId && isWingmanSession(sessionId)) || - (hideWhenDefault && mode === DEFAULT_AGENT_EXEC_MODE) + (hideWhenDefault && !isProjectSession && mode === DEFAULT_AGENT_EXEC_MODE) ) { return null; } @@ -211,9 +252,11 @@ const ModePill: React.FC = memo( }} >
- {AGENT_EXEC_MODES.map((option) => { + {pickerModes.map((option) => { const Icon = option.icon; - const isSelected = mode === option.id; + const isSelected = isProjectSession + ? option.id === PRODUCT_MODE_PROJECT + : mode === option.id; return ( = memo( dataTestId={`agent-exec-mode-option-${option.id}`} onClick={() => handleSelect(option.id)} > - {t(option.i18nKey)} + {t(option.i18nKey, { defaultValue: option.name })} ); })} diff --git a/src/engines/ChatPanel/InputArea/index.tsx b/src/engines/ChatPanel/InputArea/index.tsx index cfafcf775..836602a41 100644 --- a/src/engines/ChatPanel/InputArea/index.tsx +++ b/src/engines/ChatPanel/InputArea/index.tsx @@ -384,9 +384,12 @@ const InputAreaInteractive: React.FC = memo( // Cursor IDE sessions are read-only; no interactive model/mode pill. const modelPill = !showAgentControls || (isCursorIde && sessionId) ? null : ; + // Always visible in-session: the composer picker is the only surface + // that can move a session onto the Project product mode (§5.2), and a + // hidden-at-Build pill would make that entry unreachable. const modePill = !showAgentControls || (isCursorIde && sessionId) ? null : ( - + ); const clearReplyInfo = useCallback( () => setReplyInfo({ isReply: false }), diff --git a/src/engines/ChatPanel/components/SessionContextBar/index.tsx b/src/engines/ChatPanel/components/SessionContextBar/index.tsx index de7f0f411..9acebd3aa 100644 --- a/src/engines/ChatPanel/components/SessionContextBar/index.tsx +++ b/src/engines/ChatPanel/components/SessionContextBar/index.tsx @@ -7,13 +7,17 @@ * displayed as a static badge — the runner is locked at the agent-core * layer once the worktree is created and cannot be switched in the UI. */ -import { useAtomValue } from "jotai"; -import { GitFork, Monitor } from "lucide-react"; -import React, { memo } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { FolderKanban, GitFork, Monitor } from "lucide-react"; +import React, { memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; +import { useChannelWorkItem } from "@src/features/DiscussionChannels/ChannelPanelView/useChannelWorkItem"; +import { getWorkItemStatusConfig } from "@src/modules/ProjectManager/config/manage"; +import { openWorkItemInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; +import type { WorkItemStatus } from "@src/types/core/workItem"; import { formatBranchLabel } from "@src/util/git/branchLabel"; import { basename } from "@src/util/path"; @@ -58,6 +62,74 @@ const WorktreePill: React.FC = ({ branch }) => { ); }; +interface ActiveWorkItemPillProps { + shortId: string; + projectSlug?: string; +} + +// Active WorkItem indicator for Project sessions (orgtrack/v1 §7.2): +// shortId + live status, click opens the real Work Item panel. Items +// without a project scope (session-bootstrap standalone roots) render +// as a static badge — there is no project surface to open for them yet. +const ActiveWorkItemPill: React.FC = ({ + shortId, + projectSlug, +}) => { + const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + const { resolved } = useChannelWorkItem({ + projectSlug: projectSlug ?? "", + shortId, + }); + + const status = resolved?.workItem.status; + const statusLabel = status + ? getWorkItemStatusConfig(status as WorkItemStatus).label + : null; + + const handleOpen = useCallback(() => { + if (!resolved || !projectSlug) return; + openWorkItem({ + workItem: resolved.workItem, + shortId: resolved.workItem.shortId ?? shortId, + projectId: resolved.projectId, + projectSlug, + projectName: resolved.projectName, + orgId: resolved.orgId, + }); + }, [openWorkItem, projectSlug, resolved, shortId]); + + const body = ( + <> + + {shortId} + {statusLabel && ( + · {statusLabel} + )} + + ); + + if (!projectSlug || !resolved) { + return ( + + {body} + + ); + } + return ( + + ); +}; + // ── Main component ──────────────────────────────────────────────────────────── const SessionContextBar: React.FC = memo(() => { @@ -69,10 +141,14 @@ const SessionContextBar: React.FC = memo(() => { const worktreeBranch = session?.worktreeBranch; const sessionBranch = session?.branch; const baseBranch = session?.baseBranch; + // Project sessions surface their active WorkItem here even without a + // repo context (e.g. an OS-agent Home session tracked as a project). + const workItemId = + session?.productMode === "project" ? session?.workItemId : undefined; - if (!sessionId || !repoPath) return null; + if (!sessionId || (!repoPath && !workItemId)) return null; - const repoLabel = basename(repoPath); + const repoLabel = repoPath ? basename(repoPath) : null; const branchLabel = formatBranchLabel(sessionBranch) || formatBranchLabel(baseBranch); const worktreeLabel = formatBranchLabel(worktreeBranch); @@ -80,27 +156,42 @@ const SessionContextBar: React.FC = memo(() => { return (
{/* Repo name */} - - } - /> + {repoLabel && ( + + } + /> + )} {/* Separator */} - {(branchLabel || worktreeLabel) && ( + {repoLabel && (branchLabel || worktreeLabel) && ( / )} {/* Base branch */} - {branchLabel && !worktreeLabel && } + {repoLabel && branchLabel && !worktreeLabel && ( + + )} {/* Worktree pill (interactive) */} {worktreePath && worktreeLabel && } + + {/* Active WorkItem (Project sessions) */} + {workItemId && ( + <> + · + + + )}
); }); diff --git a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx index 52632c9c1..43d13810b 100644 --- a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx +++ b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx @@ -1,7 +1,9 @@ +import { useAtomValue } from "jotai"; import { BellOff, Braces, Clipboard, + FolderKanban, FolderOutput, Link2, MoreHorizontal, @@ -15,16 +17,20 @@ import React from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { trackSessionAsProject } from "@src/api/tauri/agent/session"; import Button from "@src/components/Button"; import { DROPDOWN_CLASSES, DROPDOWN_ITEM, DROPDOWN_WIDTHS, } from "@src/components/Dropdown/tokens"; +import Message from "@src/components/Message"; import Switch from "@src/components/Switch"; import type { DropdownEnginePosition } from "@src/hooks/dropdown"; import { useSessionNotificationMute } from "@src/hooks/notifications/useSessionNotificationMute"; +import { sessionByIdAtom, upsertSession } from "@src/store/session"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanelAtom"; +import { isAgentSession } from "@src/util/session/sessionDispatch"; const HEADER_ICON_SIZE = 14; @@ -101,6 +107,35 @@ export const SessionHeaderActionsMenu: React.FC< const { isMuted: sessionNotificationsMuted, setMuted } = useSessionNotificationMute(currentSessionId); + // Track this / Convert to Project (orgtrack/v1 §7.2). Self-contained: + // the backend command persists the switch + root WorkItem; only the + // local store row needs a merge afterwards. + const trackableSession = useAtomValue( + sessionByIdAtom(currentSessionId ?? "") + ); + const canTrackAsProject = + !!currentSessionId && + isAgentSession(currentSessionId) && + trackableSession?.productMode !== "project"; + const handleTrackAsProject = React.useCallback(async () => { + if (!currentSessionId) return; + toggleHeaderActionsMenu(); + try { + const result = await trackSessionAsProject(currentSessionId); + if (trackableSession) { + upsertSession({ + ...trackableSession, + productMode: result.productMode, + agentExecMode: result.agentExecMode, + workItemId: result.workItemId ?? trackableSession.workItemId, + }); + } + Message.success(t("sessions:chat.trackAsProject.success")); + } catch (err) { + Message.error(err instanceof Error ? err.message : String(err)); + } + }, [currentSessionId, toggleHeaderActionsMenu, trackableSession, t]); + return ( <> -
- ); - } - - // For "nothing" variant, still show a section title - return ( -
- - {t("editPanel.actionsTitle")} - -
- ); - }; - - // Handle property value change for editing variant - const handlePropertyChange = useCallback( - (inputIndex: number, value: unknown) => { - if (!selectedInstanceId || !selectedInstance || !onUpdateAction) return; - - const currentData = selectedInstance as Record; - const updatedData = { - ...currentData, - [inputIndex]: value, - }; - onUpdateAction(selectedInstanceId, updatedData); - }, - [selectedInstanceId, selectedInstance, onUpdateAction] - ); - - // Render content based on variant - const renderContent = () => { - // "nothing" variant - show placeholder - if (variant === "nothing") { - return ( -
-
- -
-

- {t("editPanel.clickToAdd")} -

-
- ); - } - - // "editing" variant - show action properties - if (variant === "editing") { - if (!selectedDefinition || !selectedInstance) { - return ( - - ); - } - - const instanceData = selectedInstance as Record; - - return ( - - {/* Action Icon and Category */} -
-
- {renderActionIcon(selectedDefinition.icon, { - size: 18, - className: "text-text-1", - })} -
-
-
- {selectedDefinition.category} -
-
-
- - {/* Properties Section */} - {selectedDefinition.inputs && selectedDefinition.inputs.length > 0 ? ( - <> -
- {t("editPanel.propertiesTitle")} -
-
- {selectedDefinition.inputs.map((input, index) => { - const currentValue = - instanceData[index] ?? input.defaultValue; - const inputLabel = translateInputLabel(tIntegrations, input); - const inputPlaceholder = translateInputPlaceholder( - tIntegrations, - input - ); - const inputUnit = translateInputUnit(tIntegrations, input); - - return ( -
- {/* Label */} - {inputLabel && ( - - )} - - {/* Input based on type */} - {input.type === "text" || input.type === "command" ? ( - - handlePropertyChange(index, newValue) - } - placeholder={inputPlaceholder} - size="default" - /> - ) : input.type === "prompt" ? ( -