diff --git a/apps/presentation/dashboard/smoke/request-control-browser-smoke.mjs b/apps/presentation/dashboard/smoke/request-control-browser-smoke.mjs new file mode 100644 index 0000000000..2275fe168a --- /dev/null +++ b/apps/presentation/dashboard/smoke/request-control-browser-smoke.mjs @@ -0,0 +1,141 @@ +// Real source registry, canonical SQLite, Chat entrance, request controls and Agent CLI. +// Synthetic progress only; no model runs or user runtime activation. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {readFile,mkdtemp,mkdir,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {resolve} from 'node:path'; +import {createServer} from 'node:net'; +import {createInterface} from 'node:readline'; +import {testPythonCommand} from '../../../../scripts/test-python.mjs'; +import {launchBrowser,loadPlaywright} from '../../../../examples/dashboard-browser-smoke-support.mjs'; +const repo=resolve(import.meta.dirname,'../../../..'); +const output=resolve(repo,'output/playwright/request-control');await mkdir(output,{recursive:true}); +const root=await mkdtemp(resolve(tmpdir(),'rc-')); +const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r)); +const command=testPythonCommand(await readFile(resolve(repo,'apps/presentation/dashboard/smoke/request-control-http-fixture.py'),'utf8'),{repoRoot:repo}); +const child=spawn(command.command,[...command.args,root,String(port)],{cwd:root,env:{...process.env,LOOPX_USAGE_PING:'0',TMPDIR:root,NODE_OPTIONS:'--experimental-sqlite'},stdio:['pipe','pipe','pipe']}); +let stderr='';child.stderr.on('data',x=>stderr+=String(x)); +const lines=createInterface({input:child.stdout});const replies=[];const waiting=[]; +let readyResolve,readyReject;const ready=new Promise((r,j)=>{readyResolve=r;readyReject=j;}); +lines.on('line',line=>{if(line.startsWith('Serving LoopX Chat'))readyResolve();if(line.startsWith('FIXTURE ')){const v=JSON.parse(line.slice(8));if(waiting.length)waiting.shift()(v);else replies.push(v);}}); +child.once('exit',code=>readyReject(Error(`fixture exited ${code}: ${stderr}`))); +async function fixture(op){const result=new Promise(r=>replies.length?r(replies.shift()):waiting.push(r));child.stdin.write(op+'\n');const value=await result;assert.equal(value.fixture_error,undefined);return value;} +let browser,page; +try{ + let timer; try {await Promise.race([ready,new Promise((_,j)=>{timer=setTimeout(()=>j(Error('startup timeout '+stderr)),90000);})]);} finally {clearTimeout(timer);} + browser=await launchBrowser(loadPlaywright().chromium);page=await browser.newPage({viewport:{width:1440,height:1100}}); + await page.addInitScript(()=>!localStorage.getItem('loopx-pw-locale')&&localStorage.setItem('loopx-pw-locale','zh-CN')); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + const writes=[];let drop=true;let droppedResolve;const dropped=new Promise(r=>{droppedResolve=r;}); + await page.route('**/api/chat/request-control',async route=>{ + if(route.request().method()!=='POST'||route.request().postDataJSON().execute!==true)return route.continue(); + const body=route.request().postDataJSON();writes.push(body); + const response=await route.fetch(); + if(drop&&body.operation==='revise'&&response.ok()){drop=false;await route.abort();droppedResolve();}else await route.fulfill({response}); + }); + await page.goto(`http://127.0.0.1:${port}/chat/`); + async function enter(){ + await page.getByRole('navigation',{name:'管家视图'}).getByRole('button',{name:/^(Chat|对话)$/}).click(); + await page.getByText('Please verify this contract. Preserve the constraints and return findings.',{exact:true}).waitFor(); + const card=page.locator('.personal-collaboration').first(); + await card.getByText('查看交办内容',{exact:true}).click(); + const open=card.getByRole('button',{name:'修正或取消请求',exact:true}); + if(await open.count())await open.click(); + await card.locator('.personal-request-control').waitFor(); + return card; + } + let card=await enter();let panel=card.locator('.personal-request-control'); + await panel.getByRole('button',{name:'修正内容',exact:true}).click(); + await panel.getByLabel('背景与补充',{exact:true}).fill('Use corrected evidence for this contract'); + // A successful storage call is not proof that the whole original request can + // survive a lost reply and reload. Fault only browser retention; the real + // request/Goal owners and the response-loss route above stay in place. + await page.evaluate(()=>{ + const set=Storage.prototype.setItem,get=Storage.prototype.getItem; + window.requestRetentionFault='drop'; + Storage.prototype.setItem=function(key,value){ + if(key.startsWith('loopx.request-control.')){ + if(window.requestRetentionFault==='drop')return; + if(window.requestRetentionFault==='changed-content'){ + const saved=JSON.parse(value);saved.parameters.brief.context='Different retained content'; + return set.call(this,key,JSON.stringify(saved)); + } + } + return set.call(this,key,value); + }; + Storage.prototype.getItem=function(key){ + if(key.startsWith('loopx.request-control.')&&window.requestRetentionFault==='unreadable')throw Error('Storage read unavailable'); + return get.call(this,key); + }; + }); + for(const fault of ['drop','changed-content','unreadable']){ + await page.evaluate(value=>{window.requestRetentionFault=value;},fault); + await panel.getByRole('button',{name:'保存修正',exact:true}).click(); + await panel.getByText('无法保存恢复信息,本次操作尚未发送。',{exact:true}).waitFor(); + assert.equal(writes.length,0,'unrecoverable request must never execute'); + assert.equal((await fixture('inspect')).revision,1); + assert.equal(await panel.getByLabel('背景与补充',{exact:true}).inputValue(),'Use corrected evidence for this contract'); + assert.equal(await panel.getByLabel('背景与补充',{exact:true}).isEnabled(),true); + assert.equal(await panel.getByRole('button',{name:'重试原操作',exact:true}).count(),0); + } + await panel.screenshot({path:resolve(output,'retention-unavailable.png')}); + await page.evaluate(()=>{ + window.requestRetentionFault=null; + for(const key of Object.keys(sessionStorage))if(key.startsWith('loopx.request-control.'))sessionStorage.removeItem(key); + }); + await page.reload();card=await enter();panel=card.locator('.personal-request-control'); + assert.equal(await panel.getByRole('button',{name:'重试原操作',exact:true}).count(),0); + assert.equal((await fixture('inspect')).revision,1); + await panel.getByRole('button',{name:'修正内容',exact:true}).click(); + await panel.getByLabel('背景与补充',{exact:true}).fill('Use corrected evidence for this contract'); + await panel.getByRole('button',{name:'保存修正',exact:true}).click(); + await dropped; + await page.waitForFunction(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='重试原操作')?.disabled===false); + assert.equal((await fixture('inspect')).revision,2); + await page.reload();card=await enter();panel=card.locator('.personal-request-control'); + await panel.getByRole('button',{name:'重试原操作',exact:true}).click(); + await panel.getByRole('button',{name:'修正内容',exact:true}).waitFor(); + assert.deepEqual(writes[0],writes[1]);assert.equal((await fixture('inspect')).revision,2); + await panel.getByRole('button',{name:'修正内容',exact:true}).click(); + await panel.getByLabel('背景与补充',{exact:true}).fill('A stale editor must not overwrite another correction'); + await fixture('correct'); + await panel.getByRole('button',{name:'保存修正',exact:true}).click(); + await panel.getByText('请求已有新修订。已重新读取,请基于当前内容修改。',{exact:true}).waitFor(); + await panel.getByRole('button',{name:'修正内容',exact:true}).click(); + assert.equal(await panel.getByLabel('背景与补充',{exact:true}).inputValue(),'Correction from another authorized view'); + await panel.getByRole('button',{name:'收起编辑',exact:true}).click(); + await fixture('receiver');await fixture('interim'); + await page.reload();card=await enter();panel=card.locator('.personal-request-control'); + await page.getByText('Checking the corrected request',{exact:true}).waitFor(); + await fixture('reserve'); + await panel.getByRole('button',{name:'重新读取',exact:true}).click(); + await panel.getByText('目标变更仍待核对',{exact:true}).waitFor(); + await panel.screenshot({path:resolve(output,'pending-effect.png')}); + const failedRead=async route=>route.abort(); + await page.route('**/api/chat/request-control?*',failedRead); + await panel.getByRole('button',{name:'重新读取',exact:true}).click(); + await page.waitForFunction(()=>![...document.querySelectorAll('.personal-request-control button')].some(b=>b.textContent==='取消请求')); + assert.equal(await panel.getByRole('button',{name:'取消请求',exact:true}).count(),0); + await page.unroute('**/api/chat/request-control?*',failedRead); + await panel.getByRole('button',{name:'重新读取',exact:true}).click(); + await panel.getByRole('button',{name:'取消请求',exact:true}).waitFor(); + await panel.getByRole('button',{name:'取消请求',exact:true}).click(); + await panel.getByText('请求已取消',{exact:true}).waitFor(); + assert.equal((await fixture('inspect')).terminal_effects[0].terminal.outcome,'aborted'); + await panel.screenshot({path:resolve(output,'cancelled.png')}); + await page.screenshot({path:resolve(output,'whole-viewport.png')}); + await page.setViewportSize({width:390,height:844}); + assert.ok(await page.locator('body').evaluate(e=>e.scrollWidth<=innerWidth+1)); + await panel.screenshot({path:resolve(output,'narrow.png')}); + await page.evaluate(()=>localStorage.setItem('loopx-pw-locale','en')); + await page.reload(); + await page.getByRole('navigation',{name:'Manager view'}).getByRole('button',{name:'Chat',exact:true}).click(); + const english=page.locator('.personal-collaboration').first(); + await english.getByText('View delegation details',{exact:true}).click(); + await english.getByText('Request cancelled',{exact:true}).waitFor(); + await english.screenshot({path:resolve(output,'english.png')}); + assert.deepEqual(errors,[]); + console.log('PASS: real source registration, normal Chat entrance, silent-drop/changed-content/unreadable retention refuses execution and preserves draft, original request correction, unknown reply/reload/exact retry, stale edit, independent receiver CLI, reserved effect and cancellation terminal readback.'); +}catch(error){if(page){await page.screenshot({path:resolve(output,'failure.png'),fullPage:true}).catch(()=>{});await (await import('node:fs/promises')).writeFile(resolve(output,'failure.txt'),await page.locator('body').innerText());}throw error;} +finally{await page?.unrouteAll({behavior:"ignoreErrors"}).catch(()=>{});await browser?.close();child.kill('SIGTERM');await new Promise(r=>child.exitCode!==null?r():child.once('exit',r));lines.close();await rm(root,{recursive:true,force:true});} diff --git a/apps/presentation/dashboard/smoke/request-control-http-fixture.py b/apps/presentation/dashboard/smoke/request-control-http-fixture.py new file mode 100644 index 0000000000..c580e99633 --- /dev/null +++ b/apps/presentation/dashboard/smoke/request-control-http-fixture.py @@ -0,0 +1,377 @@ +import json +import signal +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from loopx.chat_server import serve_chat +from loopx.chat_store import ChatSessionStore +from loopx.capabilities.manager_context import deliver +from loopx.capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal +from loopx.control_plane.goals.goal_amendment_runtime import execute_goal_amendment +from loopx.control_plane.collaboration.request_control_source import ( + execute_request_control, +) +from loopx.control_plane.effect_runtime import restart_effect_runtime + +root = Path(sys.argv[1]) +port = int(sys.argv[2]) +tempfile.tempdir = str(root) +registry = root / "project/.loopx/registry.json" +runtime = root / "runtime" + + +def cli(*args): + p = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--runtime-root", + str(runtime), + "--format", + "json", + *args, + ], + cwd=root, + capture_output=True, + text=True, + timeout=80, + ) + assert p.returncode == 0, (p.stdout, p.stderr) + return json.loads(p.stdout) + + +created = cli( + "project", + "register", + "--project-id", + "request-recovery", + "--project-kind", + "work", + "--knowledge-root", + str(root / "project"), + "--goal-id", + "request-goal", + "--objective", + "Verify the public contract", + "--non-goal", + "No remote changes", + "--acceptance", + "Independent validation passes", + "--next-effect", + "Inspect the current contract", + "--stop-condition", + "Stop when the Goal is inactive", + "--goal-instance-profile", + "source_session_v1", + "--operation-id", + "create-goal", + "--registered-agent", + "agent-a", + "--registered-agent", + "agent-b", + "--canonical-storage", + "sqlite", +) +ref = created["goal_ref"] +(root / "registration.json").write_text(json.dumps(created)) +# Synthetic typed progress supplies a real replan-reader input, not model evidence. +runs = runtime / "goals/request-goal/runs/index.jsonl" +runs.parent.mkdir(parents=True, exist_ok=True) +rows = [ + { + "generated_at": f"2026-09-01T00:0{i}:00+00:00", + "goal_id": "request-goal", + "goal_ref": ref, + "agent_id": "agent-a", + "classification": "bounded_replan_progress", + "turn_instance_id": f"synthetic-{i}", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "blocked", + "surface_id": "contract", + "hypothesis_id": "request-gap", + "probe_kind": "check", + "evidence_ids": ["synthetic-evidence"], + }, + } + for i in range(2) +] +runs.write_text("".join(json.dumps(r) + "\n" for r in rows)) + + +def goal(op, params=None, actor="agent-a", execute=False): + return execute_goal_amendment( + replan_configuration_reader=resolve_todo_replan_cadence_goal, + registry_path=registry, + runtime_root=runtime, + goal_id=ref["goal_id"], + goal_instance_id=ref["goal_instance_id"], + operation=op, + actor_agent_id=actor, + parameters=params, + execute=execute, + ) + + +def basis(v): + return { + "base_goal_revision": v["intent"]["goal_revision"], + "base_intent_digest": v["intent"]["intent_digest"], + "base_work_digest": v["work_digest"], + } + + +v = goal("inspect", actor=None) +root_config = { + "operation_id": "root-policy", + "expected_provider_revision": v["provider_revision"], + "root_intent": { + **{ + k: v["source_commitments"][k] + for k in ("objective", "non_goals", "acceptance") + }, + "permission_scope": { + "write_scopes": ["tests/**"], + "capabilities": ["filesystem_write"], + }, + "stop_conditions": [v["source_commitments"]["stop_condition"]], + "terminal_conditions": ["Accepted result returned"], + }, + "policy": { + "out_of_policy": "reject", + "eligible_agents": ["agent-a", "agent-b"], + "max_created_todos": 4, + "templates": [ + { + "id": "check", + "todo": { + "text": "Verify the public contract", + "action_kind": "validate", + "target_key": "contract-check", + "required_write_scopes": ["tests/**"], + "required_capabilities": ["filesystem_write"], + }, + } + ], + }, +} +assert goal("configure", root_config, actor=None, execute=True)["ok"] +store = ChatSessionStore(runtime) +session = store.create_session( + goal_id="loopx-manager", + agent_id="codex", + adapter_kind="codex_app_server", + upstream_thread_id="synthetic-owner", + channel_id="manager", + session_id="request-owner", +) +turn, _ = store.create_turn( + session["session_id"], + client_turn_id="original-source", + message="Please verify this contract. Preserve the constraints and return findings.", + origin="web", +) +brief = { + "schema_version": "collaboration_brief_v0", + "purpose": "Verify this contract", + "context": "Use the original contract fixture", + "constraints": ["No remote changes"], + "inputs": [], + "acceptance": ["Independent validation passes"], + "return_requirement": "Return checked findings", +} +receipt = deliver( + runtime, + registry, + session=session, + turn=turn, + request={"goal_id": ref["goal_id"], "agent_id": "agent-a", "brief": brief}, + source_store=store, +) +store.update_turn( + session["session_id"], + turn["turn_id"], + status="completed", + response={"message": "Request recorded", "context_handoff_receipt": receipt}, +) +store.finalize_managed_turn_completion(session["session_id"], turn["turn_id"]) +rid = receipt["request_id"] +route = { + **ref, + "agent_id": "agent-a", + "request_id": rid, + "session_id": session["session_id"], + "turn_id": turn["turn_id"], +} +(root / "request-route.json").write_text(json.dumps(route)) + + +def inspect(): + return execute_request_control( + registry_path=registry, + runtime_root=runtime, + goal_ref=ref, + receiver_agent_id="agent-a", + request_id=rid, + actor_agent_id=None, + operation="inspect", + )["view"] + + +def commands(): + try: + for line in sys.stdin: + operation = line.strip() + if operation == "inspect": + result = inspect() + elif operation == "receiver": + packet = cli( + "manager-inbox", + "read", + "--goal-id", + ref["goal_id"], + "--agent-id", + "agent-a", + ) + item = next(x for x in packet["items"] if x["request_id"] == rid) + cli( + "manager-inbox", + "acknowledge", + "--goal-id", + ref["goal_id"], + "--agent-id", + "agent-a", + "--request-id", + rid, + "--decision", + "adopt", + "--reason", + "Use current source", + "--request-revision", + str(item["request_revision"]), + "--request-digest", + item["request_revision_digest"], + ) + result = {"receiver_revision": item["request_revision"]} + elif operation == "interim": + current = inspect() + result = cli( + "manager-inbox", + "report", + "--goal-id", + ref["goal_id"], + "--agent-id", + "agent-a", + "--request-id", + rid, + "--phase", + "decision", + "--reply-text", + "Checking the corrected request", + "--request-revision", + str(current["revision"]), + ) + from loopx.capabilities.manager_context.roundtrip import drain + + drain(runtime, registry, store, lambda *_: None) + elif operation == "reserve": + v = goal("inspect") + goal("adopt", {**basis(v), "operation_id": "adopt-root"}, execute=True) + source = inspect() + reference = { + "request_id": rid, + "request_revision": source["revision"], + "request_digest": source["revision_digest"], + } + proposed = goal( + "propose", + { + **basis(v), + "source_request": reference, + "operation_id": "source-proposal", + "proposal_id": "request-work", + "template_id": "check", + "evidence_refs": ["evidence:request-gap"], + "replan_obligation_id": v["replan_observations"][0][ + "obligation_id" + ], + }, + execute=True, + ) + assert proposed["ok"], proposed + from loopx.control_plane.collaboration import ( + request_amendment as io_adapter, + ) + + real = io_adapter._goal + + def lost(*a, **kw): + if a[4] == "commit" and kw.get("execute"): + raise OSError("Synthetic loss before known Goal outcome") + return real(*a, **kw) + + io_adapter._goal = lost + try: + result = goal( + "commit", + { + "operation_id": "reserved-effect", + "source_request": reference, + "proposal_id": "request-work", + "proposal_digest": proposed["proposal"]["digest"], + }, + execute=True, + ) + finally: + io_adapter._goal = real + assert result["request_linked"] is False and result["effect"] is None, ( + result + ) + elif operation == "correct": + current = inspect() + result = execute_request_control( + registry_path=registry, + runtime_root=runtime, + goal_ref=ref, + receiver_agent_id="agent-a", + request_id=rid, + actor_agent_id=None, + operation="revise", + parameters={ + "operation_id": "separate-owner-correction", + "expected_revision": current["revision"], + "expected_digest": current["revision_digest"], + "brief": { + **current["brief"], + "context": "Correction from another authorized view", + }, + "reason": "Source changed", + }, + execute=True, + ) + else: + raise ValueError(operation) + print("FIXTURE " + json.dumps(result), flush=True) + except Exception as exc: + print("FIXTURE " + json.dumps({"fixture_error": str(exc)}), flush=True) + + +threading.Thread(target=commands, daemon=True).start() +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +try: + serve_chat( + registry_path=registry, + runtime_root_override=runtime, + scan_roots=[root / "project"], + port=port, + codex_bin="missing-synthetic-codex", + claude_bin="missing-synthetic-claude", + kiro_cli_bin="missing-synthetic-kiro", + ) +finally: + restart_effect_runtime() diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index 539ac72655..4c9ae4d749 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -88,6 +88,9 @@ export type CollaborationReadback = { agent_id: string; goal_id?: string; decision_reason?: string; + request_revision?: number; + control_route?: import("./request-control").RequestControlRoute; + request_control?: import("./request-control").RequestControlView | null; brief: { purpose: string; context: string; @@ -98,7 +101,7 @@ export type CollaborationReadback = { }; read_status: string; decision: string; - returns: { phase: string; status: string }[]; + returns: { phase: string; status: string; request_revision?: number | null }[]; }; export type ChatRouteCandidate = { diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 5cdabd5272..28de57040f 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -827,6 +827,7 @@ export type ChatVisibleMessage = { return_delivery?: { schema_version: "manager_return_delivery_status_v0"; phase: "decision" | "conclusion"; + request_revision?: number | null; status: string; created_at?: string | null; delivered_at?: string | null; diff --git a/apps/presentation/dashboard/src/data/request-control.ts b/apps/presentation/dashboard/src/data/request-control.ts new file mode 100644 index 0000000000..4d57e8a422 --- /dev/null +++ b/apps/presentation/dashboard/src/data/request-control.ts @@ -0,0 +1,47 @@ +import {z} from "zod"; +import {requestJson} from "./chat"; +import {REQUEST_CONTROL_SCHEMA, REQUEST_AMENDMENT_OUTCOMES, REQUEST_CONTROL_STATES, + REQUEST_OBSERVATION_STATES} from "../../../../../loopx/control_plane/collaboration/request_control_contract.js"; + +export const requestControlRouteSchema = z.object({available: z.boolean().optional(), goal_id: z.string(), goal_instance_id: z.string(), + agent_id: z.string(), request_id: z.string(), session_id: z.string(), turn_id: z.string()}); +export type RequestControlRoute = z.infer; +export function requestControlRouteKey(route: RequestControlRoute) { + return JSON.stringify([route.goal_id, route.goal_instance_id, route.agent_id, route.request_id, route.session_id, route.turn_id]); +} +export const requestBriefSchema = z.object({schema_version: z.literal("collaboration_brief_v0"), purpose: z.string(), context: z.string(), + constraints: z.array(z.string()), inputs: z.array(z.object({ref: z.string(), description: z.string(), sha256: z.string().optional(), + delegation: z.object({operation_id: z.string(), ref: z.string(), relation: z.enum(["responds_to", "revises", "uses"])}).optional()})), + acceptance: z.array(z.string()), return_requirement: z.string()}); +export type RequestBrief = z.infer; +const terminalSchema = z.object({operation_id: z.string(), reservation_digest: z.string(), outcome: z.enum(REQUEST_AMENDMENT_OUTCOMES), + goal_changed: z.boolean(), goal_revision: z.number().nullable(), todo_id: z.string().nullable(), receipt_sha256: z.string(), + goal_ref: z.object({goal_id: z.string(), goal_instance_id: z.string()})}); +export const requestControlViewSchema = z.object({schema_version: z.literal(REQUEST_CONTROL_SCHEMA), + request_id: z.string(), goal_ref: z.object({goal_id: z.string(), goal_instance_id: z.string()}), receiver_agent_id: z.string(), + revision: z.number().int().positive(), control_version: z.number().int().nonnegative(), revision_digest: z.string(), brief: requestBriefSchema, + reason: z.string(), read_at: z.string().nullable(), read_readback: z.enum(REQUEST_OBSERVATION_STATES), + assessment: z.object({decision: z.enum(["adopt", "defer", "reject", "no_change"]), reason: z.string(), assessed_at: z.string()}).nullable(), + assessment_readback: z.enum(REQUEST_OBSERVATION_STATES), + control_state: z.enum(REQUEST_CONTROL_STATES), + cancellation: z.object({operation_id: z.string(), reason: z.string(), requested_at: z.string()}).nullable(), + active_reservation: z.object({digest: z.string(), binding: z.object({request_revision: z.number(), operation_id: z.string()})}).nullable(), + terminal_effects: z.array(z.object({digest: z.string(), binding: z.object({request_revision: z.number(), operation_id: z.string()}), terminal: terminalSchema})), + execution_authority_granted: z.literal(false)}); +export type RequestControlView = z.infer; +export const pendingRequestControlSchema = z.object({route: requestControlRouteSchema, operation: z.enum(["revise", "cancel", "recover"]), + parameters: z.record(z.string(), z.unknown()), execute: z.literal(true)}); +export type PendingRequestControl = z.infer; +const responseSchema = z.object({ok: z.boolean(), view: requestControlViewSchema.nullable().optional(), + request_control: requestControlViewSchema.nullable().optional(), effect: terminalSchema.nullable().optional(), + readback_errors: z.array(z.string()).optional(), effect_recovery: z.object({readback_errors: z.array(z.string()).optional()}).optional()}); +function routing(route: RequestControlRoute) {const {available: _available, ...fields} = route; return fields;} +export async function fetchRequestControl(route: RequestControlRoute) { + const response = responseSchema.parse(await requestJson(`/api/chat/request-control?${new URLSearchParams(routing(route))}`)); + if (!response.ok || !response.view) throw new Error("Current request readback is unavailable."); + return response.view; +} +export async function applyRequestControl(request: PendingRequestControl, execute = true) { + return responseSchema.parse(await requestJson("/api/chat/request-control", {method: "POST", + body: JSON.stringify({...routing(request.route), operation: request.operation, parameters: request.parameters, execute})})); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css index 63d5a2cf29..c9a01e4019 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.css @@ -8,3 +8,16 @@ .personal-collaboration ul { padding-left: 20px; } .personal-collaboration small { display: block; font-family: var(--font-mono); font-size: var(--text-caption); line-height: var(--leading-caption); } .personal-collaboration summary:focus-visible { outline: 2px solid var(--color-link, #0070f3); outline-offset: 2px; } +.personal-request-control { border-top: 1px solid var(--pw-line); margin-top: 16px; padding-top: 12px; } +.personal-request-control form, .personal-request-control label { display: grid; gap: 6px; } +.personal-request-control form { gap: 12px; margin-top: 12px; } +.personal-request-control input, .personal-request-control textarea { width: 100%; min-width: 0; box-sizing: border-box; } +.personal-request-control textarea { min-height: 72px; resize: vertical; } +.personal-request-control fieldset { min-width: 0; margin: 0; border: 0; padding: 0; } +.personal-request-input { display: grid; gap: 8px; margin: 10px 0; padding-bottom: 10px; border-bottom: 1px solid var(--pw-line); } +.personal-request-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.personal-request-help { color: var(--pw-muted); } +.personal-request-control button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: inherit; cursor: pointer; } +.personal-request-control button:disabled { opacity: .5; cursor: default; } +.personal-request-control :is(button, input, textarea):focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } +.personal-request-control :is(input, textarea) { padding: 8px 10px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx index a73cf7b34b..64d097b870 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/collaboration-card.tsx @@ -1,4 +1,7 @@ import type { CollaborationReadback } from "../../data/chat-model"; +import {useEffect, useState} from "react"; +import type {RequestControlView} from "../../data/request-control"; +import {RequestControl} from "./request-control"; import { useWorkspaceI18n } from "./i18n"; import "./collaboration-card.css"; @@ -23,11 +26,26 @@ const copy = { export function CollaborationCard({ request }: { request?: CollaborationReadback }) { const { locale } = useWorkspaceI18n(); + const [observed, setObserved] = useState(null); + const [unavailable, setUnavailable] = useState(false); + const [editing, setEditing] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + useEffect(() => {setObserved(null); setUnavailable(false);}, [request?.request_id]); + useEffect(() => { + if (request?.request_control && observed && request.request_control.control_version >= observed.control_version) { + setObserved(null); setUnavailable(false); + } + }, [request?.request_control]); if (!request) return null; const c = copy[locale]; - const brief = request.brief; + const brief = observed?.brief ?? request.brief; + const revision = observed?.revision ?? request.request_revision; + const readStatus = unavailable ? "unavailable" : observed ? observed.read_readback === "recorded" ? "supplied" + : observed.read_readback === "unavailable" ? "unavailable" : "pending" : request.read_status; + const recordedDecision = unavailable ? "unavailable" : observed ? observed.assessment?.decision + ?? (observed.assessment_readback === "unavailable" ? "unavailable" : "pending") : request.decision; const decision = (() => { - switch (request.decision) { + switch (recordedDecision) { case "adopt": return c.adopt; case "defer": return c.defer; case "reject": return c.reject; @@ -36,23 +54,30 @@ export function CollaborationCard({ request }: { request?: CollaborationReadback default: return c.unknown; } })(); - const conclusion = request.returns.find((reply) => reply.phase === "conclusion"); + const conclusion = request.returns.find((reply) => reply.phase === "conclusion" && + (revision === undefined || (reply.request_revision === undefined ? 1 : reply.request_revision) === revision)); + const decisionReason = observed ? observed.assessment?.reason : request.decision_reason; return
{brief.purpose}{c.owner}: {request.goal_id ? `${request.goal_id} / ` : ""}{request.agent_id}

- {request.read_status === "supplied" ? c.supplied : request.read_status === "unavailable" ? c.unavailable : c.pending} + {revision !== undefined ? {locale === "zh-CN" ? "请求版本" : "Request revision"} {revision} : null} + {readStatus === "supplied" ? c.supplied : readStatus === "unavailable" ? c.unavailable : c.pending} {c.decision}: {decision} {conclusion ? {conclusion.status === "delivered" ? c.delivered : conclusion.status === "queued" ? c.result : c.unverified} : null}

- {request.decision_reason ?

{c.reason}: {request.decision_reason}

: null} -
+ {decisionReason && !unavailable ?

{c.reason}: {decisionReason}

: null} +
setDetailsOpen(event.currentTarget.open)}> {c.details} + {request.control_route ? {setObserved(value); setUnavailable(value === null);}} onEditing={setEditing} /> : null} + {!editing && <>

{c.context}

{brief.context}

{brief.constraints.length ? <>

{c.constraints}

    {brief.constraints.map((item, i) =>
  • {item}
  • )}
: null} {brief.inputs.length ? <>

{c.inputs}

    {brief.inputs.map((input, i) =>
  • {input.ref} — {input.description}{input.sha256 ? sha256:{input.sha256} : null}
  • )}
: null}

{c.acceptance}

    {brief.acceptance.map((item, i) =>
  • {item}
  • )}

{c.return}

{brief.return_requirement}

+ }
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 6631c1f8c0..126f6184ef 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -393,6 +393,7 @@ export type WorkspaceReturnDelivery = { error?: string | null; status: string; verification?: "reconciled_after_restart"; + request_revision?: number | null; }; export type WorkspaceImageAttachment = { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx b/apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx new file mode 100644 index 0000000000..e158d9d0f5 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/request-control.tsx @@ -0,0 +1,161 @@ +import {useCallback, useEffect, useRef, useState} from "react"; +import {ChatApiError} from "../../data/chat"; +import {applyRequestControl, fetchRequestControl, pendingRequestControlSchema, requestControlRouteKey, + type PendingRequestControl, type RequestBrief, type RequestControlRoute, type RequestControlView} from "../../data/request-control"; +import {useWorkspaceI18n} from "./i18n"; + +const words = { + "zh-CN": {open: "修正或取消请求", refresh: "重新读取", edit: "修正内容", save: "保存修正", cancel: "取消请求", recover: "核对原操作", + revision: "请求版本", pending: "目标变更仍待核对", cancelling: "取消已记录,原操作仍待核对", cancelled: "请求已取消", current: "当前请求", + committed: "已写入目标", aborted: "已中止,未写入目标", rejected: "目标未接受这项变更", goalRevision: "目标版本", + limitation: "取消会停止这项请求的新变更;已经写入目标的任务会保留。", retained: "已记录的变更不代表任务执行或验收完成。", + purpose: "请求内容", context: "背景与补充", constraints: "约束(每行一项)", acceptance: "验收要求(每行一项)", expected: "需要回传", + inputs: "输入材料", ref: "工作区内的相对路径", description: "材料说明", digest: "版本摘要(可选)", add: "添加材料", remove: "移除材料", + reason: "修正原因(可选)", defaultReason: "在原对话中修正请求", cancelReason: "原请求发起者取消请求", close: "收起编辑", + unknown: "结果尚未确认。原操作已保留,可重新核对。", retry: "重试原操作", stale: "请求已有新修订。已重新读取,请基于当前内容修改。", + historical: "这是历史 Goal 实例的请求,当前界面仅保留回读。", loading: "正在读取请求…", error: "暂时无法读取当前请求。", storage: "无法保存恢复信息,本次操作尚未发送。", + }, + en: {open: "Revise or cancel request", refresh: "Refresh", edit: "Revise content", save: "Save revision", cancel: "Cancel request", recover: "Recover original operation", + revision: "Request revision", pending: "Goal effect needs reconciliation", cancelling: "Cancellation recorded; original operation still needs reconciliation", cancelled: "Request cancelled", current: "Current request", + committed: "Committed to Goal", aborted: "Aborted without a Goal change", rejected: "Goal change rejected", goalRevision: "Goal revision", + limitation: "Cancellation stops new changes for this request. Work already committed to the Goal is retained.", retained: "A recorded change does not prove task execution or acceptance.", + purpose: "Request", context: "Context & corrections", constraints: "Constraints (one per line)", acceptance: "Acceptance (one per line)", expected: "Expected return", + inputs: "Input materials", ref: "Relative workspace path", description: "Description", digest: "Version digest (optional)", add: "Add material", remove: "Remove material", + reason: "Revision reason (optional)", defaultReason: "Request revised in the original conversation", cancelReason: "Original requester cancelled the request", close: "Close editor", + unknown: "The outcome is not confirmed. The original operation is retained for recovery.", retry: "Retry original operation", stale: "The request changed. Its current revision has been loaded for editing.", + historical: "This request belongs to a historical Goal instance and remains read-only here.", loading: "Reading the request…", error: "Current request readback is unavailable.", storage: "Recovery data could not be saved. The operation was not sent.", + }, +}; +const lines = (value: string) => value.split("\n").map(row => row.trim()).filter(Boolean); + +export function RequestControl({route, active, onReadback, onEditing}: {route: RequestControlRoute; active: boolean; + onReadback: (view: RequestControlView | null) => void; onEditing: (editing: boolean) => void}) { + const {locale} = useWorkspaceI18n(); const c = words[locale]; + const [opened, setOpened] = useState(false), [view, setView] = useState(null); + const [draft, setDraft] = useState(null), [reason, setReason] = useState(""); + const [pending, setPending] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(""); + const generation = useRef(0), onRead = useRef(onReadback); onRead.current = onReadback; + const editNotice = useRef(onEditing); editNotice.current = onEditing; + useEffect(() => {editNotice.current(draft !== null);}, [draft !== null]); + const storageKey = `loopx.request-control.${route.goal_instance_id}.${route.session_id}.${route.request_id}`; + const routeKey = requestControlRouteKey(route); + const show = useCallback((value: RequestControlView | null) => {setView(value); onRead.current(value);}, []); + const remember = (value: PendingRequestControl | null) => { + if (value) { + const serialized = JSON.stringify(value); + sessionStorage.setItem(storageKey, serialized); + // As with steering retention, dispatch only after the complete original + // request can be read back for an exact retry after response loss/reload. + if (sessionStorage.getItem(storageKey) !== serialized) throw new Error(c.storage); + } else sessionStorage.removeItem(storageKey); + setPending(value); + }; + const load = useCallback(async () => { + const observed = ++generation.current; + try { + const value = await fetchRequestControl(route); + if (observed === generation.current) {show(value); return value;} + } catch (failure) { + if (observed === generation.current) {show(null); setError(failure instanceof Error ? failure.message : c.error);} + } + return null; + }, [routeKey, show, c.error]); // Route identity, not caller object allocation, owns this read. + useEffect(() => { + generation.current++; setOpened(false); setDraft(null); setReason(""); setPending(null); setError(""); setView(null); setBusy(false); + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) { + const saved = pendingRequestControlSchema.parse(JSON.parse(raw)); + if (requestControlRouteKey(saved.route) === routeKey) {setPending(saved); setOpened(true); void load();} + } + } catch {setError(c.unknown);} + return () => {generation.current++;}; + }, [storageKey, routeKey]); + useEffect(() => { + if (active && !opened && route.available !== false) {setOpened(true); void load();} + }, [active, opened, route.available, load]); + + async function submit(request: PendingRequestControl) { + const observed = ++generation.current; + setBusy(true); setError(""); + if (pending === null && request.operation !== "recover") { + try { + await applyRequestControl(request, false); + if (observed !== generation.current) return; + } catch (failure) { + if (observed !== generation.current) return; + setBusy(false); + if (failure instanceof ChatApiError && failure.payload.error_code === "request_basis_changed") { + setDraft(null); await load(); setError(c.stale); + } else setError(failure instanceof Error ? failure.message : c.error); + return; + } + } + try {remember(request);} catch {setBusy(false); setError(c.storage); return;} + try { + const result = await applyRequestControl(request); + if (observed !== generation.current) return; + const current = result.view ?? result.request_control ?? null; + show(current); + if (!result.ok || !current) { + setError(result.readback_errors?.join(" ") || result.effect_recovery?.readback_errors?.join(" ") || c.unknown); + return; + } + remember(null); setDraft(null); setReason(""); + } catch (failure) { + if (observed !== generation.current) return; + show(null); + if (failure instanceof ChatApiError && failure.payload.error_code === "request_basis_changed") { + remember(null); setDraft(null); setBusy(false); + await load(); setError(c.stale); + } else setError(failure instanceof Error ? `${c.unknown} ${failure.message}` : c.unknown); + } finally {if (observed === generation.current) setBusy(false);} + } + function operation(kind: "revise" | "cancel" | "recover") { + if (kind !== "recover" && !view) return; + const parameters = kind === "recover" ? {} : {operation_id: `request-${kind}-${crypto.randomUUID()}`, + expected_revision: view!.revision, expected_digest: view!.revision_digest, + reason: kind === "cancel" ? c.cancelReason : reason.trim() || c.defaultReason, + ...(kind === "revise" ? {brief: draft} : {})}; + void submit({route, operation: kind, parameters, execute: true}); + } + if (route.available === false) return

{c.historical}

; + if (!opened) return null; + const terminal = view?.terminal_effects.at(-1); + return
+ {!view &&

{busy ? c.loading : c.error}

} + {view && view.control_state !== "open" &&

{view.control_state === "cancelled" ? c.cancelled : view.control_state === "cancellation_pending" ? c.cancelling + : view.control_state === "effect_pending" ? c.pending : c.current}

} + {terminal &&

{c.revision} {terminal.binding.request_revision}: {c[terminal.terminal.outcome]} + {terminal.terminal.goal_revision !== null ? ` · ${c.goalRevision} ${terminal.terminal.goal_revision}` : ""}

} + {view?.terminal_effects.length ?

{c.retained}

: null} +

{c.limitation}

+ {error &&

{error}

} +
+ + {pending ? : <> + {view && !view.cancellation && } + {view && !view.cancellation && } + {view?.active_reservation && } + } +
+ {draft &&
{event.preventDefault(); operation("revise");}}> + +