From 924998cb73f45b28c10a9dea23057be1a9087731 Mon Sep 17 00:00:00 2001 From: Kyle Seaman Date: Sun, 23 Aug 2026 04:02:30 -0300 Subject: [PATCH] feat(spec-builder): add approvals and per-task runs Add persisted phase approvals, per-task execution, lifecycle controls, and crash-safe duplicate publication to Spec Builder. Preserve channel project changes across slotless directive handling, provider replacement, resume, and shutdown. --- .github/black-baseline.txt | 4 - docs/guides/windows-install.md | 1 + docs/system-specs/modules/messaging.md | 2 +- .../builtins/spec_builder/backend/routes.py | 2253 ++++++++++- .../skills/spec-workflow/SKILL.md | 8 + .../builtins/spec_builder/tests/conftest.py | 6 + .../spec_builder/tests/test_routes.py | 3403 +++++++++++++++-- src/kiro_crew/platform_compat.py | 67 + .../01-task-controls-dark.png | Bin 0 -> 215342 bytes .../02-mobile-duplicate-form-dark.png | Bin 0 -> 71335 bytes test/test_platform_compat.py | 39 + test/test_spec_builder_routes_coverage.py | 53 +- .../src/apps/spec-builder/SpecBuilderPage.tsx | 6 +- website/src/apps/spec-builder/api.ts | 117 +- .../apps/spec-builder/components/DocView.tsx | 48 +- .../spec-builder/components/NewSpecView.tsx | 2 +- .../spec-builder/components/ProjectPicker.tsx | 4 +- .../spec-builder/components/SettingsModal.tsx | 2 +- .../spec-builder/components/SpecDetail.tsx | 305 +- .../apps/spec-builder/components/SpecRail.tsx | 18 +- .../apps/spec-builder/components/TaskList.tsx | 134 + .../spec-builder/components/Workspace.tsx | 2 +- website/src/components/CliPanel.tsx | 8 + website/src/i18n/en.context.json | 1 + website/src/i18n/locales/bn.json | 23 +- website/src/i18n/locales/de.json | 23 +- website/src/i18n/locales/en-XA.json | 23 +- website/src/i18n/locales/en.json | 23 +- website/src/i18n/locales/es.json | 23 +- website/src/i18n/locales/fr.json | 23 +- website/src/i18n/locales/hi.json | 23 +- website/src/i18n/locales/it.json | 23 +- website/src/i18n/locales/ja.json | 23 +- website/src/i18n/locales/ko.json | 23 +- website/src/i18n/locales/pt.json | 23 +- website/src/i18n/locales/ru.json | 23 +- website/src/i18n/locales/zh-CN.json | 23 +- website/src/test/App.test.tsx | 19 + website/src/test/CliPanelCoverage.test.tsx | 3 + website/src/test/SpecBuilderApiAbort.test.tsx | 96 + website/src/test/SpecBuilderPage.test.tsx | 3 +- .../src/test/SpecBuilderPageCov80.test.tsx | 30 +- .../test/SpecBuilderRailHorizontal.test.tsx | 23 +- .../SpecBuilderSpecDetailCoverage.test.tsx | 159 +- website/src/test/SpecBuilderTaskList.test.tsx | 110 + website/src/test/SpecBuilderWriteApi.test.tsx | 78 + website/src/test/opsMissionControl.test.ts | 3 - 47 files changed, 6732 insertions(+), 574 deletions(-) create mode 100644 temp-screenshots/spec-builder-execution-controls/01-task-controls-dark.png create mode 100644 temp-screenshots/spec-builder-execution-controls/02-mobile-duplicate-form-dark.png create mode 100644 website/src/apps/spec-builder/components/TaskList.tsx create mode 100644 website/src/test/SpecBuilderApiAbort.test.tsx create mode 100644 website/src/test/SpecBuilderTaskList.test.tsx create mode 100644 website/src/test/SpecBuilderWriteApi.test.tsx diff --git a/.github/black-baseline.txt b/.github/black-baseline.txt index c4556296158..19ef053c197 100644 --- a/.github/black-baseline.txt +++ b/.github/black-baseline.txt @@ -156,8 +156,6 @@ src/kiro_crew/apps/builtins/personal_shopper/tests/test_routes.py src/kiro_crew/apps/builtins/personal_shopper/tests/test_store.py src/kiro_crew/apps/builtins/pptx_maker/backend/engine_source.py src/kiro_crew/apps/builtins/pptx_maker/backend/library.py -src/kiro_crew/apps/builtins/spec_builder/backend/routes.py -src/kiro_crew/apps/builtins/spec_builder/tests/test_routes.py src/kiro_crew/apps/builtins/workflows/server.py src/kiro_crew/apps/cron_sdk.py src/kiro_crew/apps/dependencies.py @@ -422,7 +420,6 @@ src/kiro_crew/service/apparmor.py src/kiro_crew/service/common.py src/kiro_crew/service/linux.py src/kiro_crew/service/macos.py -src/kiro_crew/session.py src/kiro_crew/session_directive.py src/kiro_crew/session_pid_sig.py src/kiro_crew/session_workspace.py @@ -1172,7 +1169,6 @@ test/test_sel_prune_streaming.py test/test_send_message_targeted.py test/test_service.py test/test_serving_loop_unified.py -test/test_session.py test/test_session_cleanup.py test/test_session_core_audit.py test/test_session_coverage.py diff --git a/docs/guides/windows-install.md b/docs/guides/windows-install.md index ad8de50a05c..991502b618d 100644 --- a/docs/guides/windows-install.md +++ b/docs/guides/windows-install.md @@ -240,6 +240,7 @@ while the other 503s. Concretely: | Script hooks (Settings → Hooks) | need the `agent.sandbox_allow_unsandboxed_exec` opt-in above (like script crons — the hook command routes through `wrap_argv`, which fail-closes where no OS sandbox backend exists; without it the hook returns that message as its `error`). With the opt-in they run in **cmd.exe** language: a hook `command` runs as `%ComSpec% /c ""`, so read the context env vars as `%KIROCREW_HOOK_EVENT%` / `%KIROCREW_HOOK_CONTEXT%` (not `$VAR`), and group arguments with double quotes only (cmd.exe gives `'…'` no meaning). The line reaches cmd.exe verbatim, so a quoted interpreter path with a space works. A hook authored on macOS/Linux is not portable and must be rewritten | | Pull-request source drawer provider fetch/check/resolve | not yet — and for a different reason than it used to be. The provider-CLI **trust** check now works here (see Issue Radar below), but the drawer does not share Issue Radar's spawn: it keeps its own async, sandbox-routed one (`source_providers._run_json`), which refuses on Windows because no OS sandbox backend exists. So the blocker is the sandbox, not the binary check | | Issue Radar | works — its `gh` spawn is not sandbox-routed, so the trust check is the only gate, and that is answered by reading the binary's Windows ACL (`kiro_crew.windows_acl`) in place of the POSIX `st_uid` + write-bit walk, which reports nothing on this platform. Refused when any principal outside `{you, SYSTEM, Administrators, TrustedInstaller}` can replace the binary or a parent directory, when the security descriptor is unreadable, or when the gateway token is **elevated** (an elevated gateway spawns elevated children, which makes the walk vacuous). GitHub only on this platform unless `glab` is installed. **If a `gh` you trust is refused**, the override variables (`KIROCREW_ISSUE_RADAR_GH`, `KIROCREW_GH_BIN`) re-enter the same check rather than bypassing it, so the recourse is to install `gh` somewhere only you and the system can write — a per-user `%LOCALAPPDATA%` install is accepted — or to file an issue quoting the refusal, which names the offending principal or the ACE type it could not evaluate | +| Spec Builder | works, except **Duplicate** — crash-safe copy publication pins a staging directory and uses the platform's atomic no-replace rename (`renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS). Windows provides neither that native contract nor CPython's directory-descriptor operations, so the backend reports the capability as unavailable and the dashboard omits Duplicate instead of falling back to a check-then-rename race or a junction-prone path write. Approval, per-task runs, labels, archive/restore, chat, and whole-plan execution work normally | | Code Review Sage | not yet — the provider-CLI trust check now passes, but its review worker hands the session `python3 sage_lib/…` commands and `python3` is not an interpreter on Windows (the name resolves to the Microsoft Store app-execution alias, or to nothing). It refuses with that reason rather than starting a review that produces no result | | Browser automation (`playwright-cli`) | works (`npm install -g @playwright/cli@latest`, needs Node.js 20 or newer) | | Vector memory / embeddings | works — embeddings run **in-process** through the vendored llama-cpp-python (`_vendor/llama_cpp_libs/win_amd64`), which loads the Qwen3-Embedding-0.6B GGUF from `~/.kiro/crew/models`. No remote endpoint, no Docker and no Ollama server is involved on any platform | diff --git a/docs/system-specs/modules/messaging.md b/docs/system-specs/modules/messaging.md index 506e70084b9..7d888869de8 100644 --- a/docs/system-specs/modules/messaging.md +++ b/docs/system-specs/modules/messaging.md @@ -97,7 +97,7 @@ Consumes a provider's `AcpEvent` stream and emits abstract `OutputEvent`s to a p The dashboard does **not** flow through `TurnDriver`; it remains unchanged as the authoritative transcript surface. Direct channel paths that bypass the driver are sanitized at source: Discord's explicit five-message resume replay strips legacy steering frames and summary-bearing compaction notices, shortens each entry to the shared splitter's first (sealed) chunk so a replayed code block cannot arrive with its fence cut in half, and puts the role icon on its own line so the body's first line still starts where the fence grammar needs it; direct compact commands publish only terse receipts. Stored transcripts remain intact for audit. -**Session-directive consumption** — an optional `directive_consumer` callback (`(kind, args) -> awaitable`) makes the driver the channel-side consumer of the stateless session-directive protocol (`session_directive.py`): the trusted `_meta.kiro` identity is resolved by the shared forgery-gate predicate (`session_directive.directive_tool_for(mcp_server_name, tool_name)`, the same single spelling the dashboard consumer uses) and recorded at `EVENT_TOOL_CALL`, and the matching `EVENT_TOOL_RESULT`'s marker is decoded and handed to the consumer — single-consume across result frames, forged markers under any other tool ignored, `encode()` refusals logged, a lost marker on the final frame logged at WARNING. A tool call announced as a NATIVE sub-agent's (`EVENT_SUBAGENT_ACTIVITY` with a `tool_call_id`) is refused with a SEL `denied` audit rather than applied — a child session must never arm/mutate its parent, mirroring the dashboard consumer's isolation. Dispatchers inject `messaging.dispatch.build_directive_consumer(session_key=…, sessions=…, dispatcher=…)`, which funnels into the same `apply_session_directive` core the dashboard consumer uses with `slot=None` (so dashboard-only directives stay refused for channel turns). The monitor trio takes effect where the session is nudge-able (`slack:`/`discord:`); on the other six transports (Telegram, iMessage, Teams, Webex, WeCom, Weixin) the applier answers "not supported from this session type" — logged and SEL-audited instead of the old silent drop, but no loop is armed there until `autonudge.binding_key_for` admits those keys. Without a consumer, directive markers are inert exactly as before. +**Session-directive consumption** — an optional `directive_consumer` callback (`(kind, args) -> awaitable`) makes the driver the channel-side consumer of the stateless session-directive protocol (`session_directive.py`): the trusted `_meta.kiro` identity is resolved by the shared forgery-gate predicate (`session_directive.directive_tool_for(mcp_server_name, tool_name)`, the same single spelling the dashboard consumer uses) and recorded at `EVENT_TOOL_CALL`, and the matching `EVENT_TOOL_RESULT`'s marker is decoded and handed to the consumer — single-consume across result frames, forged markers under any other tool ignored, `encode()` refusals logged, a lost marker on the final frame logged at WARNING. A tool call announced as a NATIVE sub-agent's (`EVENT_SUBAGENT_ACTIVITY` with a `tool_call_id`) is refused with a SEL `denied` audit rather than applied — a child session must never arm/mutate its parent, mirroring the dashboard consumer's isolation. Dispatchers inject `messaging.dispatch.build_directive_consumer(session_key=…, sessions=…, dispatcher=…)`, which funnels into the same `apply_session_directive` core the dashboard consumer uses with `slot=None` (so card-producing dashboard-only directives stay refused for channel turns). Channel `set_project` writes the durable per-conversation project/CWD override; because its tool result arrives while the current provider still owns the turn semaphore, the provider is not killed in place. The next claimant acquires the old semaphore, replaces that provider, and cold-starts in the new CWD before sending its prompt. The monitor trio takes effect where the session is nudge-able (`slack:`/`discord:`); on the other six transports (Telegram, iMessage, Teams, Webex, WeCom, Weixin) the applier answers "not supported from this session type" — logged and SEL-audited instead of the old silent drop, but no loop is armed there until `autonudge.binding_key_for` admits those keys. Without a consumer, directive markers are inert exactly as before. **`run(message) -> str`** — calls `renderer.on_turn_start()`, then translates each provider event into a dispatched `OutputEvent` and returns the accumulated (redacted) assistant text: diff --git a/src/kiro_crew/apps/builtins/spec_builder/backend/routes.py b/src/kiro_crew/apps/builtins/spec_builder/backend/routes.py index 5298ad2c222..49751371e40 100644 --- a/src/kiro_crew/apps/builtins/spec_builder/backend/routes.py +++ b/src/kiro_crew/apps/builtins/spec_builder/backend/routes.py @@ -17,14 +17,17 @@ * Handoff: inject an execution instruction into the spec's session and arm an autonudge loop so it works through ``tasks.md`` autonomously. """ + from __future__ import annotations import asyncio +import hashlib import json import logging import math import os import re +import stat import threading import time import uuid @@ -38,6 +41,7 @@ from kiro_crew.atomic_write import atomic_write from kiro_crew.config.paths import config_dir from kiro_crew.dashboard.chat_persistence import rehydrate_slot_from_history_async +from kiro_crew.platform_compat import RENAME_NOREPLACE_AVAILABLE, rename_noreplace try: from kiro_crew.security import ( @@ -59,6 +63,7 @@ def is_sensitive_path(path: str) -> bool: # type: ignore[misc] """ return True + try: from kiro_crew.sel import sel except Exception: # pragma: no cover @@ -75,8 +80,9 @@ def is_sensitive_path(path: str) -> bool: # type: ignore[misc] CHAT_TURN_TIMEOUT = 1800 # type: ignore[assignment] try: - from kiro_crew.hooks import safe_read_file_bytes_nolink + from kiro_crew.hooks import _fd_real_path, safe_read_file_bytes_nolink except Exception: # pragma: no cover - hooks always present in prod + _fd_real_path = None # type: ignore[assignment] safe_read_file_bytes_nolink = None # type: ignore[assignment] try: @@ -169,6 +175,40 @@ def _known_status(value: object) -> str: # ── enablement gate ────────────────────────────────────────────────────────── +_DuplicateRecoveryState = dict[str, asyncio.Task[None] | None] +_DUPLICATE_RECOVERY_STATE: web.AppKey[_DuplicateRecoveryState] = web.AppKey( + "spec_builder_duplicate_recovery", dict +) +_SpecExecutionLocks = dict[str, asyncio.Lock] +_SPEC_EXECUTION_LOCKS: web.AppKey[_SpecExecutionLocks] = web.AppKey( + "spec_builder_execution_locks", dict +) + + +def _spec_execution_lock(request: web.Request, name: str) -> asyncio.Lock: + """Serialize the final dispatch boundary for one spec. + + Task-file validation awaits a worker thread. Execute and Delete must not claim + the same spec during that gap: Execute would start an overlapping run, while + Delete could tear down the slot the task is about to dispatch into. Handlers + share one aiohttp event loop, so publishing a lazily-created lock in the + per-Application mapping has no suspension point and is atomic with respect to + the other request handlers. + """ + locks = request.app.get(_SPEC_EXECUTION_LOCKS) + if locks is None: + # Direct handler callers (including focused tests and embedders) may not + # have gone through register_routes. Production registration always + # publishes this before the Application is frozen. + locks = {} + request.app[_SPEC_EXECUTION_LOCKS] = locks + lock = locks.get(name) + if lock is None: + lock = asyncio.Lock() + locks[name] = lock + return lock + + def _require_enabled(handler): """Deny requests when Spec Builder is disabled (deny-by-default). Routes are registered once at gateway startup, so a default-disabled / opt-in app would @@ -178,7 +218,13 @@ def _require_enabled(handler): @wraps(handler) async def _wrapped(request: web.Request) -> web.Response: if not await asyncio.to_thread(is_app_enabled, APP_NAME): - return web.json_response({"code": "app_disabled", "error": "spec-builder is disabled"}, status=403) + return web.json_response( + {"code": "app_disabled", "error": "spec-builder is disabled"}, status=403 + ) + # Handlers own the 401 response, but an unauthenticated probe must not + # trigger filesystem work before that gate runs. + if request.get("user") is not None: + await _ensure_duplicate_recovery(request.app) return await handler(request) return _wrapped @@ -350,7 +396,6 @@ def _save_settings(settings: dict) -> None: #: both -- so one lock keeps a concurrent pair from interleaving either write. _INDEX_LOCK = threading.Lock() - #: Cap on remembered deletions. Bounded so the file cannot grow without limit on #: an instance that creates and deletes specs repeatedly; the oldest entries fall #: off first, and a fallen-off directory becomes discoverable again (the same @@ -462,6 +507,11 @@ def _load_index() -> dict: and until then the entry is simply visible again. A reservation this process still owns is left strictly alone, which is what keeps an in-flight delete's own concurrent reads from cancelling its reservation underneath it. + + Duplicate recovery is deliberately NOT part of this read path. It renames or + removes files and must run once at app startup under the index lock, rather + than repeating those side effects on every list/detail poll until some later + mutation happens to persist the cleaned reservation. """ try: data = json.loads(_index_path().read_text()) @@ -475,9 +525,12 @@ def _load_index() -> dict: if isinstance(k, str) and _usable_name(k) and isinstance(v, dict) and _entry_is_usable(v) } if len(clean) != len(data): - logger.warning("spec index had %d malformed entries — ignoring them", - len(data) - len(clean)) - stale = [k for k, v in clean.items() if _DELETING in v and not _reservation_is_ours(v)] + logger.warning( + "spec index had %d malformed entries — ignoring them", len(data) - len(clean) + ) + stale = [ + k for k, v in clean.items() if _DELETING in v and not _reservation_is_ours(v, _DELETING) + ] for k in stale: clean[k].pop(_DELETING, None) if stale: @@ -575,6 +628,18 @@ def _apply() -> bool: #: cannot slip into the window. Hidden from the list while set. _DELETING = "deleting" +#: Set on a destination entry before duplicate starts writing its files. Keeping +#: the entry in the index reserves the name against a concurrent create, while +#: list/detail/mutation paths hide the not-yet-complete copy. +_DUPLICATING = "duplicating" + +#: Provenance marker carried inside a duplicate's hidden staging directory. +#: The directory is renamed into place only after every document is durable, so +#: this marker lets a restarted gateway distinguish its complete publication +#: from an unrelated directory at the same path. +_DUPLICATE_MARKER = ".kirocrew-duplicate" +_DUPLICATE_TOKEN_RE = re.compile(r"[0-9a-f]{32}") + #: Identity of THIS gateway process, stamped into a delete reservation so #: ``_load_index`` can tell a reservation this process still owns from one left #: behind by a process that is gone. @@ -588,14 +653,14 @@ def _apply() -> bool: _PROCESS_ID = f"{os.getpid()}:{uuid.uuid4().hex}" -def _reservation_is_ours(meta: dict) -> bool: - """True when a delete reservation belongs to a request in THIS process. +def _reservation_is_ours(meta: dict, field: str = _DELETING) -> bool: + """True when a reservation belongs to a request in THIS process. A pre-existing reservation from an older build stores a bare timestamp rather than a mapping; it has no owner, so it reads as foreign -- which is the right answer, because this process demonstrably did not write it. """ - held = meta.get(_DELETING) + held = meta.get(field) return isinstance(held, dict) and held.get("owner") == _PROCESS_ID @@ -662,7 +727,7 @@ def _apply(index: dict) -> bool: meta = index.get(name) if meta is None: return False - if meta.get(_DELETING): + if meta.get(_DELETING) or meta.get(_DUPLICATING): return False if expect_spec_dir is not None and str(meta.get("spec_dir", "")) != expect_spec_dir: return False @@ -767,11 +832,17 @@ def _contained(child: Path, root: Path) -> bool: #: ``os.replace(..., src_dir_fd=, dst_dir_fd=)`` call works (verified on Linux). _CAN_PIN_DIR = ( hasattr(os, "O_DIRECTORY") + and os.mkdir in os.supports_dir_fd and os.open in os.supports_dir_fd and os.unlink in os.supports_dir_fd and os.rename in os.supports_dir_fd ) +# Publishing a complete staging directory must be one atomic no-replace step. +# A separate existence check plus os.rename() is not equivalent: another writer +# can create an empty destination in between and POSIX rename then replaces it. +_CAN_PUBLISH_DIR_NOREPLACE = _CAN_PIN_DIR and RENAME_NOREPLACE_AVAILABLE + _BROWSE_SKIP = {"node_modules", "__pycache__", "venv", "env"} #: Cap on subdirectories returned by one browse call. A directory with tens of #: thousands of entries would otherwise produce a response the picker can't use @@ -843,7 +914,7 @@ def _owns_slot_key(name: str, key: str) -> bool: if key == legacy: return True prefix = legacy + "-" - return key.startswith(prefix) and bool(_SLOT_SUFFIX_RE.match(key[len(prefix):])) + return key.startswith(prefix) and bool(_SLOT_SUFFIX_RE.match(key[len(prefix) :])) #: name -> persisted slot key, rebuilt from every index read (see _load_index). @@ -878,6 +949,97 @@ def _new_slot_key(name: str) -> str: _PHASE_FILES = [("tasks", "tasks.md"), ("design", "design.md"), ("requirements", "requirements.md")] +#: ONE task line in ``tasks.md``: a bullet or ordered marker, then a checkbox, +#: then the task text. Group 1 is the box body (empty/blank = open, ``x``/``X`` = +#: done) and group 2 is the text. Accepts the ``-``/``*``/``+`` and ``1.``/``1)`` +#: markers Markdown allows, and a bare ``[]`` alongside ``[ ]``, because the list +#: is model-written and its marker style varies between runs. +#: +#: Deliberately the ONLY task-line pattern in this module. The handoff gate needs +#: "is there an open task", the detail endpoint needs the enumerated list, and the +#: per-task endpoint needs to address one of them; expressing those as separate +#: regexes would let the gate and the list disagree about what a task even is, +#: and the per-task run would then target a line the gate never counted. +_TASK_LINE_RE = re.compile( + r"^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[([ \t]?|[xX])\][ \t]*(.*)$", re.MULTILINE +) + +#: Documents the user may edit through the app. The spec directory also holds +#: ``.spec-state.json`` (agent-authored) and the STOP sentinel (a control), and +#: neither is a document a person should be able to PUT arbitrary text into. +_EDITABLE_DOCS = frozenset(f for _phase, f in _PHASE_FILES) + +#: Phases whose approval the app records. Matches ``ADVANCE`` in the SPA: there is +#: no "approve tasks" step, because approving the task list IS the handoff. +_APPROVABLE_PHASES = ("requirements", "design") + +#: Cap on tasks enumerated for one spec. A model-written list is normally tens of +#: lines; the bound stops a pathological file from inflating every detail poll. +_MAX_TASKS = 300 + + +def _sha256_text(text: str) -> str: + """Content hash used as an edit/approval fingerprint. + + Hex-encoded SHA-256 of the UTF-8 bytes. Two uses, both about a document + changing under someone: an editor sends back the hash it loaded so a save + that would overwrite an agent's newer write is refused, and an approval + records the hash it approved so the UI can say the document has moved since. + """ + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _parse_tasks(text: str) -> list[dict]: + """Enumerate ``tasks.md``'s checklist as addressable tasks. + + Each task carries its ``index`` (position among task lines, which is what the + UI renders and what the run endpoint addresses) and a ``hash`` of its text. + BOTH are required to act on one: an index alone is a moving target because the + agent rewrites this file between polls, so a click on "task 3" could dispatch + whatever ended up third. The hash pins the identity the user actually saw, and + a mismatch is refused rather than guessed at. + + The hash is derived from the RAW task body while only ``text`` is redacted for + egress. Hashing the redacted rendering would collapse different credentials to + the same identity, allowing an agent edit hidden by redaction to survive the + stale-click check. + + ``tasks.md`` stays the source of truth -- there is no sidecar task store. That + file is the interop contract with the Kiro IDE and CLI, which read and write + the same three documents, so a spec built here has to remain a spec they can + open. Progress is therefore DERIVED by re-parsing checkboxes rather than + tracked separately, and an agent (or a person) checking a box by hand shows up + without anything having to be told. + """ + tasks: list[dict] = [] + for match in _TASK_LINE_RE.finditer(text or ""): + body = (match.group(2) or "").strip() + if not body: + # A checkbox with no text is not something a user can be asked to run. + continue + tasks.append( + { + "index": len(tasks), + "text": _redact(body)[:_MAX_FIELD], + "done": match.group(1).strip().lower() == "x", + "hash": _sha256_text(body), + } + ) + if len(tasks) >= _MAX_TASKS: + break + return tasks + + +def _has_open_task(text: str) -> bool: + """True when ``tasks.md`` holds at least one UNCHECKED task. + + The predicate behind the handoff gate. Existence is not a plan: the prompt the + gate arms tells the agent to work through each unchecked task in order, so a + zero-byte, prose-only or fully-checked file gave the autonomous loop nothing to + act on while still reading as a finished Tasks phase. + """ + return any(not t["done"] for t in _parse_tasks(text)) + def _spec_file(spec_dir: Path, fname: str) -> Path | None: """Resolve ``spec_dir/fname`` for reading, or ``None`` if it isn't safe. @@ -939,7 +1101,7 @@ def _read_spec_text(spec_dir: Path, fname: str) -> str | None: return raw.decode("utf-8", errors="replace") -def _collect_spec_documents(spec_dir: Path) -> tuple[str, dict, dict | None]: +def _collect_spec_documents(spec_dir: Path) -> tuple[str, dict, dict | None, dict]: """Gather everything the detail endpoint needs off the filesystem. BLOCKING — call via ``asyncio.to_thread``. Bundled into one function so the @@ -948,7 +1110,7 @@ def _collect_spec_documents(spec_dir: Path) -> tuple[str, dict, dict | None]: documents, and read + normalize the agent-authored state file. """ phase = _derive_phase(spec_dir) - files = _read_spec_files(spec_dir) + files, docs, tasks = _read_spec_files(spec_dir) state: dict | None = None raw_text = _read_spec_text(spec_dir, ".spec-state.json") if raw_text is not None: @@ -956,7 +1118,15 @@ def _collect_spec_documents(spec_dir: Path) -> tuple[str, dict, dict | None]: state = _normalize_spec_state(json.loads(raw_text)) except json.JSONDecodeError: state = None - return phase, files, state + # The task list is parsed from the SAME raw tasks.md text already read for the + # document response. _parse_tasks redacts only the label it returns, preserving + # the raw identity hash without adding another filesystem read to each poll. + meta = { + "docs": docs, + "tasks": tasks, + "task_progress": {"done": sum(1 for t in tasks if t["done"]), "total": len(tasks)}, + } + return phase, files, state, meta def _verified_spec_dir(spec_dir: Path) -> Path | None: @@ -988,6 +1158,786 @@ def _verified_spec_dir(spec_dir: Path) -> Path | None: return None +def _open_verified_dir(spec_dir: Path) -> tuple[Path, int] | None: + """Open *spec_dir* and prove the descriptor still names that exact path. + + ``O_NOFOLLOW`` covers only the final component. An agent can replace an + ancestor with a symlink after pathname validation but before ``os.open``; + descriptor-relative writes would then be pinned safely to the wrong tree. + Resolving the opened descriptor closes that window because all subsequent + mutations use the same descriptor whose identity was authorized here. + """ + real_dir = _verified_spec_dir(spec_dir) + if real_dir is None or not _CAN_PIN_DIR or _fd_real_path is None: + return None + try: + dir_fd = os.open( + real_dir, + os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), + ) + except OSError: + return None + try: + opened_path = _fd_real_path(dir_fd) + expected = os.path.normcase(str(real_dir)) + if opened_path is None or os.path.normcase(os.path.normpath(opened_path)) != expected: + os.close(dir_fd) + return None + return real_dir, dir_fd + except (OSError, ValueError): + os.close(dir_fd) + return None + + +def _create_open_verified_dir(spec_dir: Path) -> tuple[Path, int, int] | None: + """Create one child and retain verified descriptors for it and its parent.""" + if not spec_dir.is_absolute() or spec_dir.name in {"", ".", ".."}: + return None + opened_parent = _open_verified_dir(spec_dir.parent) + if opened_parent is None: + return None + _real_parent, parent_fd = opened_parent + dir_fd = -1 + try: + os.mkdir(spec_dir.name, 0o700, dir_fd=parent_fd) + dir_fd = os.open( + spec_dir.name, + os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=parent_fd, + ) + opened_path = _fd_real_path(dir_fd) if _fd_real_path is not None else None + expected = os.path.normcase(str(spec_dir)) + if opened_path is None or os.path.normcase(os.path.normpath(opened_path)) != expected: + os.close(dir_fd) + return None + retained_parent_fd = parent_fd + parent_fd = -1 + return spec_dir, dir_fd, retained_parent_fd + except OSError: + if dir_fd >= 0: + os.close(dir_fd) + return None + finally: + if parent_fd >= 0: + os.close(parent_fd) + + +def _create_spec_doc( + spec_dir: Path, + fname: str, + text: str, + expected_dir_identity: tuple[int, int] | None = None, +) -> tuple[str, tuple[int, int, int, int] | None]: + """Create one absent spec document and return a rollback identity. + + Duplication owns an empty destination, so ``O_EXCL`` gives it a real atomic + boundary: an IDE or agent that creates the same file first wins and is never + overwritten. The returned stat tuple lets failure cleanup remove only the + exact file this call created; a file replaced or modified by another writer + is left alone. + + BLOCKING -- call via ``asyncio.to_thread``. + """ + if fname not in _EDITABLE_DOCS: + return "not_editable", None + encoded = text.encode("utf-8") + if len(encoded) > _MAX_SPEC_BYTES: + return "too_large", None + if not _CAN_PIN_DIR: + return "unsupported_platform", None + opened_dir = _open_verified_dir(spec_dir) + if opened_dir is None: + return "unsafe_dir", None + _real_dir, dir_fd = opened_dir + if expected_dir_identity is not None: + try: + dir_info = os.fstat(dir_fd) + if (dir_info.st_dev, dir_info.st_ino) != expected_dir_identity: + os.close(dir_fd) + return "identity_mismatch", None + except OSError: + os.close(dir_fd) + return "identity_mismatch", None + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + fd = -1 + try: + fd = os.open(fname, flags, 0o600, dir_fd=dir_fd) + try: + remaining = memoryview(encoded) + while remaining: + written = os.write(fd, remaining) + if written <= 0: + raise OSError("document write made no progress") + remaining = remaining[written:] + os.fsync(fd) + stat = os.fstat(fd) + return "", (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns) + except OSError: + # Return the exact partial inode to the duplicate transaction. Its + # rollback removes it only if no other writer replaced or modified it. + try: + stat = os.fstat(fd) + identity = (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns) + except OSError: + identity = None + return "write_failed", identity + finally: + os.close(fd) + fd = -1 + except FileExistsError: + return "conflict", None + except OSError: + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + return "write_failed", None + finally: + os.close(dir_fd) + + +def _rollback_staged_docs(spec_dir: Path, created: dict[str, tuple[int, int, int, int]]) -> bool: + """Remove unchanged files created by a failed duplicate. + + Cleanup deliberately leaves the empty hidden stage directory. POSIX has no + portable inode-bound rmdir, so removing it by name would reopen a race where + an attacker swaps in a different directory after descriptor validation. + + Returns true only when no editable document remains. The provenance marker + stays in place until the caller durably releases the index reservation, so a + crash during rollback still leaves recovery authority for any residue. + """ + opened_dir = _open_verified_dir(spec_dir) + if opened_dir is None: + return False + _, dir_fd = opened_dir + try: + for fname in _EDITABLE_DOCS: + try: + stat = os.stat(fname, dir_fd=dir_fd, follow_symlinks=False) + identity = created.get(fname) + current = (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns) + if identity is not None and current == identity: + os.unlink(fname, dir_fd=dir_fd) + except OSError: + continue + for fname in _EDITABLE_DOCS: + try: + os.stat(fname, dir_fd=dir_fd, follow_symlinks=False) + return False + except FileNotFoundError: + continue + except OSError: + return False + return True + finally: + os.close(dir_fd) + + +def _write_duplicate_marker_at(dir_fd: int, token: str) -> bool: + """Create the provenance marker relative to an already verified directory.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + fd = -1 + try: + fd = os.open(_DUPLICATE_MARKER, flags, 0o600, dir_fd=dir_fd) + remaining = memoryview(token.encode("ascii")) + while remaining: + written = os.write(fd, remaining) + if written <= 0: + return False + remaining = remaining[written:] + os.fsync(fd) + return True + except OSError: + return False + finally: + if fd >= 0: + os.close(fd) + + +def _write_duplicate_marker(stage_dir: Path, token: str) -> bool: + """Create the provenance marker in a descriptor-pinned staging directory.""" + opened_dir = _open_verified_dir(stage_dir) + if opened_dir is None: + return False + _real_dir, dir_fd = opened_dir + try: + return _write_duplicate_marker_at(dir_fd, token) + finally: + os.close(dir_fd) + + +def _duplicate_marker_matches_at(dir_fd: int, token: str) -> bool: + """Read a duplicate marker relative to an already verified directory.""" + fd = -1 + try: + fd = os.open( + _DUPLICATE_MARKER, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=dir_fd, + ) + return os.read(fd, 256).decode("ascii", errors="strict") == token + except (OSError, UnicodeError): + return False + finally: + if fd >= 0: + os.close(fd) + + +def _duplicate_marker_matches(spec_dir: Path, token: str) -> bool: + """Read a duplicate provenance marker without following directory links.""" + opened_dir = _open_verified_dir(spec_dir) + if opened_dir is None: + return False + _real_dir, dir_fd = opened_dir + try: + return _duplicate_marker_matches_at(dir_fd, token) + finally: + os.close(dir_fd) + + +def _duplicate_stage_identity(stage_dir: Path, token: str) -> tuple[int, int] | None: + """Return the inode identity of a descriptor-pinned, matching stage.""" + opened_dir = _open_verified_dir(stage_dir) + if opened_dir is None: + return None + _real_dir, dir_fd = opened_dir + try: + if not _duplicate_marker_matches_at(dir_fd, token): + return None + info = os.fstat(dir_fd) + return info.st_dev, info.st_ino + except OSError: + return None + finally: + os.close(dir_fd) + + +def _create_duplicate_stage(stage_dir: Path, token: str) -> str: + """Create and durably mark a hidden stage before its index reservation.""" + if not _CAN_PUBLISH_DIR_NOREPLACE: + return "unsupported_platform" + opened_stage = _create_open_verified_dir(stage_dir) + if opened_stage is None: + return "write_failed" + _, stage_fd, parent_fd = opened_stage + try: + if _write_duplicate_marker_at(stage_fd, token): + try: + # The marker must survive before the index can name this stage. + # Persist both the marker entry and the stage's parent entry. + os.fsync(stage_fd) + os.fsync(parent_fd) + return "" + except OSError: + pass + try: + os.unlink(_DUPLICATE_MARKER, dir_fd=stage_fd) + os.fsync(stage_fd) + except OSError: + pass + return "unsupported_platform" if not _CAN_PIN_DIR else "write_failed" + finally: + os.close(stage_fd) + os.close(parent_fd) + + +def _remove_duplicate_marker( + spec_dir: Path, token: str, expected_identity: tuple[int, int] | None = None +) -> None: + """Remove only the matching marker from a descriptor-pinned directory.""" + opened_dir = _open_verified_dir(spec_dir) + if opened_dir is None: + return + _real_dir, dir_fd = opened_dir + try: + if expected_identity is not None: + info = os.fstat(dir_fd) + if (info.st_dev, info.st_ino) != expected_identity: + return + if _duplicate_marker_matches_at(dir_fd, token): + os.unlink(_DUPLICATE_MARKER, dir_fd=dir_fd) + except OSError: + pass + finally: + os.close(dir_fd) + + +def _duplicate_manifest_is_valid(documents: object) -> bool: + """True when recovery metadata names only complete document digests.""" + if not isinstance(documents, dict) or not documents: + return False + for fname, digest in documents.items(): + if fname not in _EDITABLE_DOCS or not isinstance(digest, str) or len(digest) != 64: + return False + if _SHA256_RE.fullmatch(digest) is None: + return False + return True + + +def _duplicate_documents_match_at(dir_fd: int, documents: object) -> bool: + """Validate the complete reserved payload through one pinned directory.""" + if not _duplicate_manifest_is_valid(documents): + return False + assert isinstance(documents, dict) + for fname, digest in documents.items(): + fd = -1 + try: + fd = os.open( + fname, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=dir_fd, + ) + info = os.fstat(fd) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + or info.st_size > _MAX_SPEC_BYTES + ): + return False + remaining = info.st_size + 1 + chunks: list[bytes] = [] + while remaining: + chunk = os.read(fd, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + if len(raw) != info.st_size or hashlib.sha256(raw).hexdigest() != digest: + return False + except OSError: + return False + finally: + if fd >= 0: + os.close(fd) + return True + + +def _clear_duplicate_stage_documents_at(dir_fd: int, token: str, documents: object) -> bool: + """Remove marker-provenanced documents through their proven stage inode. + + The marker proves ownership of the directory, while the manifest digest + proves ownership of each document. Recovery must preserve the reservation + if a present document no longer matches; a project writer may have moved an + unrelated file into an abandoned stage before recovery runs. + + The marker remains until the matching index transition is durably saved. + The empty stage remains because directory removal cannot be bound to its + already-open inode across the final pathname-based rmdir syscall. + """ + if not _duplicate_manifest_is_valid(documents) or not _duplicate_marker_matches_at( + dir_fd, token + ): + return False + assert isinstance(documents, dict) + opened: dict[str, tuple[int, int, int]] = {} + owned_fds: list[int] = [] + try: + for fname, digest in documents.items(): + fd = -1 + try: + fd = os.open( + fname, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=dir_fd, + ) + except FileNotFoundError: + continue + except OSError: + return False + owned_fds.append(fd) + info = os.fstat(fd) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + or info.st_size > _MAX_SPEC_BYTES + ): + return False + opened[fname] = (fd, info.st_dev, info.st_ino) + remaining = info.st_size + 1 + chunks: list[bytes] = [] + while remaining: + chunk = os.read(fd, min(remaining, 64 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + if len(raw) != info.st_size or hashlib.sha256(raw).hexdigest() != digest: + return False + + if not _duplicate_marker_matches_at(dir_fd, token): + return False + for fname, (_fd, expected_dev, expected_ino) in opened.items(): + current = os.stat(fname, dir_fd=dir_fd, follow_symlinks=False) + if (current.st_dev, current.st_ino) != (expected_dev, expected_ino): + return False + for fname in opened: + os.unlink(fname, dir_fd=dir_fd) + os.fsync(dir_fd) + for fname in opened: + try: + os.stat(fname, dir_fd=dir_fd, follow_symlinks=False) + return False + except FileNotFoundError: + continue + except OSError: + return False + return _duplicate_marker_matches_at(dir_fd, token) + except OSError: + return False + finally: + for fd in owned_fds: + try: + os.close(fd) + except OSError: + pass + + +def _publish_staged_copy(stage_dir: Path, target_dir: Path) -> str: + """Atomically publish a sibling staging directory without replacement.""" + if not _CAN_PUBLISH_DIR_NOREPLACE or stage_dir.parent != target_dir.parent: + return "unsupported_platform" + real_stage = _verified_spec_dir(stage_dir) + real_parent = _safe_dir(str(stage_dir.parent)) + if real_stage is None or real_parent is None or real_stage.parent != real_parent: + return "unsafe_dir" + opened_parent = _open_verified_dir(real_parent) + if opened_parent is None: + return "unsafe_dir" + _real_parent, parent_fd = opened_parent + try: + try: + rename_noreplace( + stage_dir.name, + target_dir.name, + src_dir_fd=parent_fd, + dst_dir_fd=parent_fd, + ) + except FileExistsError: + return "conflict" + except NotImplementedError: + return "unsupported_platform" + except OSError: + return "write_failed" + try: + os.fsync(parent_fd) + except OSError: + # The atomic rename already completed. Treating a filesystem that + # rejects directory fsync as failure would orphan the published copy. + logger.debug("duplicate parent directory fsync unavailable", exc_info=True) + return "" + finally: + os.close(parent_fd) + + +def _publish_pinned_staged_copy( + stage_dir: Path, target_dir: Path, stage_fd: int, token: str +) -> str: + """Publish and prove the renamed name still identifies the pinned stage.""" + try: + expected = os.fstat(stage_fd) + except OSError: + return "identity_mismatch" + if not _duplicate_marker_matches_at(stage_fd, token): + return "identity_mismatch" + result = _publish_staged_copy(stage_dir, target_dir) + if result: + return result + opened_target = _open_verified_dir(target_dir) + if opened_target is None: + return "identity_mismatch" + _, target_fd = opened_target + try: + published = os.fstat(target_fd) + if (published.st_dev, published.st_ino) != ( + expected.st_dev, + expected.st_ino, + ) or not _duplicate_marker_matches_at(target_fd, token): + return "identity_mismatch" + return "" + except OSError: + return "identity_mismatch" + finally: + os.close(target_fd) + + +_DUPLICATE_RECOVERY_ADOPT = "adopt" +_DUPLICATE_RECOVERY_DISCARD = "discard" +_DUPLICATE_RECOVERY_RELEASE = "release" +_DUPLICATE_RECOVERY_RETRY = "retry" + + +def _recover_abandoned_copy(name: str, meta: dict) -> tuple[str, Path | None]: + """Resolve a duplicate and identify any marker removable after index save.""" + held = meta.get(_DUPLICATING) + if not isinstance(held, dict): + return _DUPLICATE_RECOVERY_RELEASE, None + owner = held.get("owner") + reserved_at = held.get("at") + token = held.get("token") + stage_raw = held.get("stage_dir") + stage_dev = held.get("stage_dev") + stage_ino = held.get("stage_ino") + documents = held.get("documents") + target_raw = meta.get("spec_dir") + slot_key = meta.get("slot_key") + if ( + not isinstance(owner, str) + or not owner + or not isinstance(reserved_at, (int, float)) + or not isinstance(token, str) + or not token + or not isinstance(stage_raw, str) + or not stage_raw + or type(stage_dev) is not int + or stage_dev < 0 + or type(stage_ino) is not int + or stage_ino < 0 + or not isinstance(target_raw, str) + or not target_raw + or not isinstance(slot_key, str) + or not _owns_slot_key(name, slot_key) + or not _duplicate_manifest_is_valid(documents) + ): + return _DUPLICATE_RECOVERY_RELEASE, None + if _DUPLICATE_TOKEN_RE.fullmatch(token) is None: + return _DUPLICATE_RECOVERY_RELEASE, None + target_dir = Path(target_raw) + stage_dir = Path(stage_raw) + expected_stage = target_dir.parent / f".{name}.duplicate-{token}" + if ( + not target_dir.is_absolute() + or target_dir.name != name + or stage_dir != expected_stage + or stage_dir.parent != target_dir.parent + ): + return _DUPLICATE_RECOVERY_RELEASE, None + # Before publication a genuine duplicate has no target directory. Any target + # without our marker is user data (or a concurrent writer's winning create), + # never transaction residue that recovery may delete from the index. + if target_dir.exists(): + opened_target = _open_verified_dir(target_dir) + if opened_target is not None: + _, target_fd = opened_target + try: + target_info = os.fstat(target_fd) + if (target_info.st_dev, target_info.st_ino) == ( + stage_dev, + stage_ino, + ) and _duplicate_marker_matches_at(target_fd, token): + return _DUPLICATE_RECOVERY_ADOPT, target_dir + except OSError: + pass + finally: + os.close(target_fd) + opened_stage = _open_verified_dir(stage_dir) + if opened_stage is not None: + _, stage_fd = opened_stage + try: + stage_info = os.fstat(stage_fd) + if (stage_info.st_dev, stage_info.st_ino) == ( + stage_dev, + stage_ino, + ) and _duplicate_marker_matches_at(stage_fd, token): + if not _clear_duplicate_stage_documents_at(stage_fd, token, documents): + return _DUPLICATE_RECOVERY_RETRY, None + return _DUPLICATE_RECOVERY_DISCARD, stage_dir + except OSError: + pass + finally: + os.close(stage_fd) + # The recorded transaction inode is no longer reachable at either name. + # Keep the reservation hidden: clearing it would adopt the unrelated + # target after a crash in the post-rename identity-check window. + return _DUPLICATE_RECOVERY_RETRY, None + opened_stage = _open_verified_dir(stage_dir) + if opened_stage is None: + return _DUPLICATE_RECOVERY_RETRY, None + _, stage_fd = opened_stage + try: + stage_info = os.fstat(stage_fd) + if (stage_info.st_dev, stage_info.st_ino) != ( + stage_dev, + stage_ino, + ) or not _duplicate_marker_matches_at(stage_fd, token): + return _DUPLICATE_RECOVERY_RETRY, None + if not _duplicate_documents_match_at(stage_fd, documents): + if not _clear_duplicate_stage_documents_at(stage_fd, token, documents): + return _DUPLICATE_RECOVERY_RETRY, None + return _DUPLICATE_RECOVERY_DISCARD, stage_dir + publish_result = _publish_pinned_staged_copy(stage_dir, target_dir, stage_fd, token) + if publish_result == "": + return _DUPLICATE_RECOVERY_ADOPT, target_dir + if publish_result == "identity_mismatch": + # The still-open descriptor proves the renamed directory was not the + # validated transaction. Never finalize the index around its files. + return _DUPLICATE_RECOVERY_DISCARD, None + if not _clear_duplicate_stage_documents_at(stage_fd, token, documents): + return _DUPLICATE_RECOVERY_RETRY, None + return _DUPLICATE_RECOVERY_DISCARD, stage_dir + except OSError: + return _DUPLICATE_RECOVERY_RETRY, None + finally: + os.close(stage_fd) + + +def _recover_abandoned_reservations() -> None: + """Recover duplicate transactions once and persist their terminal state. + + BLOCKING -- first enabled use runs this through ``asyncio.to_thread``. + The index lock serializes the filesystem recovery with every index mutation, + and the cleaned index is saved in the same critical section so a later poll + never repeats a rename/unlink transaction that startup already resolved. + """ + with _INDEX_LOCK: + index = _load_index() + abandoned = [ + name + for name, meta in index.items() + if _DUPLICATING in meta and not _reservation_is_ours(meta, _DUPLICATING) + ] + if not abandoned: + return + recovered = 0 + released = 0 + markers_to_remove: list[tuple[Path, str, tuple[int, int]]] = [] + for name in abandoned: + meta = index[name] + held = meta.get(_DUPLICATING) + marker_token = held.get("token", "") if isinstance(held, dict) else "" + marker_identity = ( + (held.get("stage_dev"), held.get("stage_ino")) + if isinstance(held, dict) + else (None, None) + ) + outcome, marker_dir = _recover_abandoned_copy(name, meta) + if outcome == _DUPLICATE_RECOVERY_ADOPT: + index[name].pop(_DUPLICATING, None) + recovered += 1 + elif outcome == _DUPLICATE_RECOVERY_DISCARD: + index.pop(name, None) + released += 1 + elif outcome == _DUPLICATE_RECOVERY_RELEASE: + # Malformed/unproven metadata is not authority to delete a real + # spec record, its approvals, or its conversation linkage. + index[name].pop(_DUPLICATING, None) + released += 1 + else: + # Keep both reservation and marker when cleanup cannot prove a + # safe terminal state. A later process retries the transaction. + continue + if ( + marker_dir is not None + and type(marker_identity[0]) is int + and type(marker_identity[1]) is int + ): + markers_to_remove.append( + ( + marker_dir, + str(marker_token), + (marker_identity[0], marker_identity[1]), + ) + ) + _save_index(index) + _refresh_slot_keys(index) + # Marker removal is deliberately after the durable index transition. + # A crash from here can strand only a harmless marker in an empty stage + # or committed target; it cannot create markerless transaction metadata. + for marker_dir, marker_token, marker_identity in markers_to_remove: + _remove_duplicate_marker(marker_dir, marker_token, marker_identity) + logger.info( + "spec index: recovered %d and released %d duplicate reservation(s) " + "abandoned by an earlier process", + recovered, + released, + ) + + +async def _recover_abandoned_reservations_on_first_use() -> None: + """Run recovery off-loop without making an abandoned copy disable the app.""" + try: + await asyncio.to_thread(_recover_abandoned_reservations) + except Exception: + # A reservation remains hidden and keeps its name reserved when recovery + # cannot prove a safe terminal state. Retry on the next gateway process, + # rather than failing every poll or taking unrelated app routes down. + logger.exception("spec index: abandoned duplicate recovery failed") + + +async def _ensure_duplicate_recovery(app: web.Application) -> None: + """Recover once on first enabled use, after the gateway is already ready.""" + recovery = app[_DUPLICATE_RECOVERY_STATE] + task = recovery["task"] + if task is None: + # Request handlers for one Application share an event loop. There is no + # await between checking and publishing the task, so concurrent first + # requests cannot start two filesystem transactions. + task = asyncio.create_task(_recover_abandoned_reservations_on_first_use()) + recovery["task"] = task + await task + + +def _write_and_publish_duplicate( + stage_dir: Path, + target_dir: Path, + docs: dict[str, str | None], + token: str, + expected_stage_identity: tuple[int, int] | None = None, +) -> tuple[str, dict[str, tuple[int, int, int, int]]]: + """Populate a hidden sibling directory, then atomically publish it. BLOCKING.""" + + created: dict[str, tuple[int, int, int, int]] = {} + if not _CAN_PUBLISH_DIR_NOREPLACE: + return "unsupported_platform", created + opened_stage = _open_verified_dir(stage_dir) + if opened_stage is None: + return "write_failed", created + _, stage_fd = opened_stage + try: + stage_info = os.fstat(stage_fd) + opened_identity = (stage_info.st_dev, stage_info.st_ino) + if expected_stage_identity is None: + expected_stage_identity = opened_identity + elif opened_identity != expected_stage_identity: + return "identity_mismatch", created + if not _duplicate_marker_matches_at(stage_fd, token): + return "identity_mismatch", created + except OSError: + return "identity_mismatch", created + finally: + os.close(stage_fd) + for fname, text in docs.items(): + if text is None: + continue + result, identity = _create_spec_doc(stage_dir, fname, text, expected_stage_identity) + if identity is not None: + created[fname] = identity + if result: + return result, created + opened_stage = _open_verified_dir(stage_dir) + if opened_stage is None: + return "write_failed", created + _, stage_fd = opened_stage + try: + stage_info = os.fstat(stage_fd) + if ( + stage_info.st_dev, + stage_info.st_ino, + ) != expected_stage_identity or not _duplicate_marker_matches_at(stage_fd, token): + return "identity_mismatch", created + # Each document inode is already fsynced. Persist their directory entries + # before the atomic rename makes this directory visible at the target. + os.fsync(stage_fd) + return _publish_pinned_staged_copy(stage_dir, target_dir, stage_fd, token), created + except OSError: + return "write_failed", created + finally: + os.close(stage_fd) + + def _write_stop_sentinel(spec_dir: Path) -> bool: """Write the STOP sentinel atomically, never following a symlink. @@ -1017,9 +1967,7 @@ def _write_stop_sentinel(spec_dir: Path) -> bool: flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) if _CAN_PIN_DIR: try: - dir_fd = os.open( - real_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) - ) + dir_fd = os.open(real_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)) except OSError: return False try: @@ -1076,9 +2024,7 @@ def _clear_stop_sentinel(spec_dir: Path) -> None: return if _CAN_PIN_DIR: try: - dir_fd = os.open( - real_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) - ) + dir_fd = os.open(real_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)) except OSError: return try: @@ -1114,7 +2060,9 @@ def _arm_stop_sentinel(spec_dir: Path) -> str: return str(real_dir / _STOP_FILE) -def _write_stop_sentinel_for_spec(spec_dir: Path, name: str = "", expect_slot_key: str = "") -> bool: +def _write_stop_sentinel_for_spec( + spec_dir: Path, name: str = "", expect_slot_key: str = "" +) -> bool: """``_write_stop_sentinel`` with the spec's identity pinned to the write. BLOCKING -- call via ``asyncio.to_thread``. The counterpart to the gate in @@ -1138,9 +2086,7 @@ def _write_stop_sentinel_for_spec(spec_dir: Path, name: str = "", expect_slot_ke return _write_stop_sentinel(spec_dir) -def _prepare_handoff( - spec_dir: Path, name: str = "", expect_slot_key: str = "" -) -> tuple[bool, str]: +def _prepare_handoff(spec_dir: Path, name: str = "", expect_slot_key: str = "") -> tuple[bool, str]: """Everything the handoff endpoint needs off the filesystem, in one hop. BLOCKING -- call via ``asyncio.to_thread``. Returns ``(ready, sentinel @@ -1184,8 +2130,16 @@ def _prepare_handoff( # refuses a symlink, a realpath that escapes the spec dir, and a sensitive # target; the extra is_file() keeps the "not written yet" case honest. tasks = _spec_file(spec_dir, "tasks.md") - ready = tasks is not None and tasks.is_file() - return ready, sentinel + if tasks is None or not tasks.is_file(): + return False, sentinel + # Existence is not a plan. The prompt this gate arms tells the agent to work + # through each UNCHECKED task in order, so a zero-byte or half-written + # tasks.md gave the autonomous loop nothing to act on while still reading as + # a finished Tasks phase. Read through _read_spec_text rather than by name: + # it validates the descriptor it read, and the agent writes into this very + # directory, so the inode can change after the is_file() above. + text = _read_spec_text(spec_dir, "tasks.md") + return bool(text and _has_open_task(text)), sentinel async def _restore_worker_transcript(state: Any, name: str, *, adopt_closed: bool) -> None: @@ -1450,6 +2404,49 @@ def _numeric(value: object) -> float: #: Outcomes of _claim_execution, so the caller can tell "someone else is already #: building" from "the spec is gone" without re-reading the index. +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def _normalize_approvals(raw: Any, docs: dict) -> dict: + """Project the stored approval record onto its schema and mark what has moved. + + Returns ``{phase: {"hash", "at", "user", "stale"}}`` for the phases in + ``_APPROVABLE_PHASES`` only. + + ``stale`` is DERIVED here, never stored: it compares the hash that was approved + against the document's hash right now, so a document the agent rewrote after + sign-off reports itself as changed instead of continuing to look approved. A + phase whose document has since disappeared is also stale -- there is nothing + left that the approval describes. + + Normalized on read because this record lives in the app's index, and the index + is reachable by the agent (it runs shell commands as the user), exactly like + every other index field this module scrubs on the way out. Which is also the + honest limit of what this is: a record of a human review, not an attestation + that cannot be forged. It earns its place against the previous behaviour -- + where approval was a chat message and left no trace at all -- not against a + threat model where the agent is hostile. + """ + out: dict[str, dict] = {} + if not isinstance(raw, dict): + return out + for phase in _APPROVABLE_PHASES: + entry = raw.get(phase) + if not isinstance(entry, dict): + continue + approved_hash = str(entry.get("hash", "")) + if not _SHA256_RE.match(approved_hash): + continue + current = str((docs.get(phase + ".md") or {}).get("hash", "")) + out[phase] = { + "hash": approved_hash, + "at": _numeric(entry.get("at")), + "user": _clean_str(entry.get("user")), + "stale": current != approved_hash, + } + return out + + _CLAIM_OK = "" _CLAIM_TAKEN = "taken" _CLAIM_GONE = "gone" @@ -1480,7 +2477,12 @@ async def _claim_execution( def _apply(index: dict) -> bool: meta = index.get(name) - if meta is None or str(meta.get("spec_dir", "")) != expect_spec_dir: + if ( + meta is None + or meta.get(_DELETING) + or meta.get(_DUPLICATING) + or str(meta.get("spec_dir", "")) != expect_spec_dir + ): return False actual_key = str(meta.get("slot_key", "")) if expect_slot_key and actual_key and actual_key != expect_slot_key: @@ -1612,7 +2614,11 @@ def _normalize_spec_state(raw: Any) -> dict | None: out: dict[str, Any] = {} decisions: list[dict[str, Any]] = [] - for item in (raw.get("decisions") or [])[:_MAX_DECISIONS] if isinstance(raw.get("decisions"), list) else []: + for item in ( + (raw.get("decisions") or [])[:_MAX_DECISIONS] + if isinstance(raw.get("decisions"), list) + else [] + ): if not isinstance(item, dict): continue did = _clean_str(item.get("id")) or _clean_str(item.get("title")) @@ -1621,7 +2627,9 @@ def _normalize_spec_state(raw: Any) -> dict | None: continue opts_raw = item.get("options") options = [ - _clean_str(o) for o in (opts_raw[:_MAX_OPTIONS] if isinstance(opts_raw, list) else []) if isinstance(o, str) + _clean_str(o) + for o in (opts_raw[:_MAX_OPTIONS] if isinstance(opts_raw, list) else []) + if isinstance(o, str) ] decisions.append( { @@ -1739,9 +2747,7 @@ async def _teardown_worker_slot( from kiro_crew.dashboard.chat_persistence import save_slot_off_loop try: - await save_slot_off_loop( - state, slot, closed=True, best_effort=not require_archive - ) + await save_slot_off_loop(state, slot, closed=True, best_effort=not require_archive) except Exception: # The transcript is the user's data. A caller that is about to drop the # spec from the index (delete) asks for require_archive, because reporting @@ -1783,9 +2789,7 @@ async def _halt_execution( # than being checked by the caller beforehand: the caller's check and this # write are separated by a thread hop, which is exactly the window a same-name # delete plus re-import needs to redirect the STOP onto a replacement. - if not await asyncio.to_thread( - _write_stop_sentinel_for_spec, spec_dir, name, expect_slot_key - ): + if not await asyncio.to_thread(_write_stop_sentinel_for_spec, spec_dir, name, expect_slot_key): # Not fatal: the two stops below are what actually end the run. Logged so an # operator can tell "no sentinel" from "sentinel ignored". logger.warning("spec %s: no stop sentinel written; halting by loop + turn", name) @@ -1851,12 +2855,29 @@ def _derive_phase(spec_dir: Path) -> str: return "new" -def _read_spec_files(spec_dir: Path) -> dict: - out: dict[str, str | None] = {} +def _read_spec_files(spec_dir: Path) -> tuple[dict, dict, list[dict]]: + """Read the documents once, returning ``(files, docs, tasks)``. + + ``files`` is what the browser renders: the text with credentials REDACTED. + ``docs`` carries the ON-DISK hash used to bind approvals to the version that + was reviewed. ``tasks`` carries redacted labels but raw-text identity hashes. + Documents remain read-only here because the agent and IDE write the same files + without participating in a dashboard lock; no portable compare-and-swap can + prevent a direct write between a hash check and replace. + """ + files: dict[str, str | None] = {} + docs: dict[str, dict] = {} + tasks: list[dict] = [] for _phase, fname in _PHASE_FILES: text = _read_spec_text(spec_dir, fname) - out[fname] = _redact(text) if text is not None else None - return out + if text is None: + files[fname] = None + continue + files[fname] = _redact(text) + docs[fname] = {"hash": _sha256_text(text)} + if fname == "tasks.md": + tasks = _parse_tasks(text) + return files, docs, tasks # ── validation / auth ──────────────────────────────────────────────────────── @@ -1876,7 +2897,9 @@ async def _read_json(request: web.Request): except Exception: return web.json_response({"code": "invalid_json", "error": "invalid JSON body"}, status=400) if not isinstance(body, dict): - return web.json_response({"code": "body_not_object", "error": "body must be a JSON object"}, status=400) + return web.json_response( + {"code": "body_not_object", "error": "body must be a JSON object"}, status=400 + ) return body @@ -1915,7 +2938,9 @@ def _valid_name(name: str) -> bool: } -def _seed_prompt(spec_type: str, name: str, spec_dir: Path, working_dir: str, description: str) -> str: +def _seed_prompt( + spec_type: str, name: str, spec_dir: Path, working_dir: str, description: str +) -> str: """The opening turn for a new spec. SELF-CONTAINED by necessity: this app ships a ``spec-workflow`` skill in its @@ -1926,7 +2951,9 @@ def _seed_prompt(spec_type: str, name: str, spec_dir: Path, working_dir: str, de a dangling reference -- and listed all three documents regardless of the spec type the user picked. Everything the agent needs is now stated here. """ - desc = f"\n\nThe user's initial description:\n{description.strip()}" if description.strip() else "" + desc = ( + f"\n\nThe user's initial description:\n{description.strip()}" if description.strip() else "" + ) files = _TYPE_PLAN.get(spec_type, _TYPE_PLAN["feature"]) guidance = _TYPE_GUIDANCE.get(spec_type, _TYPE_GUIDANCE["feature"]) paths = "\n".join(f" - {spec_dir / f}" for f in files) @@ -1967,6 +2994,48 @@ def _exec_prompt(name: str, spec_dir: Path, working_dir: str) -> str: ) +def _task_prompt( + name: str, spec_dir: Path, working_dir: str, task_text: str, task_index: int +) -> str: + """Instruction for running ONE task from the list. + + Deliberately scoped and deliberately NOT an autonudge loop: the whole-list + handoff arms a loop that keeps going, while this dispatches a single turn and + stops. Running one task is how a user takes a plan for a walk without handing + over the whole thing, so it must end where the user expects it to. + + Names the task by both its text and its validated checklist occurrence. Text + alone is ambiguous when a plan repeats a label, while the occurrence alone is + hard for the model to recognize. The handler revalidates both against the + latest tasks.md snapshot immediately before dispatch. + """ + return ( + f"SINGLE TASK from spec '{name}'. Work ONLY on this one task from " + f"{spec_dir / 'tasks.md'}, operating inside {working_dir} (your shell already " + f"starts there — no cd needed). This is checklist item {task_index + 1}, " + f"counting non-empty checklist items from top to bottom:\n\n{task_text}\n\n" + f"Mark its checkbox [x] in tasks.md when it is genuinely done, run the " + f"relevant build/tests to verify, then STOP and summarize. Do NOT continue " + f"to the following tasks — I am running these one at a time." + ) + + +def _duplicate_prompt(name: str, source: str, spec_dir: Path) -> str: + """Orientation for a duplicated spec's fresh conversation. + + A duplicate copies the documents but NOT the transcript -- the new spec gets + its own slot key, so it cannot inherit the original's history. Without a first + turn the agent would come to the conversation knowing nothing about documents + that are already on disk, so this tells it what it is looking at and, notably, + tells it not to start rewriting them. + """ + return ( + f"Spec '{name}' is a copy of '{source}'. Its documents are already written " + f"at {spec_dir} — read them before doing anything else. Do NOT rewrite or " + f"regenerate them; wait for me to say what should change in this copy." + ) + + # ── slot turn relay (embedded chat) ────────────────────────────────────────── @@ -2146,9 +3215,7 @@ async def _git(cwd: str, *args: str) -> tuple[int, str, str]: # Off-loop: the sandbox backend probe can shell out (subprocess.run) the # first time it runs on a host, and it writes the scrubbed-env temp file. # Neither is the cheap in-memory call it looks like. - argv, env, cleanup = await asyncio.to_thread( - _prepare_git_spawn, ["git", "-C", cwd, *args] - ) + argv, env, cleanup = await asyncio.to_thread(_prepare_git_spawn, ["git", "-C", cwd, *args]) except Exception as exc: # Sandbox unavailable / argv build failure: report it, do not 500 the # caller. Every caller already treats a non-zero rc as "not a git repo". @@ -2451,14 +3518,21 @@ async def _handle_put_settings(request: web.Request) -> web.Response: ) if base: if not Path(base).is_absolute(): - return web.json_response({"code": "base_path_not_absolute", "error": "base_path must be an absolute path"}, status=400) + return web.json_response( + {"code": "base_path_not_absolute", "error": "base_path must be an absolute path"}, + status=400, + ) # Same chokepoint as working_dir: without this, spec storage could be # repointed at a credential directory and every subsequent spec would # write into it. safe_base = await asyncio.to_thread(_safe_dir_optional, base) if safe_base is None: return web.json_response( - {"code": "base_path_not_a_directory", "error": "base_path must be an existing, non-sensitive directory"}, status=400 + { + "code": "base_path_not_a_directory", + "error": "base_path must be an existing, non-sensitive directory", + }, + status=400, ) base = str(safe_base) await asyncio.to_thread(_save_settings, {"base_path": base, "model": model}) @@ -2469,9 +3543,7 @@ async def _handle_put_settings(request: web.Request) -> web.Response: # Through _redact like the GET: the omitted-key branch echoes a value read # from disk, so a credential-looking string in the file would otherwise # reach the dashboard raw here even though the GET path scrubs it. - return web.json_response( - {"ok": True, "base_path": _redact(base), "model": _redact(model)} - ) + return web.json_response({"ok": True, "base_path": _redact(base), "model": _redact(model)}) def _discover_folder_specs(index: dict) -> bool: @@ -2540,14 +3612,26 @@ def _discover_folder_specs(index: dict) -> bool: def _prepare_spec_dir( - working_dir: str, safe_wd: Path, name: str, import_existing: bool + working_dir: str, + safe_wd: Path, + name: str, + import_existing: bool, + *, + create: bool = True, + expected_dir: Path | None = None, ) -> tuple[Path, str]: """Resolve + validate + create the spec directory. BLOCKING -- one hop. Returns ``(spec_dir, refusal)``; ``refusal`` is ``""`` on success, else - ``"escape"``, ``"existing:"`` or ``"mkdir:"``. + ``"escape"``, ``"moved"``, ``"existing:"`` or ``"mkdir:"``. """ spec_dir = _resolve_spec_dir(working_dir, name) + # Duplication reserves this exact path before any files are copied. Refuse + # if a concurrent settings change resolves the destination elsewhere. + if expected_dir is not None and os.path.normcase(str(spec_dir)) != os.path.normcase( + str(expected_dir) + ): + return spec_dir, "moved" # The spec dir must land under its declared root -- either the settings # base_path or the validated working dir (which is the WORKTREE when one was # just created). _NAME_RE already forbids '.' and '/', so this can only fail @@ -2574,10 +3658,11 @@ def _prepare_spec_dir( existing = [f for _p, f in _PHASE_FILES if (spec_dir / f).is_file()] if existing: return spec_dir, "existing:" + ", ".join(sorted(existing)) - try: - spec_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - return spec_dir, f"mkdir:{exc}" + if create: + try: + spec_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return spec_dir, f"mkdir:{exc}" return spec_dir, "" @@ -2607,7 +3692,7 @@ async def _handle_list(request: web.Request) -> web.Response: for name, meta in index.items(): # A delete in flight keeps its entry so the name stays reserved (see # _mark_deleting); it is not a spec the user still has. - if isinstance(meta, dict) and meta.get(_DELETING): + if isinstance(meta, dict) and (meta.get(_DELETING) or meta.get(_DUPLICATING)): continue spec_dir = Path(meta.get("spec_dir", "")) slot = state.get_slot(_slot_key(name)) if (state := request.app.get("state")) else None @@ -2621,6 +3706,9 @@ async def _handle_list(request: web.Request) -> web.Response: "working_dir": _redact(str(meta.get("working_dir", ""))), "spec_dir": _redact(str(spec_dir)), "spec_type": _redact(str(meta.get("spec_type", "feature"))), + # Optional display label; the rail falls back to the name. + "title": _clean_str(meta.get("title")), + "archived": meta.get("archived") is True, # Reconciled, not raw: a capped nudge loop that ran out of cycles # leaves "executing" in the index forever (see _effective_status). "status": await _effective_status(name, meta, slot), @@ -2685,20 +3773,32 @@ async def _handle_create(request: web.Request) -> web.Response: status=400, ) if spec_type not in _VALID_TYPES: - return web.json_response({"code": "invalid_spec_type", "error": f"spec_type must be one of {_VALID_TYPES}"}, status=400) + return web.json_response( + {"code": "invalid_spec_type", "error": f"spec_type must be one of {_VALID_TYPES}"}, + status=400, + ) if not working_dir or not Path(working_dir).is_absolute(): - return web.json_response({"code": "working_dir_not_absolute", "error": "working_dir must be an absolute path"}, status=400) + return web.json_response( + {"code": "working_dir_not_absolute", "error": "working_dir must be an absolute path"}, + status=400, + ) safe_wd = await asyncio.to_thread(_safe_dir, working_dir) if safe_wd is None: # Covers "missing", "not a directory" and "sensitive location" with one # response so the endpoint can't be used to probe the filesystem. return web.json_response( - {"code": "working_dir_not_a_directory", "error": "working_dir must be an existing, non-sensitive directory"}, status=400 + { + "code": "working_dir_not_a_directory", + "error": "working_dir must be an existing, non-sensitive directory", + }, + status=400, ) working_dir = str(safe_wd) index = await _aload_index() if name in index: - return web.json_response({"code": "spec_exists", "error": f"a spec named '{name}' already exists"}, status=409) + return web.json_response( + {"code": "spec_exists", "error": f"a spec named '{name}' already exists"}, status=409 + ) # Optional: create a dedicated worktree + branch off the chosen repo and # use IT as the working dir (worktree-per-spec workflow). The spec files @@ -2709,11 +3809,20 @@ async def _handle_create(request: web.Request) -> web.Response: if _opted_in(body, "use_worktree"): info = await _repo_info(working_dir) if not info.get("is_git"): - return web.json_response({"code": "worktree_requires_git", "error": "use_worktree requires a git repository"}, status=400) + return web.json_response( + { + "code": "worktree_requires_git", + "error": "use_worktree requires a git repository", + }, + status=400, + ) repo_root = info["root"] wt = await _create_worktree(repo_root, name) if isinstance(wt, str): - return web.json_response({"code": "worktree_creation_failed", "error": f"worktree creation failed: {wt}"}, status=400) + return web.json_response( + {"code": "worktree_creation_failed", "error": f"worktree creation failed: {wt}"}, + status=400, + ) working_dir, worktree_branch = wt created_worktree = working_dir _audit("spec_worktree_create", f"{name} -> {working_dir}") @@ -2725,7 +3834,11 @@ async def _handle_create(request: web.Request) -> web.Response: if safe_wt is None: await _remove_worktree(repo_root, created_worktree, worktree_branch) return web.json_response( - {"code": "worktree_unusable", "error": "created worktree is not a usable directory"}, status=400 + { + "code": "worktree_unusable", + "error": "created worktree is not a usable directory", + }, + status=400, ) safe_wd = safe_wt working_dir = str(safe_wd) @@ -2744,7 +3857,11 @@ async def _handle_create(request: web.Request) -> web.Response: if kind == "escape": _audit("spec_path_escape_denied", f"{name} -> {spec_dir}") return web.json_response( - {"code": "spec_path_outside_root", "error": "resolved spec path is outside its root"}, status=400 + { + "code": "spec_path_outside_root", + "error": "resolved spec path is outside its root", + }, + status=400, ) if kind == "existing": return web.json_response( @@ -2757,7 +3874,10 @@ async def _handle_create(request: web.Request) -> web.Response: }, status=409, ) - return web.json_response({"code": "spec_dir_creation_failed", "error": f"cannot create spec dir: {detail}"}, status=400) + return web.json_response( + {"code": "spec_dir_creation_failed", "error": f"cannot create spec dir: {detail}"}, + status=400, + ) # Creating this spec is an explicit decision that outranks an earlier delete of # the same directory, so the tombstone goes away — otherwise discovery would @@ -2795,7 +3915,9 @@ def _insert(index: dict) -> bool: if not await _mutate_index(_insert): if created_worktree: await _remove_worktree(repo_root, created_worktree, worktree_branch) - return web.json_response({"code": "spec_exists", "error": f"a spec named '{name}' already exists"}, status=409) + return web.json_response( + {"code": "spec_exists", "error": f"a spec named '{name}' already exists"}, status=409 + ) # The slot is acquired and configured ONLY AFTER the index arbitration above # decides this create won. get_or_create_slot keys off the name, so two @@ -2846,7 +3968,11 @@ def _pop_if_ours(idx: dict) -> bool: # Another app owns this slot key, or the working dir no longer validates. await _unwind_create() return web.json_response( - {"code": "slot_owned_by_another_app", "error": f"a chat session named '{name}' is owned by another app"}, status=409 + { + "code": "slot_owned_by_another_app", + "error": f"a chat session named '{name}' is owned by another app", + }, + status=409, ) # Slot setup AWAITS (the working-dir chokepoint runs off-loop), so a concurrent # delete-and-recreate can land in that window. Confirm this is still OUR spec @@ -2857,14 +3983,14 @@ def _pop_if_ours(idx: dict) -> bool: # Both fields, because a re-import at the same name AND path keeps spec_dir # while being a different creation with a different conversation -- and the # seed prompt below would then drive the replacement's agent. - if ( - str(live.get("spec_dir", "")) != str(spec_dir) - or str(live.get("slot_key", "")) != slot_key - ): + if str(live.get("spec_dir", "")) != str(spec_dir) or str(live.get("slot_key", "")) != slot_key: await _unwind_create() _audit("spec_create_aborted", f"{name}: deleted or recreated during slot setup") return web.json_response( - {"code": "spec_changed_during_create", "error": "spec was deleted or recreated while being created; retry"}, + { + "code": "spec_changed_during_create", + "error": "spec was deleted or recreated while being created; retry", + }, status=409, ) # NO auto-approve grant. This app used to stamp slot._trust because a @@ -2905,7 +4031,7 @@ async def _handle_get(request: web.Request) -> web.Response: name = request.match_info["name"] index = await _aload_index() meta = index.get(name) - if not meta: + if not meta or meta.get(_DELETING) or meta.get(_DUPLICATING): return web.json_response({"code": "not_found", "error": "not found"}, status=404) spec_dir = Path(meta["spec_dir"]) @@ -2921,7 +4047,7 @@ async def _handle_get(request: web.Request) -> web.Response: # and reading .spec-state.json. The UI polls this endpoint every 2.5s while a # build runs, so doing it inline froze the gateway's event loop — chat # streaming and heartbeats included — for the duration of every poll. - phase, files, spec_state = await asyncio.to_thread(_collect_spec_documents, spec_dir) + phase, files, spec_state, doc_meta = await asyncio.to_thread(_collect_spec_documents, spec_dir) # Live context counters from the worker slot's transcript. The slot is # CREATED here if it does not exist yet (see _ensure_worker_slot): a spec @@ -2942,7 +4068,11 @@ async def _handle_get(request: web.Request) -> web.Response: return web.json_response({"code": "not_found", "error": "not found"}, status=404) if str(meta.get("spec_dir", "")) != str(spec_dir): return web.json_response( - {"code": "spec_changed_during_read", "error": "spec was recreated while loading; retry"}, status=409 + { + "code": "spec_changed_during_read", + "error": "spec was recreated while loading; retry", + }, + status=409, ) turns = tool_calls = 0 slot = await _ensure_worker_slot(state, name, meta) @@ -2952,7 +4082,11 @@ async def _handle_get(request: web.Request) -> web.Response: # session -- the user could read it, message into it and approve its tool # calls from this app. Refuse the whole detail read instead. return web.json_response( - {"code": "slot_owned_by_another_app", "error": "this spec's chat session is owned by another app"}, status=409 + { + "code": "slot_owned_by_another_app", + "error": "this spec's chat session is owned by another app", + }, + status=409, ) if slot is not None and getattr(slot, "messages", None): for m in slot.messages: @@ -2983,6 +4117,27 @@ async def _handle_get(request: web.Request) -> web.Response: "running": bool(getattr(slot, "running", False)) if slot is not None else False, "phase": phase, "files": files, + # Per-document raw hash, used to bind approval to the exact stored + # revision even when the rendered text required redaction. + "docs": doc_meta["docs"], + # tasks.md's checklist, enumerated and individually addressable, plus + # derived progress. Both come from re-parsing the markdown -- there is + # no separate task store to drift out of sync with the file the IDE and + # CLI also read. + "tasks": doc_meta["tasks"], + "task_progress": doc_meta["task_progress"], + # A recorded human review per phase, with `stale` set when the document + # moved after sign-off. Approval used to be a chat message and left + # nothing behind at all. + "approvals": _normalize_approvals(meta.get("approvals"), doc_meta["docs"]), + # Display label. The NAME stays the immutable identity (directory, git + # branch, slot key); this is the only part a rename may touch. + "title": _clean_str(meta.get("title")), + "archived": meta.get("archived") is True, + # Duplicate's crash-safe transaction needs descriptor-relative + # filesystem operations. Keep an unsupported platform honest in the + # UI instead of presenting an action the route must fail closed. + "duplicate_supported": _CAN_PUBLISH_DIR_NOREPLACE, "state": spec_state, "context": { "worktree_branch": _redact(str(meta.get("worktree_branch", ""))), @@ -3010,7 +4165,11 @@ async def _handle_messages(request: web.Request) -> web.Response: # somebody else's conversation into this app -- same refusal the detail # endpoint makes. return web.json_response( - {"code": "slot_owned_by_another_app", "error": "this spec's chat session is owned by another app"}, status=409 + { + "code": "slot_owned_by_another_app", + "error": "this spec's chat session is owned by another app", + }, + status=409, ) return web.json_response( { @@ -3057,14 +4216,19 @@ async def _handle_message(request: web.Request) -> web.Response: ) if fresh is None: return web.json_response( - {"code": "stale_client", "error": "spec was deleted or recreated; reload and retry"}, status=409 + {"code": "stale_client", "error": "spec was deleted or recreated; reload and retry"}, + status=409, ) slot = await _ensure_worker_slot(state, name, fresh) if slot is None: # Another app owns this slot key (see _ensure_worker_slot). Refuse rather # than dispatching a turn into a session we do not own. return web.json_response( - {"code": "slot_owned_by_another_app", "error": "this spec's chat session is owned by another app"}, status=409 + { + "code": "slot_owned_by_another_app", + "error": "this spec's chat session is owned by another app", + }, + status=409, ) # Re-pin immediately before dispatch. _ensure_worker_slot awaits (it revalidates # the working dir off the event loop), so a delete can start AND finish between @@ -3088,7 +4252,8 @@ async def _handle_message(request: web.Request) -> web.Response: is None ): return web.json_response( - {"code": "stale_client", "error": "spec was deleted or recreated; reload and retry"}, status=409 + {"code": "stale_client", "error": "spec was deleted or recreated; reload and retry"}, + status=409, ) _dispatch_turn(state, slot, text) _audit("spec_message", name) @@ -3125,7 +4290,11 @@ async def _handle_handoff(request: web.Request) -> web.Response: ) if not has_tasks: return web.json_response( - {"code": "tasks_missing", "error": "tasks.md does not exist yet — finish the Tasks phase first"}, status=409 + { + "code": "tasks_missing", + "error": "tasks.md has no unchecked tasks yet — finish the Tasks phase first", + }, + status=409, ) # Reread AFTER the await as well: a delete+recreate can land during the thread # hop, and a stale request would then capture the REPLACEMENT's slot while its @@ -3144,7 +4313,11 @@ async def _handle_handoff(request: web.Request) -> web.Response: or str(meta.get("slot_key", "")) != started_slot_key ): return web.json_response( - {"code": "spec_changed_during_start", "error": "spec was deleted or recreated while starting; retry"}, status=409 + { + "code": "spec_changed_during_start", + "error": "spec was deleted or recreated while starting; retry", + }, + status=409, ) working_dir = meta.get("working_dir", "") if _client_identity_mismatch(claimed, spec_dir, str(meta.get("slot_key", ""))): @@ -3182,30 +4355,45 @@ async def _handle_handoff(request: web.Request) -> web.Response: # a timer with no execution state -- and the restored timer ran something Pause # could not stop, because Pause keys off that state. captured_slot_key = str(meta.get("slot_key", "")) - live_slot = state.get_slot(_slot_key(name)) if state is not None else None - try: - claim, committed = await _claim_execution( - name, - expect_spec_dir=str(spec_dir), - expect_slot_key=captured_slot_key, - live_running=bool(getattr(live_slot, "running", False)), - ) - except Exception: - # Nothing has been created yet, so there is nothing to unwind -- but the - # run must not proceed on an unrecorded state, because Pause keys off it. - logger.warning("could not claim execution for %s", name, exc_info=True) - return web.json_response( - {"code": "exec_state_write_failed", "error": "could not record execution state; the run was not started"}, - status=500, - ) + async with _spec_execution_lock(request, name): + # A per-task request holds this same lock across its final tasks.md read + # and synchronous dispatch. Resolve the slot only after acquiring it, so + # a task that won the lock is visible here through its published task. + live_slot = state.get_slot(_slot_key(name)) if state is not None else None + try: + claim, committed = await _claim_execution( + name, + expect_spec_dir=str(spec_dir), + expect_slot_key=captured_slot_key, + live_running=_slot_is_writing(live_slot), + ) + except Exception: + # Nothing has been created yet, so there is nothing to unwind -- but + # the run must not proceed on an unrecorded state, because Pause keys + # off it. + logger.warning("could not claim execution for %s", name, exc_info=True) + return web.json_response( + { + "code": "exec_state_write_failed", + "error": "could not record execution state; the run was not started", + }, + status=500, + ) if claim == _CLAIM_TAKEN: return web.json_response( - {"code": "already_executing", "error": "this spec is already building; pause it before starting again"}, + { + "code": "already_executing", + "error": "this spec is already building; pause it before starting again", + }, status=409, ) if claim != _CLAIM_OK: return web.json_response( - {"code": "spec_changed_during_start", "error": "spec was deleted or recreated while starting; retry"}, status=409 + { + "code": "spec_changed_during_start", + "error": "spec was deleted or recreated while starting; retry", + }, + status=409, ) meta = committed or meta # Did the slot ALREADY exist? The unwind path below must only close a slot @@ -3230,7 +4418,11 @@ async def _handle_handoff(request: web.Request) -> web.Response: exec_arming_at=0.0, ) return web.json_response( - {"code": "slot_owned_by_another_app", "error": "this spec's chat session is owned by another app"}, status=409 + { + "code": "slot_owned_by_another_app", + "error": "this spec's chat session is owned by another app", + }, + status=409, ) prompt = _exec_prompt(name, spec_dir, working_dir) # Arm the autonudge loop through the SHARED AUTHORIZATION CHOKEPOINT so this @@ -3298,7 +4490,8 @@ async def _release(reason: str, *, loop_id: str | None = None) -> None: await _release("authorization raised") _audit("spec_handoff_denied", f"{name}: authorization raised", outcome="denied") return web.json_response( - {"code": "authorization_failed", "error": "could not authorize autonomous execution"}, status=503 + {"code": "authorization_failed", "error": "could not authorize autonomous execution"}, + status=503, ) if authz_err: # No trust to revoke (we never granted any), and revoking here would undo @@ -3307,7 +4500,11 @@ async def _release(reason: str, *, loop_id: str | None = None) -> None: await _release(f"authorization refused: {authz_err}") _audit("spec_handoff_denied", f"{name}: {authz_err}", outcome="denied") return web.json_response( - {"code": "authorization_refused", "error": f"could not start autonomous execution: {authz_err}"}, status=403 + { + "code": "authorization_refused", + "error": f"could not start autonomous execution: {authz_err}", + }, + status=403, ) # Arming awaits too, so re-verify the creation once more. A DELETE landing in # that window tears down the slot and the loops it can see BY NAME -- ours @@ -3330,7 +4527,10 @@ async def _release(reason: str, *, loop_id: str | None = None) -> None: loop_id=getattr(armed_loop, "id", None), ) return web.json_response( - {"code": "spec_changed_during_start", "error": "spec was deleted or recreated while execution was starting"}, + { + "code": "spec_changed_during_start", + "error": "spec was deleted or recreated while execution was starting", + }, status=409, ) _dispatch_turn(state, slot, prompt) @@ -3391,6 +4591,809 @@ def _client_identity_mismatch( return bool(claim.slot_key) and bool(actual_slot_key) and claim.slot_key != actual_slot_key +async def _pinned_entry(request: web.Request, name: str, body: dict) -> dict | web.Response: + """Resolve the spec FRESH, pinned to the identity the client rendered. + + The shared prologue for every mutation added below, factored out because the + pinning argument is subtle and six copies of it would drift: the body read is + an await, so the entry has to be re-read after it, and the client's captured + ``spec_dir`` + ``slot_key`` are what make a stale tab detectable. These new + lifecycle controls require both fields: treating an absent claim as unpinned + would let a control rendered before detail loaded mutate whichever creation + currently owns the same name. + """ + claimed_dir = str(body.get("spec_dir", "") or "").strip() + claimed_key = str(body.get("slot_key", "") or "").strip() + if not claimed_dir or not claimed_key: + return web.json_response({"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409) + fresh = await _touch_spec(name, expect_spec_dir=claimed_dir, expect_slot_key=claimed_key) + if fresh is None: + return web.json_response({"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409) + return fresh + + +def _slot_is_writing(slot: Any) -> bool: + """True once a slot has published an in-flight agent turn.""" + task = getattr(slot, "task", None) + return bool( + getattr(slot, "running", False) + or getattr(slot, "_in_stage_execution", False) + or (task is not None and not task.done()) + ) + + +def _agent_is_writing(request: web.Request, name: str) -> bool: + """True while this spec's agent turn is in flight. + + Both the editor and the per-task run refuse in that window. The agent writes + the spec documents itself, so accepting a save mid-turn means one of the two + writes silently wins -- and the compare-and-swap hash cannot help, because the + editor's base hash was valid when the turn STARTED. Refusing is the honest + answer: the user is told to wait rather than told the save succeeded. + """ + state = request.app.get("state") + if state is None: + return False + slot = state.get_slot(_slot_key(name)) + return _slot_is_writing(slot) + + +async def _handle_approve(request: web.Request) -> web.Response: + """Record a human approval of one phase, against the version approved. + + Records rather than enforces, and the distinction is deliberate. Enforcing + would mean refusing the agent's write to ``design.md`` until requirements is + approved, and the agent writes through its OWN file tools rather than this + app's API -- so the app cannot enforce that without owning the agent's + filesystem access, and a gate that can be walked around is worse than an + honest record. What this fixes is that approval used to be a chat message and + nothing else: the server never knew a phase had been approved, by whom, or + against which text. + """ + if denied := _require_auth(request): + return denied + name = request.match_info["name"] + body = await _read_json(request) + if isinstance(body, web.Response): + return body + phase = str(body.get("phase", "")).strip() + if phase not in _APPROVABLE_PHASES: + return web.json_response( + {"code": "invalid_phase", "error": f"phase must be one of {list(_APPROVABLE_PHASES)}"}, + status=400, + ) + claimed_hash = str(body.get("hash", "") or "") + if not _SHA256_RE.match(claimed_hash): + return web.json_response( + {"code": "invalid_hash", "error": "hash must be a sha256 hex digest"}, status=400 + ) + fresh = await _pinned_entry(request, name, body) + if isinstance(fresh, web.Response): + return fresh + spec_dir = Path(str(fresh.get("spec_dir", ""))) + captured_slot_key = str(fresh.get("slot_key", "")) + fname = phase + ".md" + + def _current_hash() -> str: + text = _read_spec_text(spec_dir, fname) + return _sha256_text(text) if text is not None else "" + + actual = await asyncio.to_thread(_current_hash) + if actual != claimed_hash: + # Approving a version you have not seen records nothing meaningful, so the + # client is sent back to re-read rather than having its claim trusted. + return web.json_response( + { + "code": "doc_changed", + "error": f"{fname} changed since you reviewed it — reload before approving", + "current_hash": actual, + }, + status=409, + ) + user = str(request.get("user") or "") + record = {"hash": claimed_hash, "at": time.time(), "user": user[:_MAX_FIELD]} + + def _record(index: dict) -> bool: + meta = index.get(name) + if meta is None or meta.get(_DELETING): + return False + if str(meta.get("spec_dir", "")) != str(spec_dir): + return False + if captured_slot_key and str(meta.get("slot_key", "")) != captured_slot_key: + return False + # Merged INSIDE the lock rather than by reading the dict out, editing it and + # stamping it back: the read-modify-write would drop a second phase's + # approval that landed in between, and this is the one field where losing a + # record silently defeats the point of having it. + existing = meta.get("approvals") + approvals = dict(existing) if isinstance(existing, dict) else {} + approvals[phase] = record + meta["approvals"] = approvals + meta["updated_at"] = time.time() + return True + + if not await _mutate_index(_record): + return web.json_response({"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409) + _audit("spec_phase_approve", f"{name}/{phase}") + return web.json_response({"ok": True, "phase": phase, "hash": claimed_hash}) + + +async def _handle_run_task(request: web.Request) -> web.Response: + """Run ONE task from tasks.md as a single turn. + + The whole-list handoff arms an autonudge loop over every unchecked task, which + is the only granularity the app had: there was no way to run one task, and no + way to see which task a run was on. This dispatches a single scoped turn and + stops, and progress stays derived from the file's checkboxes. + """ + if denied := _require_auth(request): + return denied + name = request.match_info["name"] + body = await _read_json(request) + if isinstance(body, web.Response): + return body + raw_index = body.get("index") + if not isinstance(raw_index, int) or isinstance(raw_index, bool) or raw_index < 0: + return web.json_response( + {"code": "invalid_index", "error": "index must be a non-negative integer"}, status=400 + ) + claimed_hash = str(body.get("hash", "") or "") + if not _SHA256_RE.match(claimed_hash): + return web.json_response( + {"code": "invalid_hash", "error": "hash must be a sha256 hex digest"}, status=400 + ) + fresh = await _pinned_entry(request, name, body) + if isinstance(fresh, web.Response): + return fresh + state = request.app.get("state") + # An autonudge loop already working the whole list would collide with a + # single-task turn: both write the same files and both check boxes off. + if ( + await _effective_status(name, fresh, state.get_slot(_slot_key(name)) if state else None) + == "executing" + ): + return web.json_response( + { + "code": "already_executing", + "error": "this spec is already building — pause it first", + }, + status=409, + ) + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + spec_dir = Path(str(fresh.get("spec_dir", ""))) + + def _task_snapshot() -> tuple[dict | None, str]: + tasks = _parse_tasks(_read_spec_text(spec_dir, "tasks.md") or "") + if raw_index >= len(tasks): + return None, "task_not_found" + candidate = tasks[raw_index] + # Position AND text must both still match. The agent rewrites tasks.md + # between polls, so an index alone is a moving target and a click on + # "task 3" could otherwise dispatch whatever ended up third. + if candidate["hash"] != claimed_hash: + return None, "task_changed" + if candidate["done"]: + return None, "task_done" + return candidate, "" + + def _task_conflict(code: str) -> web.Response: + errors = { + "task_not_found": "that task is no longer in the list — reload", + "task_changed": "that task changed since the list was rendered — reload and pick it again", + "task_done": "that task is already checked off", + } + return web.json_response({"code": code, "error": errors[code]}, status=409) + + task, task_error = await asyncio.to_thread(_task_snapshot) + if task_error: + return _task_conflict(task_error) + # Hold the same per-spec lock that Execute uses to claim execution and Delete + # uses to reserve teardown BEFORE materializing the worker slot. If Delete + # captured "no slot" while _ensure_worker_slot awaited and this request then + # restored one, Delete's identity-pinned teardown would deliberately leave the + # new slot behind as an orphan. Re-pin first under the lock; after that Delete + # either already owns the entry and no slot is created, or waits until the task + # publishes its slot/turn and can capture that exact runtime. + async with _spec_execution_lock(request, name): + before_slot = await _touch_spec( + name, + expect_spec_dir=str(spec_dir), + expect_slot_key=str(fresh.get("slot_key", "")) or None, + ) + if before_slot is None: + return web.json_response( + {"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409 + ) + current_slot = state.get_slot(_slot_key(name)) if state else None + if await _effective_status(name, before_slot, current_slot) == "executing": + return web.json_response( + { + "code": "already_executing", + "error": "this spec is already building — pause it first", + }, + status=409, + ) + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + slot = await _ensure_worker_slot(state, name, before_slot) + if slot is None: + return web.json_response( + { + "code": "slot_owned_by_another_app", + "error": "this spec's chat session is owned by another app", + }, + status=409, + ) + # Slot setup awaits, so re-pin the creation before using the materialized + # slot. Delete cannot cross the lock, while other identity mutations still + # fail this check. + final_fresh = await _touch_spec( + name, + expect_spec_dir=str(spec_dir), + expect_slot_key=str(fresh.get("slot_key", "")) or None, + ) + if final_fresh is None: + return web.json_response( + {"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409 + ) + if await _effective_status(name, final_fresh, slot) == "executing": + return web.json_response( + { + "code": "already_executing", + "error": "this spec is already building — pause it first", + }, + status=409, + ) + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + # Slot setup and status reconciliation both await. The IDE can edit + # tasks.md during either window, so the earlier snapshot is no longer safe + # to dispatch. Execute and Delete cannot cross this final awaited reread, + # and _dispatch_turn publishes slot.task synchronously before the lock is + # released. + task, task_error = await asyncio.to_thread(_task_snapshot) + if task_error: + return _task_conflict(task_error) + assert task is not None + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + _dispatch_turn( + state, + slot, + _task_prompt( + name, + spec_dir, + str(final_fresh.get("working_dir", "")), + task["text"], + task["index"], + ), + ) + _audit("spec_task_run", f"{name}#{raw_index}") + return web.json_response({"ok": True, "index": raw_index}) + + +async def _handle_title(request: web.Request) -> web.Response: + """Set a spec's display label. + + A rename, but of the LABEL only -- and that limit is the design, not a + shortcut. The name is simultaneously the on-disk directory under + ``.kiro/specs/``, the ``spec/`` git branch, and the chat slot key, and + ``_owns_slot_key`` requires the key to ENCODE the indexed name. So renaming the + identity would move a directory the IDE and CLI also read, rewrite a branch + that may already have commits, and orphan the spec's transcript, which is the + very thing delete-and-recreate loses. A label fixes what users actually hit -- + a spec misnamed at the New Spec screen -- and costs none of that. + """ + if denied := _require_auth(request): + return denied + name = request.match_info["name"] + body = await _read_json(request) + if isinstance(body, web.Response): + return body + if "title" not in body: + return web.json_response({"code": "title_required", "error": "title required"}, status=400) + title = str(body.get("title") or "").strip()[:120] + fresh = await _pinned_entry(request, name, body) + if isinstance(fresh, web.Response): + return fresh + # "" clears the label and the UI falls back to the name, so an empty title is + # a reset rather than an error. + if ( + await _touch_spec( + name, + expect_spec_dir=str(fresh.get("spec_dir", "")), + expect_slot_key=str(fresh.get("slot_key", "")) or None, + title=title, + ) + is None + ): + return web.json_response({"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409) + _audit("spec_title", name) + return web.json_response({"ok": True, "title": title}) + + +async def _handle_archive(request: web.Request) -> web.Response: + """Move a spec out of the working set, or bring it back. + + The non-destructive counterpart to delete: documents, transcript and index + entry all stay, so an archived spec is recoverable by definition. Delete was + the only lifecycle operation besides create, which meant tidying up a finished + spec and destroying it were the same act. + """ + if denied := _require_auth(request): + return denied + name = request.match_info["name"] + body = await _read_json(request) + if isinstance(body, web.Response): + return body + archived = body.get("archived") + if not isinstance(archived, bool): + return web.json_response( + {"code": "archived_required", "error": "archived must be a boolean"}, status=400 + ) + fresh = await _pinned_entry(request, name, body) + if isinstance(fresh, web.Response): + return fresh + state = request.app.get("state") + if ( + archived + and await _effective_status(name, fresh, state.get_slot(_slot_key(name)) if state else None) + == "executing" + ): + # Archiving a running spec would hide a loop that keeps editing files, so + # the user would have no surface left to stop it from. + return web.json_response( + {"code": "spec_executing", "error": "pause this spec before archiving it"}, status=409 + ) + if ( + await _touch_spec( + name, + expect_spec_dir=str(fresh.get("spec_dir", "")), + expect_slot_key=str(fresh.get("slot_key", "")) or None, + archived=archived, + ) + is None + ): + return web.json_response({"code": "stale_client", "error": _STALE_CLIENT_ERROR}, status=409) + _audit("spec_archive" if archived else "spec_unarchive", name) + return web.json_response({"ok": True, "archived": archived}) + + +async def _handle_duplicate(request: web.Request) -> web.Response: + """Copy a spec's documents into a new spec. + + The recovery path for the case rename cannot serve: a spec whose NAME is wrong + after it already has a branch or history. The copy takes the documents and + nothing else -- new name, new directory, new slot key, so a fresh conversation + rather than a replayed one. No worktree either; that is an opt-in at create + time and silently branching off someone's repo is not a copy operation. + """ + if denied := _require_auth(request): + return denied + name = request.match_info["name"] + body = await _read_json(request) + if isinstance(body, web.Response): + return body + new_name = str(body.get("new_name", "")).strip() + if not _usable_name(new_name): + return web.json_response( + { + "code": "invalid_name", + "error": ( + "new_name must be 1-64 chars: letters, digits, '-' or '_', " + "and must not look like a credential" + ), + }, + status=400, + ) + fresh = await _pinned_entry(request, name, body) + if isinstance(fresh, web.Response): + return fresh + if new_name == name: + return web.json_response( + {"code": "spec_exists", "error": "that is the same name"}, status=409 + ) + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + working_dir = str(fresh.get("working_dir", "")) + safe_wd = await asyncio.to_thread(_safe_dir, working_dir) + if safe_wd is None: + return web.json_response( + { + "code": "working_dir_not_a_directory", + "error": "this spec's project folder is no longer usable", + }, + status=400, + ) + source_dir = Path(str(fresh.get("spec_dir", ""))) + + def _source_snapshot() -> tuple[dict[str, str | None], list[str]]: + """Read every phase file once, distinguishing absent from unsafe.""" + payload: dict[str, str | None] = {} + unreadable: list[str] = [] + for _phase, fname in _PHASE_FILES: + try: + os.lstat(source_dir / fname) + existed = True + except FileNotFoundError: + existed = False + except OSError: + payload[fname] = None + unreadable.append(fname) + continue + text = _read_spec_text(source_dir, fname) + payload[fname] = text + if text is None and existed: + unreadable.append(fname) + return payload, unreadable + + def _copy() -> tuple[Path, str, dict[str, str | None], list[str]]: + """Read the source documents, then validate the destination. ONE hop.""" + payload, unreadable = _source_snapshot() + target, refusal = _prepare_spec_dir(str(safe_wd), safe_wd, new_name, False, create=False) + return target, refusal, payload, unreadable + + target_dir, refusal, docs, unreadable = await asyncio.to_thread(_copy) + if unreadable: + return web.json_response( + { + "code": "spec_document_unreadable", + "error": "one or more source documents could not be read safely", + }, + status=409, + ) + if refusal: + kind = refusal.partition(":")[0] + if kind == "existing": + return web.json_response( + { + "code": "spec_files_exist", + "error": f"'{new_name}' already has spec files on disk", + }, + status=409, + ) + if kind == "escape": + _audit("spec_path_escape_denied", f"{new_name} -> {target_dir}") + return web.json_response( + { + "code": "spec_path_outside_root", + "error": "resolved spec path is outside its root", + }, + status=400, + ) + return web.json_response( + {"code": "spec_dir_creation_failed", "error": "cannot create the copy's directory"}, + status=400, + ) + if not any(text is not None for text in docs.values()): + return web.json_response( + {"code": "nothing_to_copy", "error": "this spec has no documents to copy yet"}, + status=409, + ) + # One read per document is not a snapshot: the agent can finish writing + # requirements after it was read and then write design before that file is + # read. A second identical pass proves the payload formed one stable view, + # while the slot checks reject the known writer on both sides of the awaits. + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + confirmed_docs, confirmed_unreadable = await asyncio.to_thread(_source_snapshot) + if confirmed_unreadable or confirmed_docs != docs: + return web.json_response( + { + "code": "spec_changed_during_duplicate", + "error": "the source documents changed while they were being copied — retry", + }, + status=409, + ) + if _agent_is_writing(request, name): + return web.json_response( + { + "code": "agent_running", + "error": "the agent is busy right now — wait for the turn to finish", + }, + status=409, + ) + + slot_key = _new_slot_key(new_name) + duplicate_token = uuid.uuid4().hex + stage_dir = target_dir.parent / f".{new_name}.duplicate-{duplicate_token}" + document_hashes = { + fname: _sha256_text(text) for fname, text in docs.items() if text is not None + } + now = time.time() + entry = { + "working_dir": str(safe_wd), + "spec_dir": str(target_dir), + # Validated, not carried over blind: spec_type comes off the agent-writable + # index, and an unknown value would flow into the copy's own payload. + "spec_type": ( + st if (st := str(fresh.get("spec_type", "feature"))) in _VALID_TYPES else "feature" + ), + "status": "planning", + "slot_key": slot_key, + "worktree_branch": "", + "repo_root": "", + "title": _clean_str(fresh.get("title")), + "created_at": now, + "updated_at": now, + _DUPLICATING: { + "owner": _PROCESS_ID, + "at": now, + "token": duplicate_token, + "stage_dir": str(stage_dir), + "documents": document_hashes, + }, + } + + def _insert(index: dict) -> bool: + if new_name in index: + return False + index[new_name] = entry + return True + + stage_failure = await asyncio.to_thread(_create_duplicate_stage, stage_dir, duplicate_token) + if stage_failure: + _audit("spec_duplicate_failed", f"{name} -> {new_name}", outcome="failure") + if stage_failure == "unsupported_platform": + return web.json_response( + { + "code": "doc_write_unsupported", + "error": "duplicating is not available on this platform", + }, + status=501, + ) + return web.json_response( + {"code": "doc_write_failed", "error": "could not write the copy"}, status=400 + ) + stage_identity = await asyncio.to_thread(_duplicate_stage_identity, stage_dir, duplicate_token) + if stage_identity is None: + await asyncio.to_thread(_remove_duplicate_marker, stage_dir, duplicate_token) + _audit("spec_duplicate_failed", f"{name} -> {new_name}", outcome="failure") + return web.json_response( + {"code": "doc_write_failed", "error": "could not write the copy"}, status=400 + ) + held = entry[_DUPLICATING] + assert isinstance(held, dict) + held["stage_dev"], held["stage_ino"] = stage_identity + + async def _release_reservation() -> bool: + def _pop(index: dict) -> bool: + meta = index.get(new_name) + if ( + meta is None + or str(meta.get("slot_key", "")) != slot_key + or not _reservation_is_ours(meta, _DUPLICATING) + ): + return False + del index[new_name] + return True + + return await _mutate_index(_pop) + + def _finish(index: dict) -> bool: + meta = index.get(new_name) + if ( + meta is None + or str(meta.get("slot_key", "")) != slot_key + or not _reservation_is_ours(meta, _DUPLICATING) + ): + return False + meta.pop(_DUPLICATING, None) + meta["updated_at"] = time.time() + return True + + async def _complete_transaction() -> tuple[str, str, Path]: + """Reach a durable terminal state after publishing transaction provenance.""" + if not await _mutate_index(_insert): + # No reservation points at this empty, marker-only stage. A crash + # before cleanup strands no copied document. + await asyncio.to_thread(_remove_duplicate_marker, stage_dir, duplicate_token) + return "exists", "", target_dir + + # The marked stage exists before the name is reserved, but it is not + # populated yet. Re-run validation after arbitration so an external + # writer that placed files in the meantime is refused, not overwritten. + resolved_target, reserved_refusal = await asyncio.to_thread( + _prepare_spec_dir, + str(safe_wd), + safe_wd, + new_name, + False, + create=False, + expected_dir=target_dir, + ) + if reserved_refusal: + if await _release_reservation(): + # The stage contains no documents. Removing the reservation + # first leaves only an empty marker directory after a crash. + await asyncio.to_thread(_remove_duplicate_marker, stage_dir, duplicate_token) + return "refusal", reserved_refusal, resolved_target + + failure, created = await asyncio.to_thread( + _write_and_publish_duplicate, + stage_dir, + resolved_target, + docs, + duplicate_token, + stage_identity, + ) + if failure: + if failure == "identity_mismatch": + # A competing directory won the publication name. It is not our + # copy, so never leave this duplicate's index entry pointing at + # it; the source documents remain available for a clean retry. + await _release_reservation() + return "write_failed", failure, resolved_target + # Keep the marker while rolling back. If the process exits during + # this step, recovery still has proof that the reservation and any + # staged documents belong to this transaction. Release the index + # only after every editable document is confirmed absent, then + # remove the marker last. + rolled_back = await asyncio.to_thread(_rollback_staged_docs, stage_dir, created) + if rolled_back and await _release_reservation(): + await asyncio.to_thread(_remove_duplicate_marker, stage_dir, duplicate_token) + return "write_failed", failure, resolved_target + + await asyncio.to_thread(_forget_deleted, str(resolved_target)) + try: + finalized = await _mutate_index(_finish) + except Exception: + # Publication already committed. Keep its marker and reservation so + # startup recovery can adopt the complete copy, while containing a + # storage failure as the same recoverable response as a lost claim. + logger.exception("could not finalize duplicate index entry for %s", new_name) + return "finalization_failed", "", resolved_target + if not finalized: + return "finalization_failed", "", resolved_target + await asyncio.to_thread(_remove_duplicate_marker, resolved_target, duplicate_token) + return "success", "", resolved_target + + transaction = asyncio.create_task(_complete_transaction()) + try: + # The thread performing publication cannot be stopped by task + # cancellation. Shield reservation and finalization together, so the + # request cannot abandon a same-process reservation that recovery skips. + outcome, detail, target_dir = await asyncio.shield(transaction) + except asyncio.CancelledError as cancelled: + # Keep this handler as a strong owner of the transaction and do not + # report cancellation until its index state is terminal. Repeated + # cancellation (for example during server shutdown) cannot reopen the + # same-process recovery gap. + while not transaction.done(): + try: + await asyncio.shield(transaction) + except asyncio.CancelledError: + continue + transaction.result() + raise cancelled + + if outcome == "exists": + return web.json_response( + {"code": "spec_exists", "error": f"a spec named '{new_name}' already exists"}, + status=409, + ) + + if outcome == "refusal": + kind = detail.partition(":")[0] + if kind == "moved": + return web.json_response( + { + "code": "spec_destination_changed", + "error": "the copy destination changed while it was being created; retry", + }, + status=409, + ) + if kind == "existing": + return web.json_response( + { + "code": "spec_files_exist", + "error": f"'{new_name}' already has spec files on disk", + }, + status=409, + ) + if kind == "escape": + _audit("spec_path_escape_denied", f"{new_name} -> {target_dir}") + return web.json_response( + { + "code": "spec_path_outside_root", + "error": "resolved spec path is outside its root", + }, + status=400, + ) + return web.json_response( + {"code": "spec_dir_creation_failed", "error": "cannot create the copy's directory"}, + status=400, + ) + + if outcome == "write_failed": + _audit("spec_duplicate_failed", f"{name} -> {new_name}", outcome="failure") + if detail == "unsupported_platform": + return web.json_response( + { + "code": "doc_write_unsupported", + "error": "duplicating is not available on this platform", + }, + status=501, + ) + return web.json_response( + {"code": "doc_write_failed", "error": "could not write the copy"}, status=400 + ) + + if outcome == "finalization_failed": + # Publication is already atomic and visible. Preserve the complete, + # marker-provenanced copy so a surviving reservation can recover it on + # restart; deleting its contents would leave a destination name that no + # future no-replace publication could win. + return web.json_response( + { + "code": "spec_changed_during_create", + "error": "the copy was published but its reservation changed; reopen or import the existing copy", + }, + status=409, + ) + entry.pop(_DUPLICATING, None) + # adopt_closed=False for the same reason create passes it: a name reused after + # a delete must not hand the fresh agent the deleted spec's transcript. + slot = await _ensure_worker_slot(request.app.get("state"), new_name, entry, adopt_closed=False) + if slot is None: + # The index and documents are committed before session arbitration. + # Retain both so the published copy stays discoverable and recoverable. + return web.json_response( + { + "code": "slot_owned_by_another_app", + "error": f"a chat session named '{new_name}' is owned by another app", + }, + status=409, + ) + try: + slot.title = f"Spec: {new_name}" + slot._titled = True + if (state := request.app.get("state")) is not None and hasattr(state, "push_slot_title"): + state.push_slot_title(slot.key, slot.title) + except Exception: + logger.debug("title set failed", exc_info=True) + _dispatch_turn(request.app.get("state"), slot, _duplicate_prompt(new_name, name, target_dir)) + _audit("spec_duplicate", f"{name} -> {new_name}") + return web.json_response({"name": new_name, "spec_dir": _redact(str(target_dir))}, status=201) + + async def _handle_stop_execution(request: web.Request) -> web.Response: if denied := _require_auth(request): return denied @@ -3433,7 +5436,11 @@ async def _handle_stop_execution(request: web.Request) -> web.Response: logger.warning("spec %s: halt failed", name, exc_info=True) _audit("spec_stop_failed", name, outcome="denied") return web.json_response( - {"code": "stop_failed", "error": "could not stop the run; it may still be working — retry"}, status=503 + { + "code": "stop_failed", + "error": "could not stop the run; it may still be working — retry", + }, + status=503, ) # Re-reading commit: halting awaits, so a concurrent DELETE in that window # must not be undone by writing back the snapshot above. The halt itself is @@ -3484,9 +5491,14 @@ async def _handle_delete(request: web.Request) -> web.Response: # key that only the ORIGINAL name may own, leaving the conversation unreachable. # Marking keeps the entry (hidden from the list), so the name cannot be taken and # a rollback restores the original with its key intact. - if not await _mark_deleting( - name, expect_spec_dir=doomed_dir, expect_slot_key=doomed_slot_key - ): + # A task request holds this lock across its final document validation and + # dispatch. Wait for that boundary before reserving teardown, after which + # _DELETING makes every task re-pin fail closed. + async with _spec_execution_lock(request, name): + marked_deleting = await _mark_deleting( + name, expect_spec_dir=doomed_dir, expect_slot_key=doomed_slot_key + ) + if not marked_deleting: await asyncio.to_thread(_forget_deleted, doomed_dir) return web.json_response({"code": "not_found", "error": "not found"}, status=404) # RESERVED -- only now capture the runtime. Capturing before the reservation left @@ -3513,7 +5525,10 @@ async def _handle_delete(request: web.Request) -> web.Response: await asyncio.to_thread(_forget_deleted, doomed_dir) _audit("spec_delete_aborted", name, outcome="denied") return web.json_response( - {"code": "loop_removal_failed", "error": "could not stop this spec's background loop; nothing was deleted"}, + { + "code": "loop_removal_failed", + "error": "could not stop this spec's background loop; nothing was deleted", + }, status=503, ) # NOW tear the worker slot down: removing only the nudge loop left the @@ -3527,9 +5542,7 @@ async def _handle_delete(request: web.Request) -> web.Response: # to be logged at DEBUG while the delete returned 200 -- the transcript silently # gone. Now the reservation is released instead, so the spec is still listed with # its session intact and the retry is meaningful. - if not await _teardown_worker_slot( - state, name, only_slot=doomed_slot, require_archive=True - ): + if not await _teardown_worker_slot(state, name, only_slot=doomed_slot, require_archive=True): released = await _unmark_deleting(name, expect_spec_dir=doomed_dir) # The spec lives again, so the tombstone must go: leaving it would suppress # the documents from discovery for a spec that was never deleted. @@ -3595,6 +5608,12 @@ def register_routes(app: web.Application) -> None: (``_save_index`` / ``_save_settings``) mkdirs on its own worker thread. """ base = f"/api/apps/{APP_NAME}" + # Mutable per-Application state lets the first enabled request publish one + # recovery task without mutating a frozen aiohttp Application. Registration + # itself stays filesystem-free so gateway readiness never depends on this app. + recovery: _DuplicateRecoveryState = {"task": None} + app[_DUPLICATE_RECOVERY_STATE] = recovery + app[_SPEC_EXECUTION_LOCKS] = {} app.router.add_get(f"{base}/settings", _require_enabled(_handle_get_settings)) app.router.add_put(f"{base}/settings", _require_enabled(_handle_put_settings)) # POST alias: the SPA page uses POST for settings writes. @@ -3611,5 +5630,13 @@ def register_routes(app: web.Application) -> None: # Alias: the SPA page calls this "execute". app.router.add_post(f"{base}/specs/{{name}}/execute", _require_enabled(_handle_handoff)) app.router.add_post(f"{base}/specs/{{name}}/stop", _require_enabled(_handle_stop_execution)) + # Direct authority over the artifacts, rather than only the ability to ask the + # agent for a change: record a phase approval, run one task, and manage the + # label / archive / duplicate lifecycle. + app.router.add_post(f"{base}/specs/{{name}}/approve", _require_enabled(_handle_approve)) + app.router.add_post(f"{base}/specs/{{name}}/task", _require_enabled(_handle_run_task)) + app.router.add_post(f"{base}/specs/{{name}}/title", _require_enabled(_handle_title)) + app.router.add_post(f"{base}/specs/{{name}}/archive", _require_enabled(_handle_archive)) + app.router.add_post(f"{base}/specs/{{name}}/duplicate", _require_enabled(_handle_duplicate)) app.router.add_delete(f"{base}/specs/{{name}}", _require_enabled(_handle_delete)) logger.info("spec-builder: registered app routes under %s", base) diff --git a/src/kiro_crew/apps/builtins/spec_builder/skills/spec-workflow/SKILL.md b/src/kiro_crew/apps/builtins/spec_builder/skills/spec-workflow/SKILL.md index 512858ab881..24e6d99f3ac 100644 --- a/src/kiro_crew/apps/builtins/spec_builder/skills/spec-workflow/SKILL.md +++ b/src/kiro_crew/apps/builtins/spec_builder/skills/spec-workflow/SKILL.md @@ -24,6 +24,14 @@ the output is portable to Kiro IDE/CLI. recommended answer, and wait. Never ask about things you can discover yourself by reading `` with your tools. - Keep every file self-contained, concrete, and free of placeholders. +- **Read the project's own conventions before you write anything.** Check + `` for `.kiro/steering/**/*.md` and `AGENTS.md`, and read whatever you + find. Those files carry the build commands, test layout, naming rules and review + conventions the rest of the toolchain already honors, so a spec written without them + can plan work that contradicts the repo it targets. Let them constrain the design and + the task list (which test framework a task uses, which directory a module belongs in, + how a change gets verified). When steering contradicts the user's request, say so in + chat and ask which wins rather than silently picking one. ## Spec types diff --git a/src/kiro_crew/apps/builtins/spec_builder/tests/conftest.py b/src/kiro_crew/apps/builtins/spec_builder/tests/conftest.py index 2977f7cc312..c23a01bb5f9 100644 --- a/src/kiro_crew/apps/builtins/spec_builder/tests/conftest.py +++ b/src/kiro_crew/apps/builtins/spec_builder/tests/conftest.py @@ -41,11 +41,17 @@ "test_sentinel_clear_is_pinned_to_the_verified_directory", "test_stop_sentinel_write_destroys_planted_symlink", "test_prepare_handoff_reports_tasks_and_clears_a_stale_sentinel", + "test_prepare_handoff_refuses_a_tasks_file_with_no_open_task", "test_prepare_handoff_still_clears_for_the_matching_identity", "test_prepare_handoff_unpinned_call_keeps_working", "test_stop_write_is_refused_for_a_replaced_spec", "test_halt_execution_leaves_user_trust_alone", "test_verified_spec_dir_accepts_an_ordinary_directory", + # Duplicate creation writes through the SAME pinned descriptor and fails + # closed without it. These assert bytes written by that POSIX-only path. + "test_duplicate_doc_create_only_succeeds_while_the_file_is_absent", + "test_duplicate_doc_create_retries_short_writes", + "test_duplicate_copies_the_documents_into_a_fresh_spec", } #: POSIX path shape: separators, absolute-path spelling and ``~`` expansion all diff --git a/src/kiro_crew/apps/builtins/spec_builder/tests/test_routes.py b/src/kiro_crew/apps/builtins/spec_builder/tests/test_routes.py index 51326beb886..1f6e50a2f7b 100644 --- a/src/kiro_crew/apps/builtins/spec_builder/tests/test_routes.py +++ b/src/kiro_crew/apps/builtins/spec_builder/tests/test_routes.py @@ -14,6 +14,7 @@ (credentials never reach the browser), recursively across nested values; * the slot-key prefix is ``spec-builder-`` (renamed from the external app). """ + from __future__ import annotations import ast @@ -28,6 +29,7 @@ import time import types from pathlib import Path +from typing import cast import pytest from aiohttp import web @@ -56,6 +58,12 @@ def _redirect_state(monkeypatch, tmp_path): return state_dir +def _stage_identity(path: Path) -> dict[str, int]: + """Serialize the inode identity captured with a duplicate reservation.""" + info = path.stat() + return {"stage_dev": info.st_dev, "stage_ino": info.st_ino} + + def _live_state_snapshot() -> dict[str, int]: """Names + mtimes of the guarded live state dir, or {} when there is none. @@ -91,9 +99,9 @@ def _never_touch_the_real_state(monkeypatch, tmp_path): before = _live_state_snapshot() _redirect_state(monkeypatch, tmp_path / "_autouse_state") yield - assert _live_state_snapshot() == before, ( - f"a test wrote to the live state dir: {_REAL_STATE_DIR}" - ) + assert ( + _live_state_snapshot() == before + ), f"a test wrote to the live state dir: {_REAL_STATE_DIR}" #: The un-redirected state dir the autouse guard watches. ``None`` until @@ -126,12 +134,15 @@ def test_register_routes_wires_expected_set(tmp_path, monkeypatch): # paths directly on the router (the external app's AppRoute-list contract # was converted during the port). mypy flags asserting a None return # (func-returns-value), so just call it. + startup_before = list(app.on_startup) routes.register_routes(app) + assert ( + list(app.on_startup) == startup_before + ), "Spec Builder filesystem recovery would block socket readiness" + wired = { - (r.method, r.resource.canonical) - for r in app.router.routes() - if r.resource is not None + (r.method, r.resource.canonical) for r in app.router.routes() if r.resource is not None } expected = { ("GET", f"{_BASE}/settings"), @@ -147,11 +158,33 @@ def test_register_routes_wires_expected_set(tmp_path, monkeypatch): ("POST", f"{_BASE}/specs/{{name}}/handoff"), ("POST", f"{_BASE}/specs/{{name}}/execute"), ("POST", f"{_BASE}/specs/{{name}}/stop"), + # Direct authority over the artifacts: record a phase approval, run one + # task, and the label / archive / duplicate lifecycle. + ("POST", f"{_BASE}/specs/{{name}}/approve"), + ("POST", f"{_BASE}/specs/{{name}}/task"), + ("POST", f"{_BASE}/specs/{{name}}/title"), + ("POST", f"{_BASE}/specs/{{name}}/archive"), + ("POST", f"{_BASE}/specs/{{name}}/duplicate"), ("DELETE", f"{_BASE}/specs/{{name}}"), } assert expected <= wired +@pytest.mark.asyncio +async def test_duplicate_recovery_runs_once_on_first_enabled_request(tmp_path, monkeypatch): + recovered: list[str] = [] + monkeypatch.setattr(routes, "is_app_enabled", lambda _name: True) + monkeypatch.setattr( + routes, "_recover_abandoned_reservations", lambda: recovered.append("recovered") + ) + + async with _make_client(monkeypatch, tmp_path) as client: + assert (await client.get(f"{_BASE}/settings")).status == 200 + assert (await client.get(f"{_BASE}/specs")).status == 200 + + assert recovered == ["recovered"] + + def test_slot_key_prefix_renamed(): # Ported from the external app's 'kiro-specs-' prefix to 'spec-builder-'. assert routes._slot_key("demo") == "spec-builder-demo" @@ -185,7 +218,9 @@ async def test_settings_model_roundtrips_and_empty_means_inherit(tmp_path, monke literal model name. An unknown name is kept (availability is only decidable in a live session, where the withhold path owns it).""" async with _make_client(monkeypatch, tmp_path) as client: - resp = await client.put(f"{_BASE}/settings", json={"base_path": "", "model": " test-model-x "}) + resp = await client.put( + f"{_BASE}/settings", json={"base_path": "", "model": " test-model-x "} + ) assert resp.status == 200 assert (await resp.json()) == {"ok": True, "base_path": "", "model": "test-model-x"} @@ -205,7 +240,9 @@ async def test_settings_write_without_model_key_preserves_the_stored_model(tmp_p base_path must not silently erase a configured model. Absence preserves; clearing requires an explicit ''.""" async with _make_client(monkeypatch, tmp_path) as client: - resp = await client.put(f"{_BASE}/settings", json={"base_path": "", "model": "test-model-x"}) + resp = await client.put( + f"{_BASE}/settings", json={"base_path": "", "model": "test-model-x"} + ) assert resp.status == 200 # Legacy-shaped write: no model key at all. @@ -223,7 +260,9 @@ async def test_settings_rejects_malformed_model(tmp_path, monkeypatch): names the problem, and an over-length id is rejected rather than truncated (a sliced id is a different string that is never served).""" async with _make_client(monkeypatch, tmp_path) as client: - resp = await client.put(f"{_BASE}/settings", json={"base_path": "", "model": ["not", "a", "string"]}) + resp = await client.put( + f"{_BASE}/settings", json={"base_path": "", "model": ["not", "a", "string"]} + ) assert resp.status == 400 assert (await resp.json())["code"] == "model_not_a_string" @@ -443,7 +482,11 @@ async def test_create_rejects_sensitive_working_dir(tmp_path, monkeypatch): async with _make_client(monkeypatch, tmp_path) as client: resp = await client.post( f"{_BASE}/specs", - json={"name": "probe", "working_dir": os.path.expanduser("~/.ssh"), "spec_type": "feature"}, + json={ + "name": "probe", + "working_dir": os.path.expanduser("~/.ssh"), + "spec_type": "feature", + }, ) assert resp.status == 400 assert "sensitive" in (await resp.json())["error"] @@ -606,7 +649,7 @@ def test_browse_scan_is_offloadable_and_bounded(tmp_path): out = routes._scan_subdirs(str(tmp_path)) names = {d["name"] for d in out} - assert names == {f"dir{i}" for i in range(5)} # no hidden, no skip-list, no files + assert names == {f"dir{i}" for i in range(5)} # no hidden, no skip-list, no files assert routes._BROWSE_MAX_DIRS > 0 @@ -738,8 +781,8 @@ def get_slot(self, key): spec_dir = tmp_path / "spec" spec_dir.mkdir() await routes._halt_execution(_State(), "s", spec_dir, reason="user stop") - assert (spec_dir / routes._STOP_FILE).is_file() # loop is sentinelled - assert slot._trust is True # user's choice preserved + assert (spec_dir / routes._STOP_FILE).is_file() # loop is sentinelled + assert slot._trust is True # user's choice preserved # ── GPT round-5 findings (#518) ─────────────────────────────────────────────── @@ -772,8 +815,8 @@ def test_list_handler_offloads_folder_discovery(): assert inline not in src, f"{inline} still called inline in the list handler" -def test_collect_spec_documents_returns_the_detail_triple(tmp_path): - """Non-vacuous: the bundled collector actually produces phase + docs + state.""" +def test_collect_spec_documents_returns_the_detail_bundle(tmp_path): + """The bundled collector produces phase, docs, state and task metadata.""" spec_dir = tmp_path / "spec" spec_dir.mkdir() @@ -781,10 +824,16 @@ def test_collect_spec_documents_returns_the_detail_triple(tmp_path): (spec_dir / "design.md").write_text("# design") (spec_dir / ".spec-state.json").write_text(json.dumps({"blocking": "waiting on you"})) - phase, files, state = routes._collect_spec_documents(spec_dir) - assert phase == "design" # newest present phase file wins + phase, files, state, meta = routes._collect_spec_documents(spec_dir) + assert phase == "design" # newest present phase file wins assert files["requirements.md"] == "# reqs" assert state is not None and state["blocking"] == "waiting on you" + # The hash is of the file AS STORED because it binds a phase approval to the + # exact document reviewed. + assert meta["docs"]["requirements.md"]["hash"] == routes._sha256_text("# reqs") + assert set(meta["docs"]["requirements.md"]) == {"hash"} + # No tasks.md here, so the list is empty rather than absent. + assert meta["tasks"] == [] and meta["task_progress"] == {"done": 0, "total": 0} # (2) deleting a spec must tear down its worker slot @@ -805,6 +854,7 @@ def cancel(self): def __await__(self): async def _done(): return None + return _done().__await__() class _Slot: @@ -830,6 +880,7 @@ def get_slot(self, key): async def test_teardown_refuses_a_slot_this_app_does_not_own(): """Anti-collision: a slot whose _app is not ours must be left alone rather than deleted because its key happens to match.""" + class _Slot: _app = "some-other-app" running = False @@ -872,8 +923,12 @@ async def test_mutate_index_reads_a_fresh_index_not_the_callers_snapshot(tmp_pat # Stand in for the concurrent request that lands during the await: it edits # the file directly, exactly as another handler's own mutation would. - routes._save_index({"kept": {"spec_dir": "/a/kept", "status": "planning"}, - "added-meanwhile": {"spec_dir": "/a/added", "status": "planning"}}) + routes._save_index( + { + "kept": {"spec_dir": "/a/kept", "status": "planning"}, + "added-meanwhile": {"spec_dir": "/a/added", "status": "planning"}, + } + ) assert await routes._mutate_index(lambda idx: idx.pop("kept", None) is not None) is True after = routes._load_index() @@ -904,10 +959,786 @@ def test_load_index_releases_a_reservation_left_by_a_dead_process(tmp_path, monk loaded = routes._load_index() assert "orphaned" in loaded, "the entry must survive -- the delete never completed" - assert routes._DELETING not in loaded["orphaned"], ( - "a reservation from a process that is gone still hides the spec and reserves its name" + assert ( + routes._DELETING not in loaded["orphaned"] + ), "a reservation from a process that is gone still hides the spec and reserves its name" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="atomic no-replace directory publication is unavailable", +) +def test_duplicate_publication_never_replaces_a_destination_created_concurrently( + tmp_path, monkeypatch +): + """The destination's creation and the publish syscall are one arbitration. + + An existence check followed by ordinary rename loses this race on POSIX: + rename replaces an empty directory, including its identity and metadata. + """ + stage = tmp_path / ".copy.stage" + stage.mkdir() + (stage / "requirements.md").write_text("# staged") + target = tmp_path / "copy" + target_identity: list[tuple[int, int]] = [] + real_rename = routes.rename_noreplace + + def _writer_wins_before_the_syscall(*args, **kwargs): + target.mkdir(mode=0o700) + stat = target.stat() + target_identity.append((stat.st_dev, stat.st_ino)) + return real_rename(*args, **kwargs) + + monkeypatch.setattr(routes, "rename_noreplace", _writer_wins_before_the_syscall) + + assert routes._publish_staged_copy(stage, target) == "conflict" + after = target.stat() + assert (after.st_dev, after.st_ino) == target_identity[0] + assert stage.is_dir(), "the losing staged copy replaced the concurrent destination" + + +@pytest.mark.skipif( + not routes._CAN_PIN_DIR, + reason="descriptor-pinned duplicate writes are unavailable", +) +def test_duplicate_doc_creation_refuses_an_ancestor_swapped_after_validation(tmp_path, monkeypatch): + """The opened directory, not its raceable pathname, owns authorization.""" + project = tmp_path / "project" + spec_dir = project / "copy" + spec_dir.mkdir(parents=True) + external_parent = tmp_path / "external" + external_spec = external_parent / "copy" + external_spec.mkdir(parents=True) + original_project = tmp_path / "original-project" + real_verified = routes._verified_spec_dir + swapped = False + + def _verify_then_swap(path): + nonlocal swapped + verified = real_verified(path) + if path == spec_dir and verified is not None and not swapped: + project.rename(original_project) + project.symlink_to(external_parent, target_is_directory=True) + swapped = True + return verified + + monkeypatch.setattr(routes, "_verified_spec_dir", _verify_then_swap) + + result, identity = routes._create_spec_doc(spec_dir, "requirements.md", "# copied") + + assert result == "unsafe_dir" + assert identity is None + assert not (external_spec / "requirements.md").exists() + + +@pytest.mark.skipif( + not routes._CAN_PIN_DIR, + reason="descriptor-pinned duplicate cleanup is unavailable", +) +def test_duplicate_rollback_refuses_an_ancestor_swapped_after_validation(tmp_path, monkeypatch): + """A redirected path cannot make rollback unlink an external matching inode.""" + project = tmp_path / "project" + spec_dir = project / "copy" + spec_dir.mkdir(parents=True) + external_parent = tmp_path / "external" + external_spec = external_parent / "copy" + external_spec.mkdir(parents=True) + external_doc = external_spec / "requirements.md" + external_doc.write_text("# external") + stat = external_doc.stat() + identity = (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns) + original_project = tmp_path / "original-project" + real_verified = routes._verified_spec_dir + swapped = False + + def _verify_then_swap(path): + nonlocal swapped + verified = real_verified(path) + if path == spec_dir and verified is not None and not swapped: + project.rename(original_project) + project.symlink_to(external_parent, target_is_directory=True) + swapped = True + return verified + + monkeypatch.setattr(routes, "_verified_spec_dir", _verify_then_swap) + + routes._rollback_staged_docs(spec_dir, {"requirements.md": identity}) + + assert external_doc.read_text() == "# external" + + +@pytest.mark.skipif( + not routes._CAN_PIN_DIR, + reason="descriptor-pinned duplicate cleanup is unavailable", +) +def test_duplicate_cleanup_leaves_the_empty_stage_instead_of_racing_replacement(tmp_path): + """POSIX has no inode-bound rmdir, so safe cleanup stops at an empty stage.""" + stage = tmp_path / ".copy.duplicate-deadbeef" + stage.mkdir() + + routes._rollback_staged_docs(stage, {}) + + assert stage.is_dir() + assert not any(stage.iterdir()) + + +@pytest.mark.skipif( + not routes._CAN_PIN_DIR, + reason="descriptor-pinned duplicate staging is unavailable", +) +def test_duplicate_marker_failure_does_not_remove_a_redirected_stage(tmp_path, monkeypatch): + """Marker unwind cannot follow a swapped ancestor into an external tree.""" + project = tmp_path / "project" + project.mkdir() + external_parent = tmp_path / "external" + external_parent.mkdir() + token = "d" * 32 + stage = project / f".copy.duplicate-{token}" + external_stage = external_parent / stage.name + external_stage.mkdir() + original_project = tmp_path / "original-project" + + def _swap_then_refuse(_stage_fd, _duplicate_token): + project.rename(original_project) + project.symlink_to(external_parent, target_is_directory=True) + return False + + monkeypatch.setattr(routes, "_write_duplicate_marker_at", _swap_then_refuse) + + result = routes._create_duplicate_stage(stage, token) + + assert result == "write_failed" + assert external_stage.is_dir() + assert (original_project / stage.name).is_dir() + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +def test_duplicate_write_failure_retains_provenance_until_documents_are_rolled_back( + tmp_path, monkeypatch +): + """Failure cleanup cannot create markerless staged-document residue.""" + token = "d" * 32 + stage = tmp_path / f".copy.duplicate-{token}" + target = tmp_path / "copy" + assert routes._create_duplicate_stage(stage, token) == "" + real_create = routes._create_spec_doc + calls = 0 + + def _fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + return "write_failed", None + return real_create(*args, **kwargs) + + monkeypatch.setattr(routes, "_create_spec_doc", _fail_second) + result, created = routes._write_and_publish_duplicate( + stage, + target, + {"requirements.md": "# copied", "design.md": "# fails"}, + token, + ) + + assert result == "write_failed" + assert routes._duplicate_marker_matches(stage, token) + assert routes._rollback_staged_docs(stage, created) is True + assert routes._duplicate_marker_matches(stage, token) + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +def test_duplicate_fsyncs_staged_directory_entries_before_publication(tmp_path, monkeypatch): + """A durable rename cannot expose child entries that were never persisted.""" + token = "d" * 32 + stage = tmp_path / f".copy.duplicate-{token}" + target = tmp_path / "copy" + assert routes._create_duplicate_stage(stage, token) == "" + stage_stat = stage.stat() + stage_identity = (stage_stat.st_dev, stage_stat.st_ino) + stage_synced = False + real_fsync = routes.os.fsync + + def _record_stage_fsync(fd): + nonlocal stage_synced + current = os.fstat(fd) + if (current.st_dev, current.st_ino) == stage_identity: + stage_synced = True + return real_fsync(fd) + + monkeypatch.setattr(routes.os, "fsync", _record_stage_fsync) + + result, _created = routes._write_and_publish_duplicate( + stage, target, {"requirements.md": "# copied"}, token + ) + + assert result == "" + assert stage_synced + assert (target / "requirements.md").read_text() == "# copied" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +def test_duplicate_refuses_a_stage_replaced_at_the_publication_boundary(tmp_path, monkeypatch): + """The renamed directory must be the descriptor-pinned transaction stage.""" + token = "d" * 32 + stage = tmp_path / f".copy.duplicate-{token}" + displaced_stage = tmp_path / ".displaced-transaction" + target = tmp_path / "copy" + assert routes._create_duplicate_stage(stage, token) == "" + real_publish = routes._publish_staged_copy + + def _replace_then_publish(stage_dir, target_dir): + stage_dir.rename(displaced_stage) + stage_dir.mkdir() + (stage_dir / "requirements.md").write_text("# attacker payload") + return real_publish(stage_dir, target_dir) + + monkeypatch.setattr(routes, "_publish_staged_copy", _replace_then_publish) + + result, _created = routes._write_and_publish_duplicate( + stage, target, {"requirements.md": "# intended payload"}, token + ) + + assert result == "identity_mismatch" + assert (target / "requirements.md").read_text() == "# attacker payload" + assert (displaced_stage / "requirements.md").read_text() == "# intended payload" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +def test_duplicate_refuses_a_stage_replaced_before_document_creation(tmp_path, monkeypatch): + """Copied documents are written only through the reservation's stage inode.""" + token = "d" * 32 + stage = tmp_path / f".copy.duplicate-{token}" + displaced_stage = tmp_path / ".displaced-transaction" + target = tmp_path / "copy" + assert routes._create_duplicate_stage(stage, token) == "" + expected_identity = routes._duplicate_stage_identity(stage, token) + assert expected_identity is not None + real_create = routes._create_spec_doc + + def _replace_then_create(*args, **kwargs): + stage.rename(displaced_stage) + stage.mkdir() + (stage / routes._DUPLICATE_MARKER).write_text(token) + return real_create(*args, **kwargs) + + monkeypatch.setattr(routes, "_create_spec_doc", _replace_then_create) + + result, _created = routes._write_and_publish_duplicate( + stage, + target, + {"requirements.md": "# intended payload"}, + token, + expected_identity, + ) + + assert result == "identity_mismatch" + assert not (stage / "requirements.md").exists() + assert (displaced_stage / routes._DUPLICATE_MARKER).read_text() == token + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires atomic no-replace directory publication", +) +def test_startup_recovery_publishes_a_complete_duplicate_staged_by_a_dead_process( + tmp_path, monkeypatch +): + """A crash after all documents are staged must not hide the copy forever. + + Custom ``base_path`` roots are not part of folder discovery, so dropping the + reservation would strand the files while their name remains unusable. + """ + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + documents = {"requirements.md": "# copied", "design.md": ""} + for fname, text in documents.items(): + (stage / fname).write_text(text) + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **_stage_identity(stage), + "documents": { + fname: routes._sha256_text(text) for fname, text in documents.items() + }, + }, + } + } + ) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" in loaded + assert routes._DUPLICATING not in loaded["copy"] + assert not stage.exists() + assert (target / "requirements.md").read_text() == "# copied" + assert (target / "design.md").read_text() == "" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires atomic no-replace directory publication", +) +def test_startup_recovery_refuses_a_stage_replaced_at_the_publication_boundary( + tmp_path, monkeypatch +): + """Recovery must publish the inode whose marker and manifest it validated.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + displaced_stage = target.parent / ".displaced-transaction" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + (stage / "requirements.md").write_text("# intended payload") + identity = _stage_identity(stage) + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **identity, + "documents": { + "requirements.md": routes._sha256_text("# intended payload"), + }, + }, + } + } + ) + real_publish = routes._publish_staged_copy + + def _replace_then_publish(stage_dir, target_dir): + stage_dir.rename(displaced_stage) + stage_dir.mkdir() + (stage_dir / "requirements.md").write_text("# attacker payload") + return real_publish(stage_dir, target_dir) + + monkeypatch.setattr(routes, "_publish_staged_copy", _replace_then_publish) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" not in loaded + assert (target / "requirements.md").read_text() == "# attacker payload" + assert (displaced_stage / "requirements.md").read_text() == "# intended payload" + + +def test_startup_recovery_does_not_adopt_a_competing_target_after_identity_check_crash( + tmp_path, monkeypatch +): + """An ambiguous post-rename crash remains reserved instead of adopting files.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + displaced_stage = target.parent / ".displaced-transaction" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + (stage / "requirements.md").write_text("# intended payload") + identity = _stage_identity(stage) + stage.rename(displaced_stage) + target.mkdir() + (target / "requirements.md").write_text("# attacker payload") + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **identity, + "documents": { + "requirements.md": routes._sha256_text("# intended payload"), + }, + }, + } + } + ) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert routes._DUPLICATING in loaded["copy"] + assert (target / "requirements.md").read_text() == "# attacker payload" + assert (displaced_stage / routes._DUPLICATE_MARKER).read_text() == token + + +def test_startup_recovery_preserves_a_spec_with_unproven_duplicate_metadata(tmp_path, monkeypatch): + """Untrusted reservation metadata is not proof that the entry is disposable.""" + _redirect_state(monkeypatch, tmp_path) + spec_dir = tmp_path / "custom" / "real-spec" + spec_dir.mkdir(parents=True) + (spec_dir / "requirements.md").write_text("# keep me") + token = "d" * 32 + routes._save_index( + { + "real-spec": { + "working_dir": str(tmp_path), + "spec_dir": str(spec_dir), + "status": "planning", + "slot_key": "spec-builder-real-spec-deadbeef", + "approvals": {"requirements": {"hash": "a" * 64}}, + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(spec_dir.parent / f".real-spec.duplicate-{token}"), + "documents": { + "requirements.md": routes._sha256_text("# keep me"), + }, + }, + } + } + ) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "real-spec" in loaded + assert routes._DUPLICATING not in loaded["real-spec"] + assert loaded["real-spec"]["approvals"] == {"requirements": {"hash": "a" * 64}} + assert (spec_dir / "requirements.md").read_text() == "# keep me" + + +def test_startup_recovery_preserves_an_unproven_reservation_with_no_files(tmp_path, monkeypatch): + """Well-shaped metadata alone cannot prove that recovery may delete a spec.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "offline" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + approvals = {"requirements": {"hash": "a" * 64}} + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + "approvals": approvals, + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + "documents": { + "requirements.md": routes._sha256_text("# copied"), + }, + }, + } + } + ) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" in loaded + assert routes._DUPLICATING not in loaded["copy"] + assert loaded["copy"]["approvals"] == approvals + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires atomic no-replace directory publication", +) +@pytest.mark.parametrize("target_exists_before_recovery", [False, True]) +def test_startup_recovery_discards_duplicate_when_an_external_target_wins( + tmp_path, monkeypatch, target_exists_before_recovery +): + """A proven duplicate must not adopt a competing writer's target files.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + (stage / "requirements.md").write_text("# copied") + if target_exists_before_recovery: + target.mkdir() + (target / "requirements.md").write_text("# external") + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **_stage_identity(stage), + "documents": { + "requirements.md": routes._sha256_text("# copied"), + }, + }, + } + } + ) + + def _concurrent_writer_wins(stage_dir, target_dir): + assert not target_exists_before_recovery + target_dir.mkdir() + (target_dir / "requirements.md").write_text("# external") + return "conflict" + + monkeypatch.setattr(routes, "_publish_staged_copy", _concurrent_writer_wins) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" not in loaded + assert (target / "requirements.md").read_text() == "# external" + assert stage.is_dir() + assert not any(stage.iterdir()) + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires descriptor-pinned cleanup", +) +@pytest.mark.parametrize("external_target", [False, True]) +def test_startup_recovery_cleanup_stays_on_the_proven_stage_inode( + tmp_path, monkeypatch, external_target +): + """Cleanup cannot unlink files from a replacement at the reserved stage name.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + displaced_stage = target.parent / ".displaced-transaction" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + (stage / "requirements.md").write_text("# copied") + if external_target: + target.mkdir() + (target / "requirements.md").write_text("# external") + documents = {"requirements.md": routes._sha256_text("# copied")} + if not external_target: + documents["design.md"] = routes._sha256_text("# missing") + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **_stage_identity(stage), + "documents": documents, + }, + } + } + ) + real_clear = routes._clear_duplicate_stage_documents_at + + def _replace_then_clear(dir_fd, marker_token, manifest): + stage.rename(displaced_stage) + stage.mkdir() + (stage / "requirements.md").write_text("# unrelated") + return real_clear(dir_fd, marker_token, manifest) + + monkeypatch.setattr(routes, "_clear_duplicate_stage_documents_at", _replace_then_clear) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" not in loaded + assert (stage / "requirements.md").read_text() == "# unrelated" + assert not (displaced_stage / "requirements.md").exists() + assert (displaced_stage / routes._DUPLICATE_MARKER).read_text() == token + if external_target: + assert (target / "requirements.md").read_text() == "# external" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires atomic no-replace directory publication", +) +def test_startup_recovery_keeps_a_duplicate_published_by_a_dead_process(tmp_path, monkeypatch): + """The exact post-rename/pre-index crash window keeps the visible copy.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + target.mkdir(parents=True) + (target / routes._DUPLICATE_MARKER).write_text(token) + (target / "requirements.md").write_text("# copied") + stage = target.parent / f".copy.duplicate-{token}" + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **_stage_identity(target), + "documents": { + "requirements.md": routes._sha256_text("# copied"), + }, + }, + } + } + ) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" in loaded + assert routes._DUPLICATING not in loaded["copy"] + assert (target / "requirements.md").read_text() == "# copied" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires atomic no-replace directory publication", +) +def test_startup_recovery_discards_an_incomplete_duplicate_stage_from_a_dead_process( + tmp_path, monkeypatch +): + """A marker-provenanced partial stage is transaction residue, not user data.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + (stage / "requirements.md").write_text("# only the first document landed") + documents = { + "requirements.md": routes._sha256_text("# only the first document landed"), + "design.md": routes._sha256_text("# never written"), + } + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **_stage_identity(stage), + "documents": documents, + }, + } + } + ) + + real_save = routes._save_index + + def _assert_cleanup_order(index): + if "copy" not in index: + assert routes._duplicate_marker_matches(stage, token) + assert not (stage / "requirements.md").exists() + real_save(index) + + monkeypatch.setattr(routes, "_save_index", _assert_cleanup_order) + + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert "copy" not in loaded + assert stage.is_dir() + assert not any(stage.iterdir()), "the abandoned transaction left hidden copied documents" + + +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate recovery requires descriptor-pinned cleanup", +) +@pytest.mark.parametrize("external_target", [False, True]) +def test_startup_recovery_preserves_a_mismatched_staged_document( + tmp_path, monkeypatch, external_target +): + """A marker proves the stage directory, not ownership of replaced files.""" + _redirect_state(monkeypatch, tmp_path) + token = "d" * 32 + target = tmp_path / "custom" / "copy" + stage = target.parent / f".copy.duplicate-{token}" + stage.mkdir(parents=True) + (stage / routes._DUPLICATE_MARKER).write_text(token) + moved_file = tmp_path / "user-requirements.md" + moved_file.write_text("# user file moved into the abandoned stage") + moved_file.replace(stage / "requirements.md") + if external_target: + target.mkdir() + (target / "requirements.md").write_text("# external target") + routes._save_index( + { + "copy": { + "working_dir": str(tmp_path), + "spec_dir": str(target), + "status": "planning", + "slot_key": "spec-builder-copy-deadbeef", + routes._DUPLICATING: { + "owner": "1234:deadbeef", + "at": 1.0, + "token": token, + "stage_dir": str(stage), + **_stage_identity(stage), + "documents": { + "requirements.md": routes._sha256_text("# copied"), + }, + }, + } + } ) + routes._recover_abandoned_reservations() + loaded = routes._load_index() + + assert routes._DUPLICATING in loaded["copy"] + assert (stage / "requirements.md").read_text() == "# user file moved into the abandoned stage" + if external_target: + assert (target / "requirements.md").read_text() == "# external target" + def test_load_index_releases_a_legacy_bare_timestamp_reservation(tmp_path, monkeypatch): """An index written by an older build stores the marker as a bare float, which @@ -939,9 +1770,9 @@ async def test_load_index_keeps_a_reservation_this_process_still_owns(tmp_path, ) loaded = routes._load_index() - assert routes._DELETING in loaded["live"], ( - "this process's own in-flight reservation was released by its own read" - ) + assert ( + routes._DELETING in loaded["live"] + ), "this process's own in-flight reservation was released by its own read" @pytest.mark.asyncio @@ -963,9 +1794,9 @@ async def test_released_reservation_is_persisted_by_the_next_mutation(tmp_path, assert await routes._touch_spec("orphaned", status="planning") is not None on_disk = json.loads((tmp_path / "spec-builder" / "index.json").read_text()) - assert routes._DELETING not in on_disk["orphaned"], ( - "the stale reservation is still on disk after a mutation" - ) + assert ( + routes._DELETING not in on_disk["orphaned"] + ), "the stale reservation is still on disk after a mutation" @pytest.mark.asyncio @@ -994,9 +1825,10 @@ async def test_touch_spec_returns_the_fresh_entry(tmp_path, monkeypatch): def test_no_handler_writes_the_index_from_a_stale_snapshot(): - """Source guard for the CLASS, not the two reported instances. Every handler + """Source guard for the CLASS, not the reported instances. Every handler awaits somewhere, so none of them may call _save_index directly: the only - sanctioned writers are the re-reading mutator and the discovery loader.""" + sanctioned writers are the re-reading mutator, startup recovery, and the + discovery loader.""" src = inspect.getsource(routes) writers = [ @@ -1004,8 +1836,8 @@ def test_no_handler_writes_the_index_from_a_stale_snapshot(): for ln in src.splitlines() if "_save_index(" in ln and not ln.strip().startswith(("#", "*", "def _save_index")) ] - # _mutate_index and _load_index_with_discovery hold the only write sites. - assert len(writers) == 2, f"unexpected _save_index call sites: {writers}" + # Each sanctioned writer holds _INDEX_LOCK around its read-modify-write. + assert len(writers) == 3, f"unexpected _save_index call sites: {writers}" for handler in ( routes._handle_create, routes._handle_message, @@ -1014,9 +1846,9 @@ def test_no_handler_writes_the_index_from_a_stale_snapshot(): routes._handle_delete, routes._handle_list, ): - assert "_save_index(" not in inspect.getsource(handler), ( - f"{handler.__name__} writes the index directly; it must go through _mutate_index" - ) + assert "_save_index(" not in inspect.getsource( + handler + ), f"{handler.__name__} writes the index directly; it must go through _mutate_index" def test_handoff_commits_before_dispatch_and_unwinds_on_deletion(): @@ -1043,9 +1875,9 @@ def test_handoff_commits_before_dispatch_and_unwinds_on_deletion(): assert claim < src.index("_ensure_worker_slot("), "the slot is created before the claim" assert claim < arm, "the loop is armed before the execution state is recorded" assert claim < dispatch, "handoff dispatches before claiming the run" - assert src[claim:dispatch].count("_release(") == 4, ( - "an abort arm does not release the loop, the recorded state and the slot" - ) + assert ( + src[claim:dispatch].count("_release(") == 4 + ), "an abort arm does not release the loop, the recorded state and the slot" # ── blocking sentinel write on the event loop ──────────────────────────────── @@ -1081,7 +1913,8 @@ def test_prepare_handoff_arms_under_the_same_lock_as_the_identity_check(): def calls_arm(node): return any( - isinstance(c, ast.Call) and isinstance(c.func, ast.Name) + isinstance(c, ast.Call) + and isinstance(c.func, ast.Name) and c.func.id == "_arm_stop_sentinel" for c in ast.walk(node) ) @@ -1116,9 +1949,9 @@ def test_stop_write_is_refused_for_a_replaced_spec(tmp_path, monkeypatch): ) assert routes._write_stop_sentinel_for_spec(spec_dir, "s", "stale-key") is False - assert not (spec_dir / routes._STOP_FILE).exists(), ( - "a stale Stop wrote a STOP into the replacement's directory" - ) + assert not ( + spec_dir / routes._STOP_FILE + ).exists(), "a stale Stop wrote a STOP into the replacement's directory" # Ordinary case: the caller's captured key still matches, so it writes. assert routes._write_stop_sentinel_for_spec(spec_dir, "s", "new-key") is True assert (spec_dir / routes._STOP_FILE).exists() @@ -1136,9 +1969,9 @@ def test_halt_execution_writes_the_sentinel_off_the_loop(): src = inspect.getsource(routes._halt_execution) # Whitespace-insensitive: the call spans lines, and a literal anchor that a # reformat can break is a guard that silently stops guarding. - assert re.search(r"asyncio\.to_thread\(\s*_write_stop_sentinel_for_spec", src), ( - "the STOP write no longer rides the identity-pinned wrapper off-loop" - ) + assert re.search( + r"asyncio\.to_thread\(\s*_write_stop_sentinel_for_spec", src + ), "the STOP write no longer rides the identity-pinned wrapper off-loop" assert "\n _write_stop_sentinel(" not in src, "sentinel still written on the event loop" @@ -1149,9 +1982,9 @@ def test_handoff_does_no_filesystem_work_on_the_loop(): src = inspect.getsource(routes._handle_handoff) # Whitespace-insensitive: the call spans lines, and a literal anchor that # a reformat can break is a guard that silently stops guarding. - assert re.search(r"asyncio\.to_thread\(\s*_prepare_handoff", src), ( - "handoff no longer hands _prepare_handoff to a worker thread" - ) + assert re.search( + r"asyncio\.to_thread\(\s*_prepare_handoff", src + ), "handoff no longer hands _prepare_handoff to a worker thread" for inline in ("_clear_stop_sentinel(", "os.path.realpath(", '/ "tasks.md"'): assert inline not in src, f"{inline} still runs on the event loop in handoff" @@ -1173,6 +2006,62 @@ def test_prepare_handoff_reports_tasks_and_clears_a_stale_sentinel(tmp_path): assert routes._prepare_handoff(spec_dir)[0] is True +@pytest.mark.parametrize( + "body", + [ + "", + " \n\n\t\n", + "# Tasks\n\nProse with no checkbox at all.\n", + "- [x] done\n- [X] also done\n", + "- [ x] not an empty box\n", + "-[ ] no space after the marker\n", + ], +) +def test_open_task_predicate_rejects_a_plan_with_nothing_to_do(body): + """A tasks.md the autonomous prompt cannot act on: the prompt tells the agent + to work through each UNCHECKED task, so an empty file, prose, or an + all-checked list leaves the run with no work.""" + assert routes._has_open_task(body) is False + + +@pytest.mark.parametrize( + "body", + [ + "- [ ] task", + "* [ ] star marker", + "+ [ ] plus marker", + "1. [ ] ordered marker", + "2) [ ] paren-ordered marker", + "- [] bare brackets", + " - [ ] indented under a heading", + "\t- [ ] tab-indented", + "- [x] done first\n- [ ] then this one is open\n", + ], +) +def test_open_task_predicate_accepts_the_marker_styles_a_model_writes(body): + """The list is model-written and its marker style varies between runs, so the + gate accepts every Markdown checkbox spelling rather than only ``- [ ]``.""" + assert routes._has_open_task(body) is True + + +def test_prepare_handoff_refuses_a_tasks_file_with_no_open_task(tmp_path): + """The reported gap: the gate stat-ed tasks.md and accepted it on existence + alone, so a zero-byte or all-checked plan armed an autonomous loop that had + nothing to act on while the spec still read as a finished Tasks phase.""" + spec_dir = tmp_path / "spec" + spec_dir.mkdir() + tasks = spec_dir / "tasks.md" + + tasks.write_text("") + assert routes._prepare_handoff(spec_dir)[0] is False, "empty tasks.md armed a run" + + tasks.write_text("- [x] everything already done\n") + assert routes._prepare_handoff(spec_dir)[0] is False, "all-checked tasks.md armed a run" + + tasks.write_text("- [x] done\n- [ ] still open\n") + assert routes._prepare_handoff(spec_dir)[0] is True + + # ── GPT round-7 findings (#518) ────────────────────────────────────────────── @@ -1200,6 +2089,7 @@ def _insert(key): def _apply(index): index[key] = {"spec_dir": f"/a/{key}", "status": "planning"} return True + return _apply await asyncio.gather(*(routes._mutate_index(_insert(f"spec-{i}")) for i in range(6))) @@ -1301,7 +2191,9 @@ def _stub_spawn(monkeypatch): """Replace the sandboxed argv with a trivial no-op binary. The host running the suite may have no sandbox backend available, and this test is about the AUDIT contract, not about sandboxing or git itself.""" - monkeypatch.setattr(routes, "sandboxed_spawn_argv", lambda argv: ([sys.executable, "-c", ""], None, "")) + monkeypatch.setattr( + routes, "sandboxed_spawn_argv", lambda argv: ([sys.executable, "-c", ""], None, "") + ) @pytest.mark.asyncio @@ -1367,6 +2259,7 @@ async def test_detail_payload_reports_live_running_state(tmp_path, monkeypatch): """The reported defect: the detail payload carried `status` but not `running`, so the selected spec's working indicator, document skeleton and fast 2.5s poll were all permanently off — the one place they matter.""" + monkeypatch.setattr(routes, "_CAN_PUBLISH_DIR_NOREPLACE", False) client = _make_client(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "live" spec_dir.mkdir(parents=True) @@ -1400,6 +2293,7 @@ def get_or_create_slot(self, name, app=""): await client.close() assert body["running"] is True, "detail payload omits the live running flag" + assert body["duplicate_supported"] is False # ── GPT round-9 findings (#518) ────────────────────────────────────────────── @@ -1454,9 +2348,9 @@ def test_create_arbitrates_the_index_before_touching_the_shared_slot(): src = inspect.getsource(routes._handle_create) arbitration = src.index("_mutate_index(_insert)") for acquisition in ("get_or_create_slot(", "_ensure_worker_slot("): - assert acquisition not in src[:arbitration], ( - "create acquires the shared slot before the index arbitration" - ) + assert ( + acquisition not in src[:arbitration] + ), "create acquires the shared slot before the index arbitration" for mutation in ("slot.project =", "slot._app =", "slot.title ="): assert mutation not in src[:arbitration], f"{mutation} happens before arbitration" assert "_ensure_worker_slot(" in src[arbitration:] @@ -1486,6 +2380,7 @@ def get_or_create_slot(self, name, app=""): class _S: key = name _app = app + return _S() def get_slot(self, key): @@ -1527,6 +2422,7 @@ def get_or_create_slot(self, name, app=""): class _S: key = name _app = app + return _S() def get_slot(self, key): @@ -1561,6 +2457,7 @@ def done(self): def __await__(self): async def _done(): return None + return _done().__await__() class _Slot: @@ -1588,6 +2485,7 @@ def get_slot(self, key): async def test_pause_leaves_a_foreign_slot_alone(): """Anti-collision: a slot this app does not own must not be stopped because its key happens to match.""" + class _Slot: key = "spec-builder-x" _app = "some-other-app" @@ -1695,7 +2593,9 @@ def get_or_create_slot(self, name, app=""): assert out is slot assert created == ["spec-builder-found"], "the missing slot was not created" assert slot._app == routes.APP_NAME, "slot left unscoped — it would show in the main sidebar" - assert slot.project == "/projects/thing", "slot has no project — tools would run in the wrong dir" + assert ( + slot.project == "/projects/thing" + ), "slot has no project — tools would run in the wrong dir" assert slot._titled is True @@ -1717,9 +2617,9 @@ def test_every_slot_acquisition_goes_through_the_scoping_chokepoint(): routes._handle_handoff, routes._handle_create, ): - assert "_ensure_worker_slot(" in inspect.getsource(handler), ( - f"{handler.__name__} does not scope the slot it uses" - ) + assert "_ensure_worker_slot(" in inspect.getsource( + handler + ), f"{handler.__name__} does not scope the slot it uses" # ── GPT round-12 findings (#518) ───────────────────────────────────────────── @@ -1749,12 +2649,14 @@ def test_no_handler_reads_the_index_on_the_event_loop(): "_aload_index", "_mutate_index", "_load_index_with_discovery", + "_recover_abandoned_reservations", "_load_index", "_prepare_handoff", "_write_stop_sentinel_for_spec", } for off_loop in ( "_load_index_with_discovery", + "_recover_abandoned_reservations", "_prepare_handoff", "_write_stop_sentinel_for_spec", ): @@ -1786,9 +2688,9 @@ def test_no_handler_reads_the_index_on_the_event_loop(): routes._handle_delete, routes._handle_create, ): - assert "await _aload_index()" in inspect.getsource(handler), ( - f"{handler.__name__} does not read the index off the loop" - ) + assert "await _aload_index()" in inspect.getsource( + handler + ), f"{handler.__name__} does not read the index off the loop" @pytest.mark.asyncio @@ -1827,9 +2729,7 @@ def test_read_recent_projects_filters_to_existing_dirs(tmp_path, monkeypatch): home.mkdir() real = tmp_path / "real-project" real.mkdir() - (home / "recent_projects.json").write_text( - json.dumps([str(real), str(tmp_path / "gone"), 42]) - ) + (home / "recent_projects.json").write_text(json.dumps([str(real), str(tmp_path / "gone"), 42])) monkeypatch.setattr(routes, "config_dir", lambda: home) assert routes._read_recent_projects() == [str(real)] @@ -1860,6 +2760,7 @@ async def test_missing_git_degrades_instead_of_500(tmp_path, monkeypatch): async def test_git_reports_unavailable_when_the_sandbox_refuses(tmp_path, monkeypatch): """Same contract when the sandbox cannot build an argv at all — this host has no sandbox backend, which is exactly how the suite hits that path.""" + def _boom(_argv): raise RuntimeError("sandbox backend unavailable") @@ -1879,9 +2780,7 @@ def test_repo_info_validates_through_the_chokepoint_off_loop(): src = inspect.getsource(routes._handle_repo_info) assert "asyncio.to_thread(_safe_dir" in src - code = [ - ln for ln in src.splitlines() if ln.strip() and not ln.strip().startswith("#") - ] + code = [ln for ln in src.splitlines() if ln.strip() and not ln.strip().startswith("#")] for ln in code: assert "is_dir()" not in ln, f"repo-info still stats on the event loop: {ln.strip()}" @@ -1955,9 +2854,9 @@ async def test_persisted_transcript_is_read_off_the_event_loop(): continue if "conversation_log.read_messages" not in stripped: continue - assert stripped.startswith("state.conversation_log.read_messages,"), ( - f"read_messages still called on the loop: {stripped}" - ) + assert stripped.startswith( + "state.conversation_log.read_messages," + ), f"read_messages still called on the loop: {stripped}" # And the caller must await it, not schedule a coroutine into the payload. assert "await _serialize_messages(" in inspect.getsource(routes._handle_messages) @@ -2251,19 +3150,13 @@ def test_load_settings_degrades_a_credential_shaped_model_to_inherit(tmp_path, m fake redactor mirrors the real one's contract (clean text passes unchanged).""" monkeypatch.setattr(routes, "_SETTINGS_PATH", tmp_path / "settings.json") monkeypatch.setattr(routes, "_STATE_DIR", tmp_path) - monkeypatch.setattr( - routes, "_redact", lambda t: t.replace("SECRET-MARKER", "[redacted]") - ) - (tmp_path / "settings.json").write_text( - json.dumps({"base_path": "", "model": "SECRET-MARKER"}) - ) - assert routes._load_settings()["model"] == "", ( - "a credential-shaped model survived the read chokepoint" - ) + monkeypatch.setattr(routes, "_redact", lambda t: t.replace("SECRET-MARKER", "[redacted]")) + (tmp_path / "settings.json").write_text(json.dumps({"base_path": "", "model": "SECRET-MARKER"})) + assert ( + routes._load_settings()["model"] == "" + ), "a credential-shaped model survived the read chokepoint" # Contract check on the fake: a clean id passes through untouched. - (tmp_path / "settings.json").write_text( - json.dumps({"base_path": "", "model": "clean-model"}) - ) + (tmp_path / "settings.json").write_text(json.dumps({"base_path": "", "model": "clean-model"})) assert routes._load_settings()["model"] == "clean-model" @@ -2272,9 +3165,7 @@ async def test_settings_write_rejects_a_credential_shaped_model(tmp_path, monkey """The write path is the other half of the load-chokepoint degrade: a credential-shaped value gets a machine-readable 400 instead of being persisted and riding the slot stamp to the browser.""" - monkeypatch.setattr( - routes, "_redact", lambda t: t.replace("SECRET-MARKER", "[redacted]") - ) + monkeypatch.setattr(routes, "_redact", lambda t: t.replace("SECRET-MARKER", "[redacted]")) async with _make_client(monkeypatch, tmp_path) as client: resp = await client.put( f"{_BASE}/settings", json={"base_path": "", "model": "SECRET-MARKER"} @@ -2408,9 +3299,7 @@ async def test_detail_refuses_when_the_spec_is_recreated_mid_request(tmp_path, m old_dir = tmp_path / "old" / ".kiro" / "specs" / "moved" new_dir = tmp_path / "new" / ".kiro" / "specs" / "moved" new_dir.mkdir(parents=True) - routes._save_index( - {"moved": {"spec_dir": str(old_dir), "working_dir": str(tmp_path / "old")}} - ) + routes._save_index({"moved": {"spec_dir": str(old_dir), "working_dir": str(tmp_path / "old")}}) real_collect = routes._collect_spec_documents @@ -2462,9 +3351,7 @@ async def test_detail_serves_normally_when_nothing_changes(tmp_path, monkeypatch spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "steady" spec_dir.mkdir(parents=True) (spec_dir / "requirements.md").write_text("# r") - routes._save_index( - {"steady": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"steady": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) class _Slot: key = "spec-builder-steady" @@ -2533,6 +3420,7 @@ async def test_abort_cleanup_spares_a_replacement_slot(): """The reported defect, introduced by round 20's abort path: both cleanups look the slot up BY NAME, so unwinding a refused handoff destroyed the slot of the same-name spec that had replaced ours.""" + class _Slot: def __init__(self, tag): self.tag = tag @@ -2601,8 +3489,13 @@ async def test_stale_executing_status_settles_back_to_planning(tmp_path, monkeyp _redirect_state(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "done" routes._save_index( - {"done": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), - "status": "executing"}} + { + "done": { + "spec_dir": str(spec_dir), + "working_dir": str(tmp_path / "wd"), + "status": "executing", + } + } ) meta = routes._load_index()["done"] @@ -2635,6 +3528,7 @@ def _svc(active): class _Svc: def get_by_slot(self, key): return _Loop(active) + return lambda: _Svc() monkeypatch.setattr(routes, "_autonudge_instance", _svc(True)) @@ -2685,9 +3579,7 @@ async def test_handoff_confirms_identity_before_acquiring_the_slot(tmp_path, mon for d in (old_dir, new_dir): d.mkdir(parents=True) (d / "tasks.md").write_text("- [ ] task") - routes._save_index( - {"swap": {"spec_dir": str(old_dir), "working_dir": str(tmp_path / "old")}} - ) + routes._save_index({"swap": {"spec_dir": str(old_dir), "working_dir": str(tmp_path / "old")}}) def _prepare_then_recreate(spec_dir, name="", expect_slot_key=""): # Mirrors the real signature: the handler now passes the name and the @@ -2730,7 +3622,7 @@ def test_handoff_checks_identity_before_slot_acquisition(): """Source guard on the ORDERING, which is the whole fix.""" src = inspect.getsource(routes._handle_handoff) - check = src.index('!= str(spec_dir)') + check = src.index("!= str(spec_dir)") acquire = src.index("_ensure_worker_slot(") assert check < acquire, "handoff acquires the slot before confirming identity" @@ -2755,9 +3647,7 @@ async def test_message_refuses_a_recreated_spec(tmp_path, monkeypatch): async def _read_then_recreate(request): body = await real_read(request) - routes._save_index( - {"t": {"spec_dir": str(new_dir), "working_dir": str(tmp_path / "new")}} - ) + routes._save_index({"t": {"spec_dir": str(new_dir), "working_dir": str(tmp_path / "new")}}) return body monkeypatch.setattr(routes, "_read_json", _read_then_recreate) @@ -2854,9 +3744,9 @@ def test_name_only_operations_are_identity_pinned(): ): src = inspect.getsource(handler) assert f"{cap} = _exec_loop_id(" in src, f"{handler.__name__} does not capture the loop" - assert "only_loop_id=" in src and "only_slot=" in src, ( - f"{handler.__name__} does not pin its cleanups" - ) + assert ( + "only_loop_id=" in src and "only_slot=" in src + ), f"{handler.__name__} does not pin its cleanups" # The capture must precede the teardown it pins (nothing may await in # between and let a recreate become the thing we act on). assert src.index(cap) < src.index(acts_on), f"{handler.__name__} captures too late" @@ -2877,9 +3767,7 @@ def test_sandbox_setup_is_offloaded(): stripped = line.strip() if stripped.startswith("#"): continue - assert inline not in stripped, ( - f"spawn setup still runs on the event loop: {stripped}" - ) + assert inline not in stripped, f"spawn setup still runs on the event loop: {stripped}" prep = inspect.getsource(routes._prepare_git_spawn) assert "sandboxed_spawn_argv(" in prep # Limits are applied AFTER exec by the shim (see test_spawn_preexec_guard), so no @@ -2986,9 +3874,9 @@ def test_seed_prompt_is_self_contained_and_type_aware(): quick = routes._seed_prompt("quick", "thing", spec_dir, "/w", "make it fast") assert "spec-workflow" not in quick, "seed still points at an unavailable skill" - assert "design.md" not in quick.split("Do NOT write design.md")[0], ( - "quick spec is still told to write design.md" - ) + assert ( + "design.md" not in quick.split("Do NOT write design.md")[0] + ), "quick spec is still told to write design.md" assert "Do NOT write design.md" in quick assert str(spec_dir / "requirements.md") in quick assert str(spec_dir / "tasks.md") in quick @@ -3071,9 +3959,7 @@ async def test_handoff_unwinds_when_the_index_commit_raises(tmp_path, monkeypatc spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "boom" spec_dir.mkdir(parents=True) (spec_dir / "tasks.md").write_text("- [ ] task") - routes._save_index( - {"boom": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"boom": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) removed: list[str] = [] dispatched: list[str] = [] @@ -3192,15 +4078,16 @@ class _State: def get_slot(self, key): return slot + return _State() for owner in (None, "", "some-other-app"): stopped.clear() cancelled["v"] = False slot = _Slot(owner) - assert await routes._halt_active_turn(_state_for(slot), "shared") is False, ( - f"stopped a turn on a slot owned by {owner!r}" - ) + assert ( + await routes._halt_active_turn(_state_for(slot), "shared") is False + ), f"stopped a turn on a slot owned by {owner!r}" assert stopped == [] and cancelled["v"] is False # Our own slot is still stopped. @@ -3233,9 +4120,7 @@ async def test_failed_handoff_keeps_a_pre_existing_conversation(tmp_path, monkey spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "chatty" spec_dir.mkdir(parents=True) (spec_dir / "tasks.md").write_text("- [ ] task") - routes._save_index( - {"chatty": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"chatty": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) class _Loop: id = "loop-armed" @@ -3298,9 +4183,9 @@ def get_or_create_slot(self, name, app=""): await client.close() assert resp.status == 500 - assert slots.get("spec-builder-chatty") is existing, ( - "a pre-existing conversation was destroyed by the failed handoff" - ) + assert ( + slots.get("spec-builder-chatty") is existing + ), "a pre-existing conversation was destroyed by the failed handoff" @pytest.mark.asyncio @@ -3310,9 +4195,7 @@ async def test_delete_commits_the_index_before_closing_the_session(tmp_path, mon conversation already discarded -- unusable and unrecoverable.""" client = _make_client(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "keepme" - routes._save_index( - {"keepme": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"keepme": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) async def _boom(_mutate): raise OSError("index write failed") @@ -3343,9 +4226,9 @@ def get_slot(self, key): finally: await client.close() - assert slots.get("spec-builder-keepme") is slot, ( - "the session was discarded before the delete was committed" - ) + assert ( + slots.get("spec-builder-keepme") is slot + ), "the session was discarded before the delete was committed" def test_delete_orders_reserve_teardown_remove(): @@ -3372,10 +4255,8 @@ def test_handoff_unwind_is_gated_on_having_created_the_slot(): # Delimit by the unwind's own last statement rather than the next "try:" — # the body contains one now (the best-effort loop removal), and slicing to it # cut the assertion's search space to nothing. - release = src[start:src.index('_audit("spec_handoff_aborted"', start)] - assert "if not slot_pre_existed:" in release, ( - "unwind tears down a slot it may not have created" - ) + release = src[start : src.index('_audit("spec_handoff_aborted"', start)] + assert "if not slot_pre_existed:" in release, "unwind tears down a slot it may not have created" # ── GPT round-31 findings (#518) ───────────────────────────────────────────── @@ -3479,15 +4360,14 @@ def get_or_create_slot(self, name, app=""): assert slot.project == "", "a sensitive directory became the agent's cwd" # Non-existent path -> also refused (the chokepoint requires an existing dir). - assert await routes._ensure_worker_slot( - _State(), "x", {"working_dir": str(tmp_path / "gone")} - ) is None + assert ( + await routes._ensure_worker_slot(_State(), "x", {"working_dir": str(tmp_path / "gone")}) + is None + ) assert slot.project == "" # Ordinary project -> accepted and scoped. - assert await routes._ensure_worker_slot( - _State(), "x", {"working_dir": str(ordinary)} - ) is slot + assert await routes._ensure_worker_slot(_State(), "x", {"working_dir": str(ordinary)}) is slot assert slot.project == str(ordinary) @@ -3496,9 +4376,9 @@ def test_working_dir_validation_is_offloaded(): run BEFORE the assignment it protects.""" src = inspect.getsource(routes._ensure_worker_slot) assert "asyncio.to_thread(_safe_dir" in src - assert src.index("safe_wd = await asyncio.to_thread") < src.index("slot.project ="), ( - "the cwd is assigned before it is validated" - ) + assert src.index("safe_wd = await asyncio.to_thread") < src.index( + "slot.project =" + ), "the cwd is assigned before it is validated" assert "slot.project = wd" not in src, "the raw indexed value is still assigned" @@ -3557,9 +4437,9 @@ def get_or_create_slot(self, name, app=""): assert resp.status == 409, "create dispatched against a replaced spec" assert dispatched == [] # The REPLACEMENT's index entry survives -- the unwind is identity-pinned. - assert routes._load_index()["racy"]["spec_dir"] == str(other), ( - "the unwind deleted the replacement spec's index entry" - ) + assert routes._load_index()["racy"]["spec_dir"] == str( + other + ), "the unwind deleted the replacement spec's index entry" def test_create_unwind_is_identity_pinned(): @@ -3568,7 +4448,7 @@ def test_create_unwind_is_identity_pinned(): src = inspect.getsource(routes._handle_create) assert "idx.pop(name, None)" not in src, "create unwinds by name alone" assert "_pop_if_ours" in src - recheck = src.index('!= str(spec_dir)') + recheck = src.index("!= str(spec_dir)") assert recheck < src.index("_dispatch_turn("), "create dispatches before rechecking identity" @@ -3611,8 +4491,9 @@ async def test_list_survives_a_malformed_index_entry(tmp_path, monkeypatch): spec_dir.mkdir(parents=True) routes._index_path().parent.mkdir(parents=True, exist_ok=True) routes._index_path().write_text( - json.dumps({"good": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}, - "bad": None}) + json.dumps( + {"good": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}, "bad": None} + ) ) await client.start_server() @@ -3633,7 +4514,7 @@ async def test_message_identity_comes_from_the_client(tmp_path, monkeypatch): be the one the CLIENT captured.""" src = inspect.getsource(routes._handle_message) assert 'body.get("spec_dir"' in src, "message still derives identity from the index" - assert 'expect_spec_dir=str(index[name]' not in src + assert "expect_spec_dir=str(index[name]" not in src client = _make_client(monkeypatch, tmp_path) old_dir = tmp_path / "old" / ".kiro" / "specs" / "m" @@ -3733,9 +4614,11 @@ async def _spy_halt(*_a, **_k): finally: await client.close() - assert (ex.status, st.status, rm.status) == (409, 409, 409), ( - f"a stale control was honoured: {(ex.status, st.status, rm.status)}" - ) + assert (ex.status, st.status, rm.status) == ( + 409, + 409, + 409, + ), f"a stale control was honoured: {(ex.status, st.status, rm.status)}" assert halted == [], "a side effect ran for a stale client" assert "c" in routes._load_index(), "the replacement spec was deleted by a stale tab" @@ -3813,9 +4696,10 @@ async def test_restart_restores_the_worker_transcript(tmp_path, monkeypatch): slot = await routes._ensure_worker_slot(state, "s1", {"working_dir": str(tmp_path)}) assert slot is not None - assert [m["content"] for m in slot.messages] == ["build me a spec", "on it"], ( - "the persisted conversation did not come back" - ) + assert [m["content"] for m in slot.messages] == [ + "build me a spec", + "on it", + ], "the persisted conversation did not come back" assert state.created == [], "an empty slot was created instead of restoring" @@ -3859,9 +4743,9 @@ def test_transcript_restore_runs_before_slot_creation(): resume in the sidebar.""" # Compare CODE only: the docstring names get_or_create_slot too. body = inspect.getsource(routes._ensure_worker_slot).split('"""')[-1] - assert body.index("_restore_worker_transcript") < body.index("get_or_create_slot"), ( - "the empty slot is created before the transcript is restored" - ) + assert body.index("_restore_worker_transcript") < body.index( + "get_or_create_slot" + ), "the empty slot is created before the transcript is restored" # ── GPT round-37 findings + scrub/CodeQL fallout (#518) ────────────────────── @@ -3874,7 +4758,12 @@ def test_settings_reader_normalizes_a_non_string_base_path(tmp_path, monkeypatch _redirect_state(monkeypatch, tmp_path) routes._settings_path().parent.mkdir(parents=True, exist_ok=True) - for bad in ('{"base_path": []}', '{"base_path": 7}', '{"base_path": null}', '{"base_path": {}}'): + for bad in ( + '{"base_path": []}', + '{"base_path": 7}', + '{"base_path": null}', + '{"base_path": {}}', + ): routes._settings_path().write_text(bad) assert routes._load_settings()["base_path"] == "", bad # The real crash was downstream: this must not raise. @@ -3891,9 +4780,9 @@ def test_opt_in_flags_require_a_real_boolean(): flags cause side effects a retry cannot undo, so the check is exact.""" rejected: tuple[object, ...] = ("false", "0", "no", "", 0, [], None, 1, "true") for truthy_but_not_true in rejected: - assert routes._opted_in({"use_worktree": truthy_but_not_true}, "use_worktree") is False, ( - f"{truthy_but_not_true!r} opted in" - ) + assert ( + routes._opted_in({"use_worktree": truthy_but_not_true}, "use_worktree") is False + ), f"{truthy_but_not_true!r} opted in" assert routes._opted_in({"use_worktree": True}, "use_worktree") is True assert routes._opted_in({}, "import_existing") is False @@ -3925,7 +4814,9 @@ def get_or_create_slot(self, name, app=""): for bad in ("x" * 400, "../etc/passwd", "has space", "", "9" * 200 + "." + "9" * 200): state = _State() - assert await routes._ensure_worker_slot(state, bad, {"working_dir": str(tmp_path)}) is None, bad + assert ( + await routes._ensure_worker_slot(state, bad, {"working_dir": str(tmp_path)}) is None + ), bad assert state.touched == [], f"{bad[:20]!r} reached the slot layer" @@ -3935,9 +4826,9 @@ def test_browse_skip_list_carries_no_hidden_paths(): literal internal path marker in the source, which the repo's scrub lint rejects.""" assert not [n for n in routes._BROWSE_SKIP if n.startswith(".")] - assert "startswith(\".\")" in inspect.getsource(routes._scan_subdirs), ( - "the hidden-entry skip that makes the dotted names redundant is gone" - ) + assert 'startswith(".")' in inspect.getsource( + routes._scan_subdirs + ), "the hidden-entry skip that makes the dotted names redundant is gone" # ── GPT round-38 findings (#518) ────────────────────────────────────────────── @@ -3956,7 +4847,7 @@ async def test_stop_reads_the_body_before_the_index(tmp_path, monkeypatch): capture_at = src.index("_exec_loop_id(name)") assert body_at < index_at < capture_at, "the body await is not first" # And no await may sit between the verified identity and the capture. - between = src[src.index("_client_identity_mismatch(claimed"):capture_at] + between = src[src.index("_client_identity_mismatch(claimed") : capture_at] assert "await" not in between, "an await reopened the capture window" @@ -4012,9 +4903,7 @@ def test_sentinel_clear_is_pinned_to_the_verified_directory(tmp_path, monkeypatc def test_sentinel_pin_capability_is_resolved_once(): """Guard: the confinement must be decided from real platform capability, not a per-call guess, and the source must not fall back to a bare path unlink.""" - assert routes._CAN_PIN_DIR is ( - hasattr(os, "O_DIRECTORY") and os.unlink in os.supports_dir_fd - ) + assert routes._CAN_PIN_DIR is (hasattr(os, "O_DIRECTORY") and os.unlink in os.supports_dir_fd) src = inspect.getsource(routes._clear_stop_sentinel) assert "dir_fd=dir_fd" in src assert "O_NOFOLLOW" in src @@ -4043,12 +4932,16 @@ def test_index_entries_missing_identity_fields_are_dropped(tmp_path, monkeypatch _redirect_state(monkeypatch, tmp_path) routes._index_path().parent.mkdir(parents=True, exist_ok=True) good = {"spec_dir": str(tmp_path / "s"), "working_dir": str(tmp_path)} - routes._index_path().write_text(json.dumps({ - "demo": {}, # the reported shape - "blank": {"spec_dir": " "}, - "typed": {"spec_dir": []}, - "ok": good, - })) + routes._index_path().write_text( + json.dumps( + { + "demo": {}, # the reported shape + "blank": {"spec_dir": " "}, + "typed": {"spec_dir": []}, + "ok": good, + } + ) + ) index = routes._load_index() @@ -4067,18 +4960,14 @@ async def test_delete_aborts_when_the_loop_cannot_be_removed(tmp_path, monkeypat client = _make_client(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "doomed" spec_dir.mkdir(parents=True) - routes._save_index( - {"doomed": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"doomed": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) async def _boom(*_a, **_k): raise OSError("autonudge store is read-only") monkeypatch.setattr(routes, "_remove_nudge_loop", _boom) torn_down: list[str] = [] - monkeypatch.setattr( - routes, "_teardown_worker_slot", lambda *a, **k: _noop_await(torn_down) - ) + monkeypatch.setattr(routes, "_teardown_worker_slot", lambda *a, **k: _noop_await(torn_down)) await client.start_server() try: @@ -4097,17 +4986,20 @@ async def _noop_await(sink: list) -> None: @pytest.mark.asyncio -async def test_stop_reports_failure_instead_of_a_halt_that_did_not_happen( - tmp_path, monkeypatch -): +async def test_stop_reports_failure_instead_of_a_halt_that_did_not_happen(tmp_path, monkeypatch): """Same class on the stop path: reporting "planning" while the loop can still nudge tells the user to stop worrying about a run that is still going.""" client = _make_client(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "running" spec_dir.mkdir(parents=True) routes._save_index( - {"running": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), - "status": "executing"}} + { + "running": { + "spec_dir": str(spec_dir), + "working_dir": str(tmp_path / "wd"), + "status": "executing", + } + } ) async def _boom(*_a, **_k): @@ -4123,9 +5015,9 @@ async def _boom(*_a, **_k): await client.close() assert resp.status == 503, await resp.text() - assert routes._load_index()["running"].get("status") == "executing", ( - "the spec was marked stopped after a failed halt" - ) + assert ( + routes._load_index()["running"].get("status") == "executing" + ), "the spec was marked stopped after a failed halt" def test_loop_removal_does_not_swallow_failures(): @@ -4160,9 +5052,7 @@ def get_slot(self, key): return None def get_or_create_slot(self, name, app=""): - return types.SimpleNamespace( - key=name, _app=app, project="", messages=[], _titled=False - ) + return types.SimpleNamespace(key=name, _app=app, project="", messages=[], _titled=False) meta = {"working_dir": str(tmp_path), "spec_dir": str(tmp_path / "s")} await routes._ensure_worker_slot(_State(), "reused-name", meta, adopt_closed=False) @@ -4180,9 +5070,9 @@ def test_create_handler_refuses_closed_history(): Matching the bare keyword also matched the comment above it explaining why — a guard a revert could not fail.""" src = inspect.getsource(routes._handle_create) - assert "_ensure_worker_slot(state, name, entry, adopt_closed=False)" in src, ( - "create can adopt a deleted conversation again" - ) + assert ( + "_ensure_worker_slot(state, name, entry, adopt_closed=False)" in src + ), "create can adopt a deleted conversation again" @pytest.mark.asyncio @@ -4194,12 +5084,14 @@ async def test_delete_restores_the_spec_when_archiving_fails(tmp_path, monkeypat client = _make_client(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "keepme" spec_dir.mkdir(parents=True) - entry = {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), - "spec_type": "plan"} + entry = {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), "spec_type": "plan"} routes._save_index({"keepme": entry}) slot = types.SimpleNamespace( - key=routes._slot_key("keepme"), _app=routes.APP_NAME, running=False, task=None, + key=routes._slot_key("keepme"), + _app=routes.APP_NAME, + running=False, + task=None, messages=[{"role": "user", "content": "unsaved work"}], ) @@ -4217,6 +5109,7 @@ async def _archive_boom(*_a, **_k): raise OSError("history volume is full") import kiro_crew.dashboard.chat_persistence as cp + monkeypatch.setattr(cp, "save_slot_off_loop", _archive_boom) await client.start_server() @@ -4363,9 +5256,9 @@ def test_detail_reports_the_slot_key_it_scoped(tmp_path, monkeypatch): a reused name would mount the embed on the previous spec's transcript. The detail payload therefore names the session the app itself scoped.""" src = inspect.getsource(routes._handle_get) - assert '"slot_key": getattr(slot, "key", None) or _slot_key(name)' in src, ( - "detail no longer tells the client which slot to mount" - ) + assert ( + '"slot_key": getattr(slot, "key", None) or _slot_key(name)' in src + ), "detail no longer tells the client which slot to mount" @pytest.mark.asyncio @@ -4389,6 +5282,7 @@ def get_slot(self, key): state = _State() import kiro_crew.dashboard.chat_persistence as cp + monkeypatch.setattr(cp, "save_slot_off_loop", lambda *a, **k: _noop_await([])) assert await routes._teardown_worker_slot(state, "s", only_slot=slot) is True @@ -4462,9 +5356,9 @@ def test_sentinel_write_is_pinned_to_the_verified_directory(tmp_path, monkeypatc routes._write_stop_sentinel(link) if routes._CAN_PIN_DIR: - assert not (victim / routes._STOP_FILE).exists(), ( - "the sentinel write followed a replaced directory and halted another spec" - ) + assert not ( + victim / routes._STOP_FILE + ).exists(), "the sentinel write followed a replaced directory and halted another spec" else: # pragma: no cover - Windows fallback assert True @@ -4510,10 +5404,13 @@ def test_redirect_state_covers_every_path_the_app_writes(): app learns to write fails here instead of in someone's home directory. """ written = { - n for n, v in vars(routes).items() + n + for n, v in vars(routes).items() if n.endswith("_PATH") and isinstance(v, Path) and n != "SPEC_STATE_PATH" } - redirected = set(re.findall(r'setattr\(routes, "(\w+_PATH)"', inspect.getsource(_redirect_state))) + redirected = set( + re.findall(r'setattr\(routes, "(\w+_PATH)"', inspect.getsource(_redirect_state)) + ) assert written <= redirected, f"not redirected in tests: {sorted(written - redirected)}" @@ -4556,23 +5453,29 @@ def test_slot_key_is_the_deciding_identity(tmp_path, monkeypatch): old_key, new_key = "spec-builder-s-aaaa1111", "spec-builder-s-bbbb2222" # Same directory, different creation -> refused on the key alone. - assert routes._client_identity_mismatch( - routes._ClientClaim(same_dir, old_key), same_dir, new_key - ) is True + assert ( + routes._client_identity_mismatch(routes._ClientClaim(same_dir, old_key), same_dir, new_key) + is True + ) # Same creation -> allowed. - assert routes._client_identity_mismatch( - routes._ClientClaim(same_dir, new_key), same_dir, new_key - ) is False + assert ( + routes._client_identity_mismatch(routes._ClientClaim(same_dir, new_key), same_dir, new_key) + is False + ) # A directory mismatch still refuses on its own. - assert routes._client_identity_mismatch( - routes._ClientClaim("/p/other", new_key), same_dir, new_key - ) is True + assert ( + routes._client_identity_mismatch( + routes._ClientClaim("/p/other", new_key), same_dir, new_key + ) + is True + ) # Unpinned stays unpinned: an older tab sends neither field. assert routes._client_identity_mismatch(routes._ClientClaim("", ""), same_dir, new_key) is False # A server-side entry with no key yet cannot refuse on the key. - assert routes._client_identity_mismatch( - routes._ClientClaim(same_dir, old_key), same_dir, "" - ) is False + assert ( + routes._client_identity_mismatch(routes._ClientClaim(same_dir, old_key), same_dir, "") + is False + ) @pytest.mark.asyncio @@ -4631,17 +5534,34 @@ async def _authz(**_kw): monkeypatch.setattr(routes, "authorize_and_add_nudge", _authz) for label, index, slot_running in ( - ("indexed status", {"busy": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), - "status": "executing"}}, False), - ("live slot", {"busy": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}, True), + ( + "indexed status", + { + "busy": { + "spec_dir": str(spec_dir), + "working_dir": str(tmp_path / "wd"), + "status": "executing", + } + }, + False, + ), + ( + "live slot", + {"busy": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}, + True, + ), ): # The client redirects state, so the index must be written AFTER it exists # (the autouse fixture points elsewhere until then). client = _make_client(monkeypatch, tmp_path) routes._save_index(index) slot = types.SimpleNamespace( - key=routes._slot_key("busy"), _app=routes.APP_NAME, running=slot_running, - project="", messages=[], _titled=True, + key=routes._slot_key("busy"), + _app=routes.APP_NAME, + running=slot_running, + project="", + messages=[], + _titled=True, ) class _State: @@ -4684,14 +5604,14 @@ async def test_touch_spec_pins_the_creation_not_just_the_directory(tmp_path, mon request passed the check and stamped (or dropped) the replacement.""" _redirect_state(monkeypatch, tmp_path) same_dir = str(tmp_path / "p" / ".kiro" / "specs" / "s") - routes._save_index( - {"s": {"spec_dir": same_dir, "slot_key": "spec-builder-s-bbbb2222"}} - ) + routes._save_index({"s": {"spec_dir": same_dir, "slot_key": "spec-builder-s-bbbb2222"}}) # Stale claim: right directory, previous creation -> refused. assert ( await routes._touch_spec( - "s", expect_spec_dir=same_dir, expect_slot_key="spec-builder-s-aaaa1111", + "s", + expect_spec_dir=same_dir, + expect_slot_key="spec-builder-s-aaaa1111", status="executing", ) is None @@ -4700,7 +5620,9 @@ async def test_touch_spec_pins_the_creation_not_just_the_directory(tmp_path, mon # Current creation -> accepted. fresh = await routes._touch_spec( - "s", expect_spec_dir=same_dir, expect_slot_key="spec-builder-s-bbbb2222", + "s", + expect_spec_dir=same_dir, + expect_slot_key="spec-builder-s-bbbb2222", status="executing", ) assert fresh is not None and fresh["status"] == "executing" @@ -4709,7 +5631,9 @@ async def test_touch_spec_pins_the_creation_not_just_the_directory(tmp_path, mon routes._save_index({"old": {"spec_dir": same_dir}}) assert ( await routes._touch_spec( - "old", expect_spec_dir=same_dir, expect_slot_key="spec-builder-old-1", + "old", + expect_spec_dir=same_dir, + expect_slot_key="spec-builder-old-1", status="planning", ) is not None @@ -4741,8 +5665,13 @@ async def test_authorization_failure_reverts_the_recorded_execution_state(tmp_pa spec_dir.mkdir(parents=True) (spec_dir / "tasks.md").write_text("- [ ] task") routes._save_index( - {"nope": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), - "slot_key": "spec-builder-nope-1234abcd"}} + { + "nope": { + "spec_dir": str(spec_dir), + "working_dir": str(tmp_path / "wd"), + "slot_key": "spec-builder-nope-1234abcd", + } + } ) dispatched: list[str] = [] monkeypatch.setattr(routes, "_dispatch_turn", lambda *a, **k: dispatched.append("x")) @@ -4762,8 +5691,13 @@ async def _refuse(**_kw): monkeypatch.setattr(routes, "authorize_and_add_nudge", _refuse) slot = types.SimpleNamespace( - key=routes._slot_key("nope"), _app=routes.APP_NAME, running=False, - project="", messages=[], _titled=True, task=None, + key=routes._slot_key("nope"), + _app=routes.APP_NAME, + running=False, + project="", + messages=[], + _titled=True, + task=None, ) slots: dict = {} @@ -4801,9 +5735,7 @@ async def test_deletion_during_authorization_removes_the_armed_loop(tmp_path, mo spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "gone" spec_dir.mkdir(parents=True) (spec_dir / "tasks.md").write_text("- [ ] task") - routes._save_index( - {"gone": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"gone": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) removed: list[str] = [] dispatched: list[str] = [] monkeypatch.setattr(routes, "_dispatch_turn", lambda *a, **k: dispatched.append("x")) @@ -4834,8 +5766,13 @@ async def _authz(**_kw): monkeypatch.setattr(routes, "authorize_and_add_nudge", _authz) slot = types.SimpleNamespace( - key=routes._slot_key("gone"), _app=routes.APP_NAME, running=False, - project="", messages=[], _titled=True, task=None, + key=routes._slot_key("gone"), + _app=routes.APP_NAME, + running=False, + project="", + messages=[], + _titled=True, + task=None, ) slots: dict = {} @@ -4908,9 +5845,9 @@ def test_handoff_stamps_and_clears_the_arming_marker(): loop exists, so the exemption lasts for the arming window and no longer.""" # The stamp is part of the atomic claim; the clear is in the handler, after the # loop exists. - assert 'meta["exec_arming_at"] = now' in inspect.getsource(routes._claim_execution), ( - "the claim no longer marks the pre-arm window" - ) + assert 'meta["exec_arming_at"] = now' in inspect.getsource( + routes._claim_execution + ), "the claim no longer marks the pre-arm window" src = inspect.getsource(routes._handle_handoff) claim = src.index("await _claim_execution(") arm = src.index("await authorize_and_add_nudge(") @@ -4964,7 +5901,20 @@ async def test_claim_refuses_a_different_creation(tmp_path, monkeypatch): assert reason == routes._CLAIM_GONE assert routes._load_index()["s"].get("status") != "executing" + # Delete publishes its reservation before tearing down the slot. A handoff + # queued behind that boundary must not claim the hidden entry while teardown + # is in flight. + deleting = routes._load_index() + deleting["s"][routes._DELETING] = {"owner": routes._PROCESS_ID, "at": time.time()} + routes._save_index(deleting) + reason, _entry = await routes._claim_execution( + "s", expect_spec_dir=spec_dir, expect_slot_key="spec-builder-s-new", live_running=False + ) + assert reason == routes._CLAIM_GONE + # A live turn on the slot counts as taken even when the index says planning. + deleting["s"].pop(routes._DELETING) + routes._save_index(deleting) reason, _entry = await routes._claim_execution( "s", expect_spec_dir=spec_dir, expect_slot_key="spec-builder-s-new", live_running=True ) @@ -4981,9 +5931,7 @@ async def test_delete_tombstones_before_dropping_the_entry(tmp_path, monkeypatch spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "bye" spec_dir.mkdir(parents=True) (spec_dir / "requirements.md").write_text("# r") - routes._save_index( - {"bye": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"bye": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) order: list[str] = [] real_remember = routes._remember_deleted @@ -5023,9 +5971,7 @@ async def test_failed_delete_clears_the_tombstone(tmp_path, monkeypatch): spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "stay" spec_dir.mkdir(parents=True) (spec_dir / "requirements.md").write_text("# r") - routes._save_index( - {"stay": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"stay": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) async def _teardown_fails(*_a, **_kw): return False @@ -5042,9 +5988,9 @@ async def _teardown_fails(*_a, **_kw): assert status == 503 assert "stay" in routes._load_index(), "the spec was not restored" - assert str(spec_dir) not in routes._load_deleted(), ( - "a spec that still exists is tombstoned; discovery will hide its documents" - ) + assert ( + str(spec_dir) not in routes._load_deleted() + ), "a spec that still exists is tombstoned; discovery will hide its documents" def test_delete_orders_the_tombstone_before_the_pop(): @@ -5054,9 +6000,7 @@ def test_delete_orders_the_tombstone_before_the_pop(): remember = src.index("_remember_deleted") pop = src.index("_mutate_index(_pop_if_same)") assert remember < pop, "the entry is dropped before the directory is tombstoned" - assert src.count("_forget_deleted") >= 2, ( - "a non-deleting arm leaves the tombstone behind" - ) + assert src.count("_forget_deleted") >= 2, "a non-deleting arm leaves the tombstone behind" # ── GPT round-50 findings (#518) ─────────────────────────────────────────────── @@ -5088,8 +6032,7 @@ def _slow_load(): monkeypatch.setattr(routes, "_load_deleted", _slow_load) threads = [ - threading.Thread(target=routes._remember_deleted, args=(f"/p/spec-{i}",)) - for i in range(2) + threading.Thread(target=routes._remember_deleted, args=(f"/p/spec-{i}",)) for i in range(2) ] for t in threads: t.start() @@ -5103,9 +6046,9 @@ def _slow_load(): monkeypatch.setattr(routes, "_load_deleted", real_load) recorded = routes._load_deleted() - assert "/p/spec-0" in recorded and "/p/spec-1" in recorded, ( - f"a concurrent delete lost its tombstone: {recorded}" - ) + assert ( + "/p/spec-0" in recorded and "/p/spec-1" in recorded + ), f"a concurrent delete lost its tombstone: {recorded}" assert "/p/keep-me" in recorded, "an unrelated tombstone was dropped" @@ -5178,8 +6121,13 @@ async def test_create_abort_does_not_drop_a_replacement_spec(tmp_path, monkeypat } slot = types.SimpleNamespace( - key="spec-builder-reused-99999999", _app=routes.APP_NAME, running=False, - project="", messages=[], _titled=True, task=None, + key="spec-builder-reused-99999999", + _app=routes.APP_NAME, + running=False, + project="", + messages=[], + _titled=True, + task=None, ) class _State: @@ -5204,8 +6152,12 @@ async def _ensure_then_replace(state, name, meta, **kw): client.app["state"] = _State() resp = await client.post( f"{_BASE}/specs", - json={"name": "reused", "working_dir": str(working), "spec_type": "feature", - "import_existing": True}, + json={ + "name": "reused", + "working_dir": str(working), + "spec_type": "feature", + "import_existing": True, + }, ) status, body = resp.status, await resp.json() finally: @@ -5225,12 +6177,12 @@ def test_create_identity_checks_pin_the_creation(): directory.""" src = inspect.getsource(routes._handle_create) pop = src.index("def _pop_if_ours(") - pop_body = src[pop:src.index("del idx[name]", pop)] + pop_body = src[pop : src.index("del idx[name]", pop)] assert "slot_key" in pop_body, "the unwind pops on the directory alone" post = src.index("live = current.get(name) or {}") - assert "slot_key" in src[post:src.index("_unwind_create()", post)], ( - "the post-slot-setup check compares the directory alone" - ) + assert ( + "slot_key" in src[post : src.index("_unwind_create()", post)] + ), "the post-slot-setup check compares the directory alone" # ── GPT round-52 findings (#518) ─────────────────────────────────────────────── @@ -5276,9 +6228,7 @@ async def test_index_derived_strings_are_redacted_on_egress(tmp_path, monkeypatc assert entry[field] == "[SCRUBBED]", f"list leaked {field}: {entry[field]}" assert detail[field] == "[SCRUBBED]", f"detail leaked {field}: {detail[field]}" # spec_type still goes through _redact -- proven by a path-shaped value in it. - routes._save_index( - {"leaky": {**routes._load_index()["leaky"], "spec_type": "feature/../etc"}} - ) + routes._save_index({"leaky": {**routes._load_index()["leaky"], "spec_type": "feature/../etc"}}) assert detail["context"]["worktree_branch"] == "[SCRUBBED]", "detail leaked the branch" @@ -5359,7 +6309,9 @@ def test_no_sentinel_path_is_resolved_by_string(): if stripped.startswith("#") or "_STOP_FILE" not in stripped: continue if "os.open(" in stripped or "os.unlink(" in stripped or "os.replace(" in stripped: - assert "dir_fd" in stripped, f"{fn.__name__} touches the sentinel by path: {stripped}" + assert ( + "dir_fd" in stripped + ), f"{fn.__name__} touches the sentinel by path: {stripped}" # And no path arithmetic builds a sentinel target any more. assert "real_dir / _STOP_FILE" not in src, f"{fn.__name__} still joins the path" @@ -5425,9 +6377,9 @@ def log_tool_invocation(self, **kw): assert rc == routes._GIT_UNAVAILABLE, rc assert "audit" in err assert spawned == [], "git ran despite the audit write failing" - assert calls and calls[0]["critical"] is True, ( - f"the invocation audit did not ask for a durable write: {calls}" - ) + assert ( + calls and calls[0]["critical"] is True + ), f"the invocation audit did not ask for a durable write: {calls}" def test_only_the_invocation_audit_is_critical(): @@ -5544,8 +6496,13 @@ async def test_failed_archive_restores_the_original_name_and_key(tmp_path, monke spec_dir.mkdir(parents=True) key = "spec-builder-keeper-abcd1234" routes._save_index( - {"keeper": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd"), - "slot_key": key}} + { + "keeper": { + "spec_dir": str(spec_dir), + "working_dir": str(tmp_path / "wd"), + "slot_key": key, + } + } ) async def _archive_fails(*_a, **_kw): @@ -5584,8 +6541,13 @@ async def test_a_reserved_name_cannot_be_taken_mid_delete(tmp_path, monkeypatch) spec_dir = working / ".kiro" / "specs" / "busy" spec_dir.mkdir(parents=True) routes._save_index( - {"busy": {"spec_dir": str(spec_dir), "working_dir": str(working), - "slot_key": "spec-builder-busy-11112222"}} + { + "busy": { + "spec_dir": str(spec_dir), + "working_dir": str(working), + "slot_key": "spec-builder-busy-11112222", + } + } ) # Mark the entry as a delete in flight, then try to create the same name. assert await routes._mark_deleting( @@ -5616,9 +6578,7 @@ async def test_removal_failure_keeps_the_spec_hidden_for_a_retry(tmp_path, monke client = _make_client(monkeypatch, tmp_path) spec_dir = tmp_path / "wd" / ".kiro" / "specs" / "stuck" spec_dir.mkdir(parents=True) - routes._save_index( - {"stuck": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}} - ) + routes._save_index({"stuck": {"spec_dir": str(spec_dir), "working_dir": str(tmp_path / "wd")}}) async def _ok_teardown(*_a, **_kw): return True @@ -5722,8 +6682,13 @@ async def test_list_serves_only_allowlisted_statuses(tmp_path, monkeypatch): base = tmp_path / "wd" / ".kiro" / "specs" (base / "odd").mkdir(parents=True) routes._save_index( - {"odd": {"spec_dir": str(base / "odd"), "working_dir": str(tmp_path / "wd"), - "status": "AKIAIOSFODNN7EXAMPLE"}} + { + "odd": { + "spec_dir": str(base / "odd"), + "working_dir": str(tmp_path / "wd"), + "status": "AKIAIOSFODNN7EXAMPLE", + } + } ) await client.start_server() @@ -5845,9 +6810,7 @@ async def _fake_spawn(*argv, **kwargs): # timeout rather than on the behaviour it exists to check. The assertion is # only about the process being killed, so sandbox preparation is setup here, # not subject: stubbing it makes the window depend on scheduling alone. - monkeypatch.setattr( - routes, "_prepare_git_spawn", lambda argv: (list(argv), {}, None) - ) + monkeypatch.setattr(routes, "_prepare_git_spawn", lambda argv: (list(argv), {}, None)) await client.start_server() try: @@ -5904,9 +6867,7 @@ def test_git_kills_the_process_on_every_exceptional_exit(): ) reraise_arms = [ handler - for handler in ( - h for t in ast.walk(target) if isinstance(t, ast.Try) for h in t.handlers - ) + for handler in (h for t in ast.walk(target) if isinstance(t, ast.Try) for h in t.handlers) if any(isinstance(n, ast.Raise) and n.exc is None for n in ast.walk(handler)) ] assert reraise_arms, "_git no longer re-raises; this guard needs rewriting" @@ -5969,9 +6930,9 @@ async def test_index_entry_without_a_working_dir_is_refused_a_slot(tmp_path, mon assert routes._entry_is_usable(meta) state, slot = _slot_stub() - assert await routes._ensure_worker_slot(state, "s", meta) is None, ( - "a projectless entry was handed a slot" - ) + assert ( + await routes._ensure_worker_slot(state, "s", meta) is None + ), "a projectless entry was handed a slot" assert slot.project == "", "the slot was scoped from a missing working_dir" @@ -5995,9 +6956,9 @@ async def test_working_dir_absent_and_invalid_refuse_identically(tmp_path, monke ("not-a-dir", {**common, "working_dir": str(base / "s" / "requirements.md")}), ): state, _ = _slot_stub() - assert await routes._ensure_worker_slot(state, "s", meta) is None, ( - f"{label} working_dir was allowed to produce a slot" - ) + assert ( + await routes._ensure_worker_slot(state, "s", meta) is None + ), f"{label} working_dir was allowed to produce a slot" # ...and a real one still works, so the guard is not vacuous. state, slot = _slot_stub() @@ -6031,15 +6992,17 @@ async def test_touch_spec_refuses_an_entry_reserved_for_deletion(tmp_path, monke base = tmp_path / "wd" / ".kiro" / "specs" (base / "doomed").mkdir(parents=True) sd = str(base / "doomed") - routes._save_index({ - "doomed": { - "spec_dir": sd, - "working_dir": str(tmp_path / "wd"), - "spec_type": "feature", - "status": "planning", - "slot_key": routes._new_slot_key("doomed"), + routes._save_index( + { + "doomed": { + "spec_dir": sd, + "working_dir": str(tmp_path / "wd"), + "spec_type": "feature", + "status": "planning", + "slot_key": routes._new_slot_key("doomed"), + } } - }) + ) # Live: the stamp lands. assert await routes._touch_spec("doomed", expect_spec_dir=sd, status="planning") is not None @@ -6062,15 +7025,17 @@ async def test_message_during_delete_is_refused_not_dispatched(tmp_path, monkeyp base = tmp_path / "wd" / ".kiro" / "specs" (base / "doomed").mkdir(parents=True) sd = str(base / "doomed") - routes._save_index({ - "doomed": { - "spec_dir": sd, - "working_dir": str(tmp_path / "wd"), - "spec_type": "feature", - "status": "planning", - "slot_key": routes._new_slot_key("doomed"), + routes._save_index( + { + "doomed": { + "spec_dir": sd, + "working_dir": str(tmp_path / "wd"), + "spec_type": "feature", + "status": "planning", + "slot_key": routes._new_slot_key("doomed"), + } } - }) + ) dispatched: list = [] monkeypatch.setattr(routes, "_dispatch_turn", lambda *a, **k: dispatched.append(a)) assert await routes._mark_deleting("doomed", expect_spec_dir=sd, expect_slot_key="") @@ -6127,15 +7092,17 @@ async def test_failed_loop_removal_releases_both_tombstone_and_reservation(tmp_p base = tmp_path / "wd" / ".kiro" / "specs" (base / "keeper").mkdir(parents=True) sd = str(base / "keeper") - routes._save_index({ - "keeper": { - "spec_dir": sd, - "working_dir": str(tmp_path / "wd"), - "spec_type": "feature", - "status": "planning", - "slot_key": routes._new_slot_key("keeper"), + routes._save_index( + { + "keeper": { + "spec_dir": sd, + "working_dir": str(tmp_path / "wd"), + "spec_type": "feature", + "status": "planning", + "slot_key": routes._new_slot_key("keeper"), + } } - }) + ) async def _explode(*_a, **_k): raise RuntimeError("loop service down") @@ -6151,7 +7118,9 @@ async def _explode(*_a, **_k): assert resp.status == 503 idx = await routes._aload_index() assert "keeper" in idx, "the spec was dropped despite the abort" - assert not idx["keeper"].get(routes._DELETING), "reservation left behind — spec hidden from the list" + assert not idx["keeper"].get( + routes._DELETING + ), "reservation left behind — spec hidden from the list" # The tombstone must be gone too, or discovery would refuse to re-adopt it. assert await routes._touch_spec("keeper", expect_spec_dir=sd) is not None @@ -6174,15 +7143,17 @@ async def test_predispatch_repin_uses_captured_key_when_client_sends_none(tmp_pa (base / "s").mkdir(parents=True) sd = str(base / "s") original_key = routes._new_slot_key("s") - routes._save_index({ - "s": { - "spec_dir": sd, - "working_dir": str(tmp_path / "wd"), - "spec_type": "feature", - "status": "planning", - "slot_key": original_key, + routes._save_index( + { + "s": { + "spec_dir": sd, + "working_dir": str(tmp_path / "wd"), + "spec_type": "feature", + "status": "planning", + "slot_key": original_key, + } } - }) + ) dispatched: list = [] monkeypatch.setattr(routes, "_dispatch_turn", lambda *a, **k: dispatched.append(a)) @@ -6456,19 +7427,19 @@ def test_unwind_gates_the_rollback_on_the_pinned_pop(): """Source guard on the WIRING: the unwind must pass the pop's own result through, not re-derive ownership or hardcode it.""" src = inspect.getsource(routes._handle_create) - unwind = src[src.index("async def _unwind_create"):] + unwind = src[src.index("async def _unwind_create") :] pop = unwind.index("was_ours = await _mutate_index(_pop_if_ours)") call = unwind.index("_rollback_worktree_if_ours(") assert pop < call, "ownership must be established before the rollback" - args = unwind[call:unwind.index(")", unwind.index("worktree_branch=worktree_branch", call))] + args = unwind[call : unwind.index(")", unwind.index("worktree_branch=worktree_branch", call))] assert "was_ours=was_ours" in args, ( "the rollback is not gated on the pinned pop -- a stale unwind would " "force-delete a replacement spec's worktree and branch" ) # The raw destructive call must NOT survive alongside the gated one. - assert "_remove_worktree(" not in unwind, ( - "the unwind still calls _remove_worktree directly, bypassing the gate" - ) + assert ( + "_remove_worktree(" not in unwind + ), "the unwind still calls _remove_worktree directly, bypassing the gate" def test_remove_worktree_is_destructive_enough_to_need_the_gate(): @@ -6489,7 +7460,7 @@ def test_only_the_post_insert_unwind_needs_the_gate(): acquisition) during which a delete + recreate can land. """ src = inspect.getsource(routes._handle_create) - early = src[:src.index("async def _unwind_create")] + early = src[: src.index("async def _unwind_create")] assert early.count("_remove_worktree(") == 3, ( "the early-rollback count changed -- re-audit whether the new one spans " "an await after the index insert (if so it needs the ownership gate too)" @@ -6537,9 +7508,7 @@ async def _restore(state, name, adopt_closed=False): monkeypatch.setattr(routes, "_restore_worker_transcript", _restore) state, created = _identity_state("spec-builder-old") - got = await routes._ensure_worker_slot( - state, _IDENTITY_SPEC, {"working_dir": str(tmp_path)} - ) + got = await routes._ensure_worker_slot(state, _IDENTITY_SPEC, {"working_dir": str(tmp_path)}) assert got is None, "a stale request acquired the replacement spec's slot" assert created == [], "the stale request created a slot under the new identity" @@ -6563,9 +7532,7 @@ def _safe_dir_then_replace(path): monkeypatch.setattr(routes, "_safe_dir", _safe_dir_then_replace) - got = await routes._ensure_worker_slot( - state, _IDENTITY_SPEC, {"working_dir": str(tmp_path)} - ) + got = await routes._ensure_worker_slot(state, _IDENTITY_SPEC, {"working_dir": str(tmp_path)}) assert got is None, "the stale request kept going after its spec was replaced" assert ours.project == "", "a replaced spec's slot was repointed at the stale project" assert ours._app == routes.APP_NAME @@ -6581,9 +7548,7 @@ async def test_stable_identity_still_acquires_the_slot(monkeypatch, tmp_path): ) state, _ = _identity_state("spec-builder-stable", slot=ours) - got = await routes._ensure_worker_slot( - state, _IDENTITY_SPEC, {"working_dir": str(tmp_path)} - ) + got = await routes._ensure_worker_slot(state, _IDENTITY_SPEC, {"working_dir": str(tmp_path)}) assert got is ours, "a stable identity failed to acquire its own slot" assert got.project == str(tmp_path), "the slot was not scoped to the spec's project" @@ -6605,14 +7570,16 @@ def test_slot_key_is_resolved_once_and_both_awaits_are_guarded(): src.index("await asyncio.to_thread(_safe_dir"), ] assert resolve < min(awaits), "the identity is captured after an await" - guards = [i for i in range(len(src)) if src.startswith("_slot_identity_moved(name, slot_key)", i)] + guards = [ + i for i in range(len(src)) if src.startswith("_slot_identity_moved(name, slot_key)", i) + ] assert len(guards) == 2, f"expected 2 identity re-checks, found {len(guards)}" for a in awaits: assert any(g > a for g in guards), "an await is not followed by an identity re-check" # Creation must use the captured key. - assert "get_or_create_slot(name=slot_key" in src, ( - "slot creation does not use the captured identity" - ) + assert ( + "get_or_create_slot(name=slot_key" in src + ), "slot creation does not use the captured identity" def test_identity_guard_refuses_and_audits(): @@ -6702,7 +7669,7 @@ def test_handoff_captures_its_identity_before_the_await_and_pins_on_both(): claim previously had no identity check at all. """ src = inspect.getsource(routes._handle_handoff) - capture = src.index("started_slot_key = str(meta.get(\"slot_key\", \"\"))") + capture = src.index('started_slot_key = str(meta.get("slot_key", ""))') await_match = re.search(r"await asyncio\.to_thread\(\s*_prepare_handoff", src) assert await_match, "handoff no longer hands _prepare_handoff to a worker thread" await_at = await_match.start() @@ -6712,18 +7679,17 @@ def test_handoff_captures_its_identity_before_the_await_and_pins_on_both(): # sentinel, so a stale execute that reaches it disarms a replacement's Pause # before any comparison has run. claim_at = src.index("_client_identity_mismatch(claimed, spec_dir, started_slot_key)") - assert claim_at < await_at, ( - "the client-claim check moved after the sentinel clear it is meant to gate" - ) + assert ( + claim_at < await_at + ), "the client-claim check moved after the sentinel clear it is meant to gate" assert await_at < guard, "the reread guard does not follow the await" window = src[await_at:guard] assert "!= started_slot_key" in window, ( - "the reread is pinned on spec_dir only -- a same-name, same-path " - "replacement passes it" - ) - assert 'str(meta.get("spec_dir", "")) != str(spec_dir)' in window, ( - "the directory pin was dropped" + "the reread is pinned on spec_dir only -- a same-name, same-path " "replacement passes it" ) + assert ( + 'str(meta.get("spec_dir", "")) != str(spec_dir)' in window + ), "the directory pin was dropped" def test_no_index_mutation_is_pinned_on_the_directory_alone(): @@ -6741,13 +7707,14 @@ def test_no_index_mutation_is_pinned_on_the_directory_alone(): if depth == 0: break i += 1 - call = src[match.start():i + 1] + call = src[match.start() : i + 1] if "expect_spec_dir" in call: assert "expect_slot_key" in call, ( "a _touch_spec call pins the directory without the creation key, " f"so a same-path replacement passes it:\n{call}" ) + # ── index admission: the write side must use the read side's predicate ── @@ -6787,9 +7754,9 @@ async def test_create_refuses_a_name_the_loader_would_discard(tmp_path, monkeypa assert resp.status == 400 body = await resp.json() assert body["code"] == "invalid_name" - assert not (tmp_path / ".kiro" / "specs" / _CREDENTIAL_SHAPED_NAME).exists(), ( - "create left a spec directory for a name the index cannot hold" - ) + assert not ( + tmp_path / ".kiro" / "specs" / _CREDENTIAL_SHAPED_NAME + ).exists(), "create left a spec directory for a name the index cannot hold" def test_discovery_does_not_adopt_a_name_the_loader_would_discard(tmp_path, monkeypatch): @@ -6850,6 +7817,7 @@ def test_no_index_write_path_admits_on_the_grammar_alone(): f"_load_index discards: {offenders}" ) + # ── _safe_dir: absolute-only, enforced where it can actually fail ── @@ -6862,9 +7830,9 @@ def test_safe_dir_refuses_a_relative_working_dir(tmp_path, monkeypatch): """ monkeypatch.chdir(tmp_path) for relative in (".", "..", "relative/path", "./sub", ""): - assert routes._safe_dir(relative) is None, ( - f"_safe_dir accepted the relative value {relative!r}" - ) + assert ( + routes._safe_dir(relative) is None + ), f"_safe_dir accepted the relative value {relative!r}" def test_safe_dir_still_accepts_absolute_and_tilde(tmp_path, monkeypatch): @@ -6875,9 +7843,9 @@ def test_safe_dir_still_accepts_absolute_and_tilde(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "inside").mkdir() - assert routes._safe_dir("~/inside") is not None, ( - "_safe_dir rejected a ~-relative path, which expands to an absolute one" - ) + assert ( + routes._safe_dir("~/inside") is not None + ), "_safe_dir rejected a ~-relative path, which expands to an absolute one" def test_absoluteness_is_checked_before_realpath(): @@ -6893,9 +7861,10 @@ def test_absoluteness_is_checked_before_realpath(): src = inspect.getsource(routes._safe_dir) isabs_at = src.index("os.path.isabs(") realpath_at = src.index("os.path.realpath(") - assert isabs_at < realpath_at, ( - "the absoluteness test moved after realpath, where it can never fail" - ) + assert ( + isabs_at < realpath_at + ), "the absoluteness test moved after realpath, where it can never fail" + # ── a stale execute must not clear a replacement's STOP sentinel ── @@ -6957,6 +7926,7 @@ def test_prepare_handoff_unpinned_call_keeps_working(tmp_path, monkeypatch): assert routes._prepare_handoff(spec_dir, "paused", "")[0] is True assert not stop.exists() + # ── broadcast-eligible appends must not carry raw caller text ── @@ -7006,12 +7976,10 @@ def test_the_host_still_exempts_only_these_roles_from_broadcast(): src = inspect.getsource(_ChatSlot.append) assert 'role not in ("chunk", "done")' in src, ( - "the host's unconditional broadcast skip set changed; " - "_NON_BROADCAST_ROLES is now stale" + "the host's unconditional broadcast skip set changed; " "_NON_BROADCAST_ROLES is now stale" ) assert '(role != "user" or broadcast_user)' in src, ( - "the host no longer skips user rows by default; _NON_BROADCAST_ROLES is " - "now stale" + "the host no longer skips user rows by default; _NON_BROADCAST_ROLES is " "now stale" ) assert "broadcast_user" not in inspect.getsource(routes._dispatch_turn), ( "this app now opts into broadcasting user rows, so 'user' is broadcast " @@ -7031,14 +7999,14 @@ def test_queued_append_is_redacted_before_it_is_broadcast(monkeypatch): assert slot.appended, "nothing was appended for a running slot" role, content = slot.appended[-1] assert role == "queued" - assert secret not in content, ( - "the queued message reached the broadcast path with the credential intact" - ) + assert ( + secret not in content + ), "the queued message reached the broadcast path with the credential intact" # The queue itself still carries the real text: the agent must receive what # the user actually typed. Only the broadcast copy is scrubbed. - assert slot.queued and secret in slot.queued[-1], ( - "the redaction leaked into the queue, so the agent would get scrubbed input" - ) + assert ( + slot.queued and secret in slot.queued[-1] + ), "the redaction leaked into the queue, so the agent would get scrubbed input" def test_no_broadcast_eligible_append_passes_raw_caller_text(): @@ -7080,6 +8048,7 @@ def test_no_broadcast_eligible_append_passes_raw_caller_text(): f"that did not go through _redact: {offenders}" ) + # ── the settings egress redacts like every other stored value ── @@ -7096,9 +8065,9 @@ async def test_get_settings_redacts_an_agent_written_base_path(tmp_path, monkeyp assert resp.status == 200 body = await resp.json() - assert secret not in body["base_path"], ( - "the agent-written base_path reached the dashboard with the credential intact" - ) + assert ( + secret not in body["base_path"] + ), "the agent-written base_path reached the dashboard with the credential intact" @pytest.mark.asyncio @@ -7143,3 +8112,1683 @@ def test_every_handler_that_returns_settings_redacts_it(): "these handlers return agent-writable settings without _redact, so a " f"credential in the file reaches the dashboard raw: {offenders}" ) + + +# ── direct authority over the artifacts ────────────────────────────────────── +# Before these endpoints the user could only ASK the agent to change anything: +# a typo cost a model turn, an approval was a chat message that left no trace, +# and the task list could only be handed over whole. Each test below pins the +# guard that makes the new write path safe rather than merely present. + + +def _seed_spec(tmp_path, name="live", *, files=None, extra=None): + """Seed one spec on disk and in the index. Returns (spec_dir, working_dir).""" + working_dir = tmp_path / "wd" + spec_dir = working_dir / ".kiro" / "specs" / name + spec_dir.mkdir(parents=True) + for fname, text in (files or {}).items(): + (spec_dir / fname).write_text(text) + entry = { + "spec_dir": str(spec_dir), + "working_dir": str(working_dir), + "spec_type": "feature", + "status": "planning", + "slot_key": routes._slot_key(name), + "created_at": 1.0, + "updated_at": 1.0, + } + entry.update(extra or {}) + routes._save_index({name: entry}) + return spec_dir, working_dir + + +def _spec_identity(name="live"): + """Return the creation identity a detail response gives the client.""" + entry = routes._load_index()[name] + return {"spec_dir": entry["spec_dir"], "slot_key": entry["slot_key"]} + + +class _IdleSlot: + """A slot of OUR app that is not mid-turn.""" + + running = False + messages: list = [] + _app = routes.APP_NAME + project = "" + _titled = False + title = "" + + def __init__(self, key): + self.key = key + self.dispatched: list[str] = [] + + +def _state_for(*names): + """A fake gateway state serving an idle slot per spec name.""" + slots = {routes._slot_key(n): _IdleSlot(routes._slot_key(n)) for n in names} + + class _State: + def get_slot(self, key): + return slots.get(key) + + def get_or_create_slot(self, name, app=""): + key = routes._slot_key(name) + slots.setdefault(key, _IdleSlot(key)) + return slots[key] + + return _State(), slots + + +# ── gap 1: documents are reviewable through their redacted rendering ───────── + + +def test_read_spec_files_returns_only_the_raw_hash_for_a_redacted_document(tmp_path, monkeypatch): + """The browser gets safe rendering plus the hash needed for approval.""" + monkeypatch.setattr(routes, "_redact", lambda t: t.replace("sk-secret", "[redacted]")) + spec_dir = tmp_path / "spec" + spec_dir.mkdir() + (spec_dir / "requirements.md").write_text("token is sk-secret") + (spec_dir / "design.md").write_text("nothing sensitive") + + files, docs, tasks = routes._read_spec_files(spec_dir) + + assert files["requirements.md"] == "token is [redacted]" + # The hash is of the file AS STORED, never of the redacted copy: an approval + # binds to the real version that was reviewed. + assert docs["requirements.md"] == {"hash": routes._sha256_text("token is sk-secret")} + assert set(docs["design.md"]) == {"hash"} + assert tasks == [] + + +def test_duplicate_doc_create_only_succeeds_while_the_file_is_absent(tmp_path): + """O_EXCL makes an external writer the winner rather than its victim.""" + spec_dir = tmp_path / "spec" + spec_dir.mkdir() + + result, identity = routes._create_spec_doc(spec_dir, "design.md", "# fresh") + assert result == "" and identity is not None + assert (spec_dir / "design.md").read_text() == "# fresh" + + result, identity = routes._create_spec_doc(spec_dir, "design.md", "# second") + assert result == "conflict" and identity is None + assert (spec_dir / "design.md").read_text() == "# fresh" + + +@pytest.mark.skipif( + not routes._CAN_PIN_DIR, + reason="duplicate document writes require descriptor-relative directory pinning", +) +def test_duplicate_doc_create_refuses_a_spec_directory_swapped_for_a_symlink(tmp_path): + """Same attack the sentinel writers refuse: an agent replaces its own indexed + directory with a symlink, and a path-based write then lands user-authored text + somewhere else entirely.""" + real = tmp_path / "elsewhere" + real.mkdir() + attacker = tmp_path / "attacker-spec" + os.symlink(real, attacker) + + assert routes._create_spec_doc(attacker, "design.md", "# text")[0] == "unsafe_dir" + assert not (real / "design.md").exists() + + +def test_duplicate_doc_create_retries_short_writes(tmp_path, monkeypatch): + """A successful save means the complete UTF-8 payload reached the temporary + file; a regular-file write is allowed to accept only a prefix.""" + spec_dir = tmp_path / "spec" + spec_dir.mkdir() + real_write = routes.os.write + writes: list[int] = [] + + def _short_write(fd, data): + chunk = data[:2] + writes.append(len(chunk)) + return real_write(fd, chunk) + + monkeypatch.setattr(routes.os, "write", _short_write) + content = "complete payload" + + assert routes._create_spec_doc(spec_dir, "design.md", content)[0] == "" + assert (spec_dir / "design.md").read_text() == content + assert len(writes) > 1 + + +# ── gap 2: approval is recorded against the version approved ───────────────── + + +@pytest.mark.asyncio +async def test_approve_records_the_version_and_the_user(tmp_path, monkeypatch): + """Approval used to be nothing but a chat message: the server never knew a + phase had been signed off, by whom, or against which text.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# reviewed"}) + digest = routes._sha256_text("# reviewed") + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/approve", + json={**_spec_identity(), "phase": "requirements", "hash": digest}, + ) + assert resp.status == 200, await resp.json() + detail = await (await client.get(f"{_BASE}/specs/live")).json() + finally: + await client.close() + + stored = routes._load_index()["live"]["approvals"]["requirements"] + assert stored["hash"] == digest and stored["user"] == "tester" and stored["at"] > 0 + assert detail["approvals"]["requirements"]["stale"] is False + + +@pytest.mark.asyncio +async def test_approve_refuses_a_hash_that_is_not_the_current_document(tmp_path, monkeypatch): + """Approving a version nobody has seen records nothing meaningful, so the + claim is checked against the file rather than trusted.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# actually on disk"}) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/approve", + json={ + **_spec_identity(), + "phase": "requirements", + "hash": routes._sha256_text("# something else"), + }, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "doc_changed" + assert "approvals" not in routes._load_index()["live"] + + +@pytest.mark.asyncio +async def test_approve_refuses_a_same_path_spec_recreated_during_hash_read(tmp_path, monkeypatch): + """A directory path is reusable; the per-creation slot key is the identity.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# reviewed"}) + old_key = routes._load_index()["live"]["slot_key"] + real_read = routes._read_spec_text + replaced = False + + def _replace_identity(spec_dir, fname): + nonlocal replaced + text = real_read(spec_dir, fname) + if fname == "requirements.md" and not replaced: + replaced = True + index = routes._load_index() + index["live"]["slot_key"] = old_key + "-replacement" + routes._save_index(index) + return text + + monkeypatch.setattr(routes, "_read_spec_text", _replace_identity) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/approve", + json={ + **_spec_identity(), + "phase": "requirements", + "hash": routes._sha256_text("# reviewed"), + }, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "stale_client" + assert "approvals" not in routes._load_index()["live"] + + +def test_approvals_report_stale_once_the_document_moves(tmp_path): + """Why the approved VERSION is recorded rather than a bare flag: a document the + agent rewrote after sign-off must stop reading as approved.""" + approved = routes._sha256_text("# as reviewed") + record = {"requirements": {"hash": approved, "at": 5.0, "user": "tester"}} + + unchanged = routes._normalize_approvals(record, {"requirements.md": {"hash": approved}}) + assert unchanged["requirements"]["stale"] is False + + moved = routes._normalize_approvals( + record, {"requirements.md": {"hash": routes._sha256_text("# rewritten since")}} + ) + assert moved["requirements"]["stale"] is True + + # The document disappearing is also stale: nothing is left that it describes. + assert routes._normalize_approvals(record, {})["requirements"]["stale"] is True + + +def test_approvals_from_the_index_are_normalized_not_trusted(): + """The index is reachable by the agent (it runs shell commands as the user), + exactly like every other index field this module scrubs on egress. A forged + or malformed record must not reach the browser as-is.""" + junk = { + "requirements": {"hash": "not-a-digest", "at": "soon", "user": "x"}, + "design": "not even a dict", + "tasks": {"hash": "0" * 64}, # not an approvable phase + "../evil": {"hash": "0" * 64}, + } + out = routes._normalize_approvals(junk, {}) + assert out == {}, out + + +@pytest.mark.asyncio +@pytest.mark.parametrize("phase", ["tasks", "new", "", "../requirements"]) +async def test_approve_rejects_a_phase_outside_the_approvable_two(phase, tmp_path, monkeypatch): + """There is no "approve tasks" step: approving the task list IS the handoff.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# r"}) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/approve", + json={**_spec_identity(), "phase": phase, "hash": "0" * 64}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 400 and body["code"] == "invalid_phase" + + +# ── gap 3: one task at a time, addressed by position AND text ──────────────── + + +def test_parse_tasks_enumerates_hashes_and_derives_progress(): + text = ( + "# Tasks\n\n" + "- [x] wire the endpoint\n" + "- [ ] add the tests\n" + "1. [ ] update the docs\n" + "- [ ]\n" # no text: not something a user can run + "just prose\n" + ) + tasks = routes._parse_tasks(text) + + assert [t["index"] for t in tasks] == [0, 1, 2] + assert [t["done"] for t in tasks] == [True, False, False] + assert [t["text"] for t in tasks] == ["wire the endpoint", "add the tests", "update the docs"] + assert tasks[1]["hash"] == routes._sha256_text("add the tests") + # The gate's predicate reads the same parse, so the two cannot disagree. + assert routes._has_open_task(text) is True + + +@pytest.mark.asyncio +async def test_task_run_dispatches_one_scoped_turn(tmp_path, monkeypatch): + """Deliberately a single turn and NOT an autonudge loop: the whole-list handoff + arms a loop that keeps going, while running one task must end where the user + expects it to.""" + client = _make_client(monkeypatch, tmp_path) + spec_dir, working_dir = _seed_spec( + tmp_path, files={"tasks.md": "- [x] done already\n- [ ] add the tests\n"} + ) + state, slots = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + detail = await (await client.get(f"{_BASE}/specs/live")).json() + target = detail["tasks"][1] + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": target["index"], "hash": target["hash"]}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 200, body + assert detail["task_progress"] == {"done": 1, "total": 2} + assert len(sent) == 1 + # Names the task by text and occurrence, and tells the agent to stop rather than continue. + assert "add the tests" in sent[0] + assert "checklist item 2" in sent[0] + assert "Do NOT continue" in sent[0] + assert str(spec_dir / "tasks.md") in sent[0] and str(working_dir) in sent[0] + + +@pytest.mark.asyncio +async def test_task_run_identifies_the_selected_duplicate_occurrence(tmp_path, monkeypatch): + """Repeated labels still dispatch the exact checkbox occurrence the user clicked.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] run the tests\n- [ ] run the tests\n"}) + state, _ = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + detail = await (await client.get(f"{_BASE}/specs/live")).json() + target = detail["tasks"][1] + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": target["index"], "hash": target["hash"]}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 200, body + assert len(sent) == 1 + assert "checklist item 2" in sent[0] + + +@pytest.mark.asyncio +async def test_task_run_revalidates_the_task_after_slot_setup(tmp_path, monkeypatch): + """An IDE edit during the awaited slot setup must win over the stale click.""" + client = _make_client(monkeypatch, tmp_path) + spec_dir, _ = _seed_spec(tmp_path, files={"tasks.md": "- [ ] original task\n"}) + state, slots = _state_for("live") + sent: list[str] = [] + + async def _setup_then_edit(_state, _name, _meta): + (spec_dir / "tasks.md").write_text("- [ ] replacement task\n") + return slots[routes._slot_key("live")] + + monkeypatch.setattr(routes, "_ensure_worker_slot", _setup_then_edit) + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": 0, "hash": routes._sha256_text("original task")}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "task_changed" + assert sent == [], "dispatched the snapshot captured before slot setup" + + +@pytest.mark.asyncio +async def test_task_run_refuses_a_task_whose_text_moved(tmp_path, monkeypatch): + """The reason position alone is not an identity: the agent rewrites tasks.md + between polls, so a click on "task 2" could otherwise dispatch whatever ended + up second.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] the list was reordered\n"}) + state, _ = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/task", + json={ + **_spec_identity(), + "index": 0, + "hash": routes._sha256_text("what the user actually clicked"), + }, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "task_changed" + assert sent == [], "dispatched a task the user did not choose" + + +@pytest.mark.asyncio +async def test_task_run_uses_the_same_redacted_identity_the_detail_endpoint_serves( + tmp_path, monkeypatch +): + """Reloading produces a runnable raw identity and a redacted task label.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] rotate sk-secret\n"}) + state, _ = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr(routes, "_redact", lambda text: text.replace("sk-secret", "[redacted]")) + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + detail = await (await client.get(f"{_BASE}/specs/live")).json() + task = detail["tasks"][0] + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": task["index"], "hash": task["hash"]}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 200, body + assert len(sent) == 1 and "rotate [redacted]" in sent[0] + + +@pytest.mark.asyncio +async def test_task_run_hashes_raw_text_when_redaction_hides_a_change(tmp_path, monkeypatch): + """A stale click must not survive when only a redacted credential changes.""" + client = _make_client(monkeypatch, tmp_path) + spec_dir, _ = _seed_spec(tmp_path, files={"tasks.md": "- [ ] rotate secret-old\n"}) + state, _ = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr( + routes, + "_redact", + lambda text: re.sub(r"secret-(?:old|new)", "[redacted]", text), + ) + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + detail = await (await client.get(f"{_BASE}/specs/live")).json() + task = detail["tasks"][0] + (spec_dir / "tasks.md").write_text("- [ ] rotate secret-new\n") + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": task["index"], "hash": task["hash"]}, + ) + body = await resp.json() + finally: + await client.close() + + assert task["text"] == "rotate [redacted]" + assert resp.status == 409 and body["code"] == "task_changed" + assert sent == [] + + +@pytest.mark.asyncio +async def test_task_run_is_refused_while_the_whole_list_is_building(tmp_path, monkeypatch): + """A loop already working the list and a single-task turn write the same files + and check off the same boxes, so they must not overlap.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}, extra={"status": "executing"}) + state, _ = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + monkeypatch.setattr(routes, "_effective_status", _always_executing) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "already_executing" + assert sent == [] + + +@pytest.mark.asyncio +async def test_task_run_refuses_a_handoff_that_claims_during_slot_setup(tmp_path, monkeypatch): + """The handoff owns the spec as soon as its index claim lands, before its + nudge loop or first turn makes the slot look busy.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}) + state, _ = _state_for("live") + real_ensure = routes._ensure_worker_slot + sent: list[str] = [] + + async def _ensure_after_handoff_claim(*args, **kwargs): + slot = await real_ensure(*args, **kwargs) + await routes._touch_spec( + "live", + status="executing", + exec_arming_at=time.time(), + ) + return slot + + monkeypatch.setattr(routes, "_ensure_worker_slot", _ensure_after_handoff_claim) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: sent.append("sent")) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "already_executing" + assert sent == [] + + +@pytest.mark.asyncio +async def test_concurrent_task_runs_dispatch_only_once(tmp_path, monkeypatch): + """Two requests may pass the early status check before either owns the slot.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}) + state, slots = _state_for("live") + slot = slots[routes._slot_key("live")] + real_ensure = routes._ensure_worker_slot + first_waiting = asyncio.Event() + release = asyncio.Event() + arrivals = 0 + sent: list[str] = [] + + async def _held_ensure(*args, **kwargs): + nonlocal arrivals + resolved = await real_ensure(*args, **kwargs) + arrivals += 1 + if arrivals == 1: + first_waiting.set() + await release.wait() + return resolved + + def _mark_running(_state, _slot, message): + sent.append(message) + slot.running = True + + monkeypatch.setattr(routes, "_ensure_worker_slot", _held_ensure) + monkeypatch.setattr(routes, "_dispatch_turn", _mark_running) + body = {**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")} + + await client.start_server() + try: + client.app["state"] = state + first = asyncio.create_task(client.post(f"{_BASE}/specs/live/task", json=body)) + second = asyncio.create_task(client.post(f"{_BASE}/specs/live/task", json=body)) + await asyncio.wait_for(first_waiting.wait(), timeout=5) + release.set() + responses = await asyncio.gather(first, second) + payloads = [await response.json() for response in responses] + finally: + release.set() + await client.close() + + assert sorted(response.status for response in responses) == [200, 409] + assert any(payload.get("code") == "agent_running" for payload in payloads) + assert arrivals == 1, "the losing request materialized the slot before checking the winner" + assert len(sent) == 1 + + +@pytest.mark.asyncio +async def test_task_slot_materialization_serializes_delete_capture(tmp_path, monkeypatch): + """Delete cannot capture no slot while a task is about to restore that slot.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}) + state, slots = _state_for() + ensure_waiting = asyncio.Event() + release_ensure = asyncio.Event() + delete_waiting = asyncio.Event() + mark_entered = asyncio.Event() + real_lock = routes._spec_execution_lock + real_mark = routes._mark_deleting + lock_calls = 0 + captured: list[object] = [] + order: list[str] = [] + + async def _held_slot_materialization(_state, _name, _meta): + ensure_waiting.set() + await release_ensure.wait() + key = routes._slot_key("live") + slots[key] = _IdleSlot(key) + return slots[key] + + def _watched_lock(request, name): + nonlocal lock_calls + lock_calls += 1 + if lock_calls == 2: + delete_waiting.set() + return real_lock(request, name) + + async def _watched_mark(*args, **kwargs): + mark_entered.set() + order.append("reserve-delete") + return await real_mark(*args, **kwargs) + + async def _remove_loop(*args, **kwargs): + return None + + async def _capture_teardown(*args, **kwargs): + captured.append(kwargs.get("only_slot")) + return True + + monkeypatch.setattr(routes, "_ensure_worker_slot", _held_slot_materialization) + monkeypatch.setattr(routes, "_spec_execution_lock", _watched_lock) + monkeypatch.setattr(routes, "_mark_deleting", _watched_mark) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: order.append("dispatch-task")) + monkeypatch.setattr(routes, "_remove_nudge_loop", _remove_loop) + monkeypatch.setattr(routes, "_teardown_worker_slot", _capture_teardown) + + await client.start_server() + try: + client.app["state"] = state + body = {**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")} + task_request = asyncio.create_task(client.post(f"{_BASE}/specs/live/task", json=body)) + await asyncio.wait_for(ensure_waiting.wait(), timeout=5) + delete_request = asyncio.create_task(client.delete(f"{_BASE}/specs/live")) + await asyncio.wait_for(delete_waiting.wait(), timeout=5) + assert not mark_entered.is_set(), "Delete captured the runtime during slot restore" + release_ensure.set() + task_response, delete_response = await asyncio.gather(task_request, delete_request) + finally: + release_ensure.set() + await client.close() + + assert task_response.status == 200 and delete_response.status == 200 + assert order == ["dispatch-task", "reserve-delete"] + assert captured == [slots[routes._slot_key("live")]], "Delete missed the restored slot" + + +@pytest.mark.asyncio +async def test_task_final_snapshot_serializes_the_whole_plan_claim(tmp_path, monkeypatch): + """Execute cannot claim the spec while a task's final disk snapshot awaits.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}) + state, slots = _state_for("live") + slot = slots[routes._slot_key("live")] + release_snapshot = asyncio.Event() + snapshot_waiting = asyncio.Event() + execute_waiting = asyncio.Event() + claim_entered = asyncio.Event() + real_to_thread = routes.asyncio.to_thread + real_lock = routes._spec_execution_lock + real_claim = routes._claim_execution + snapshot_calls = 0 + lock_calls = 0 + sent: list[str] = [] + + async def _held_final_snapshot(func, *args, **kwargs): + nonlocal snapshot_calls + if getattr(func, "__name__", "") == "_task_snapshot": + snapshot_calls += 1 + if snapshot_calls == 2: + snapshot_waiting.set() + await release_snapshot.wait() + return await real_to_thread(func, *args, **kwargs) + + def _watched_lock(request, name): + nonlocal lock_calls + lock_calls += 1 + if lock_calls == 2: + execute_waiting.set() + return real_lock(request, name) + + async def _watched_claim(*args, **kwargs): + claim_entered.set() + return await real_claim(*args, **kwargs) + + def _mark_running(_state, _slot, message): + sent.append(message) + slot.running = True + + monkeypatch.setattr(routes.asyncio, "to_thread", _held_final_snapshot) + monkeypatch.setattr(routes, "_spec_execution_lock", _watched_lock) + monkeypatch.setattr(routes, "_claim_execution", _watched_claim) + monkeypatch.setattr(routes, "_dispatch_turn", _mark_running) + monkeypatch.setattr(routes, "_autonudge_instance", lambda: object()) + + await client.start_server() + try: + client.app["state"] = state + body = {**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")} + task_request = asyncio.create_task(client.post(f"{_BASE}/specs/live/task", json=body)) + await asyncio.wait_for(snapshot_waiting.wait(), timeout=5) + execute_request = asyncio.create_task(client.post(f"{_BASE}/specs/live/execute")) + await asyncio.wait_for(execute_waiting.wait(), timeout=5) + assert not claim_entered.is_set(), "Execute crossed the task's final snapshot" + release_snapshot.set() + task_response, execute_response = await asyncio.gather(task_request, execute_request) + execute_body = await execute_response.json() + finally: + release_snapshot.set() + slot.running = False + await client.close() + + assert task_response.status == 200 + assert execute_response.status == 409 and execute_body["code"] == "already_executing" + assert claim_entered.is_set() and len(sent) == 1 + + +@pytest.mark.asyncio +async def test_task_final_snapshot_serializes_delete_reservation(tmp_path, monkeypatch): + """Delete reserves teardown only after a final task snapshot has dispatched.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}) + state, _ = _state_for("live") + release_snapshot = asyncio.Event() + snapshot_waiting = asyncio.Event() + delete_waiting = asyncio.Event() + mark_entered = asyncio.Event() + real_to_thread = routes.asyncio.to_thread + real_lock = routes._spec_execution_lock + real_mark = routes._mark_deleting + snapshot_calls = 0 + lock_calls = 0 + order: list[str] = [] + + async def _held_final_snapshot(func, *args, **kwargs): + nonlocal snapshot_calls + if getattr(func, "__name__", "") == "_task_snapshot": + snapshot_calls += 1 + if snapshot_calls == 2: + snapshot_waiting.set() + await release_snapshot.wait() + return await real_to_thread(func, *args, **kwargs) + + def _watched_lock(request, name): + nonlocal lock_calls + lock_calls += 1 + if lock_calls == 2: + delete_waiting.set() + return real_lock(request, name) + + async def _watched_mark(*args, **kwargs): + mark_entered.set() + order.append("reserve-delete") + return await real_mark(*args, **kwargs) + + async def _remove_loop(*args, **kwargs): + return None + + async def _teardown(*args, **kwargs): + return True + + monkeypatch.setattr(routes.asyncio, "to_thread", _held_final_snapshot) + monkeypatch.setattr(routes, "_spec_execution_lock", _watched_lock) + monkeypatch.setattr(routes, "_mark_deleting", _watched_mark) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: order.append("dispatch-task")) + monkeypatch.setattr(routes, "_remove_nudge_loop", _remove_loop) + monkeypatch.setattr(routes, "_teardown_worker_slot", _teardown) + + await client.start_server() + try: + client.app["state"] = state + body = {**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")} + task_request = asyncio.create_task(client.post(f"{_BASE}/specs/live/task", json=body)) + await asyncio.wait_for(snapshot_waiting.wait(), timeout=5) + delete_request = asyncio.create_task(client.delete(f"{_BASE}/specs/live")) + await asyncio.wait_for(delete_waiting.wait(), timeout=5) + assert not mark_entered.is_set(), "Delete crossed the task's final snapshot" + release_snapshot.set() + task_response, delete_response = await asyncio.gather(task_request, delete_request) + finally: + release_snapshot.set() + await client.close() + + assert task_response.status == 200 and delete_response.status == 200 + assert order == ["dispatch-task", "reserve-delete"] + + +@pytest.mark.asyncio +async def test_task_run_refuses_between_orchestration_stages(tmp_path, monkeypatch): + """A staged plan owns the slot even when no individual turn task is live.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [ ] add the tests\n"}) + state, slots = _state_for("live") + slots[routes._slot_key("live")]._in_stage_execution = True + sent: list[str] = [] + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: sent.append("sent")) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/task", + json={**_spec_identity(), "index": 0, "hash": routes._sha256_text("add the tests")}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "agent_running" + assert sent == [] + + +async def _always_executing(name, meta, slot): + return "executing" + + +@pytest.mark.asyncio +async def test_task_run_refuses_a_task_already_checked_off(tmp_path, monkeypatch): + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"tasks.md": "- [x] already finished\n"}) + state, _ = _state_for("live") + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/task", + json={ + **_spec_identity(), + "index": 0, + "hash": routes._sha256_text("already finished"), + }, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "task_done" + + +# ── gap 5: label, archive, duplicate ───────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_title_relabels_without_touching_the_identity(tmp_path, monkeypatch): + """A rename of the LABEL only. The name is simultaneously the on-disk directory, + the git branch and the chat slot key -- and _owns_slot_key requires the key to + ENCODE the name -- so renaming the identity would move a directory the IDE and + CLI also read and orphan the spec's transcript, which is exactly what + delete-and-recreate loses.""" + client = _make_client(monkeypatch, tmp_path) + spec_dir, _ = _seed_spec(tmp_path) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/title", + json={**_spec_identity(), "title": "Checkout rewrite"}, + ) + assert resp.status == 200, await resp.json() + finally: + await client.close() + + entry = routes._load_index()["live"] + assert entry["title"] == "Checkout rewrite" + assert entry["spec_dir"] == str(spec_dir), "the directory moved" + assert entry["slot_key"] == routes._slot_key("live"), "the transcript was orphaned" + assert spec_dir.is_dir() + + +@pytest.mark.asyncio +async def test_archive_marks_the_spec_without_deleting_it(tmp_path, monkeypatch): + """The non-destructive counterpart to delete: documents, transcript and index + entry all stay. Delete used to be the only way out of the rail, so tidying up + and destroying the work were the same action.""" + client = _make_client(monkeypatch, tmp_path) + spec_dir, _ = _seed_spec(tmp_path, files={"requirements.md": "# keep me"}) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + assert ( + await client.post( + f"{_BASE}/specs/live/archive", + json={**_spec_identity(), "archived": True}, + ) + ).status == 200 + + listed = await (await client.get(f"{_BASE}/specs")).json() + # Still reachable directly, and still on disk. + detail = await (await client.get(f"{_BASE}/specs/live")).json() + + assert ( + await client.post( + f"{_BASE}/specs/live/archive", + json={**_spec_identity(), "archived": False}, + ) + ).status == 200 + restored = await (await client.get(f"{_BASE}/specs")).json() + finally: + await client.close() + + assert [(s["name"], s["archived"]) for s in listed["specs"]] == [("live", True)] + assert detail["archived"] is True + assert (spec_dir / "requirements.md").read_text() == "# keep me" + assert [s["name"] for s in restored["specs"]] == ["live"] + + +@pytest.mark.asyncio +async def test_archive_is_refused_while_the_spec_is_building(tmp_path, monkeypatch): + """Archiving a running spec would hide a loop that keeps editing files, leaving + the user no surface to stop it from.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, extra={"status": "executing"}) + monkeypatch.setattr(routes, "_effective_status", _always_executing) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/archive", + json={**_spec_identity(), "archived": True}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "spec_executing" + assert routes._load_index()["live"].get("archived") is not True + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_copies_the_documents_into_a_fresh_spec(tmp_path, monkeypatch): + """The recovery path rename cannot serve: a spec whose NAME is wrong once it + already has a branch or history. The copy takes the documents and nothing + else, so it gets its own slot key and therefore its own conversation.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# reqs", "design.md": "# design"}) + state, _ = _state_for("live") + sent: list[str] = [] + monkeypatch.setattr(routes, "_dispatch_turn", lambda st, slot, msg: sent.append(msg)) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 201, body + copy_dir = Path(body["spec_dir"]) + assert copy_dir.name == "live-copy" + assert (copy_dir / "requirements.md").read_text() == "# reqs" + assert (copy_dir / "design.md").read_text() == "# design" + assert not (copy_dir / routes._DUPLICATE_MARKER).exists() + + index = routes._load_index() + assert index["live"]["spec_dir"] != index["live-copy"]["spec_dir"] + assert index["live-copy"]["slot_key"] != index["live"]["slot_key"], "shared a transcript" + # A duplicate never inherits a worktree: branching off someone's repo is not a + # copy operation, and it is an opt-in at create time. + assert index["live-copy"]["worktree_branch"] == "" + # The fresh conversation is told what it is looking at, and told not to rewrite it. + assert len(sent) == 1 and "copy of 'live'" in sent[0] and "Do NOT rewrite" in sent[0] + + +@pytest.mark.asyncio +async def test_duplicate_refuses_while_the_source_agent_is_writing(tmp_path, monkeypatch): + """A live agent turn can rewrite phase files while they are being copied.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# reqs"}) + state, slots = _state_for("live") + slots[routes._slot_key("live")].running = True + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: None) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "agent_running" + assert "live-copy" not in routes._load_index() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_refuses_a_mixed_source_document_snapshot(tmp_path, monkeypatch): + """Every copied phase file must belong to one stable source snapshot.""" + client = _make_client(monkeypatch, tmp_path) + spec_dir, _working_dir = _seed_spec( + tmp_path, + files={"requirements.md": "# requirements v1", "design.md": "# design v1"}, + ) + real_read = routes._read_spec_text + changed = False + + def _change_plan_between_reads(directory, fname): + nonlocal changed + text = real_read(directory, fname) + if fname == "requirements.md" and not changed: + changed = True + (spec_dir / "requirements.md").write_text("# requirements v2") + (spec_dir / "design.md").write_text("# design v2") + return text + + monkeypatch.setattr(routes, "_read_spec_text", _change_plan_between_reads) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: None) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "mixed-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "spec_changed_during_duplicate" + assert "mixed-copy" not in routes._load_index() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_redacts_the_returned_spec_directory(tmp_path, monkeypatch): + """A credential-like project path must not cross the HTTP response boundary raw.""" + client = _make_client(monkeypatch, tmp_path) + secret_root = tmp_path / "secret-segment" + _seed_spec(secret_root, files={"requirements.md": "# copied"}) + monkeypatch.setattr( + routes, + "_redact", + lambda text: text.replace("secret-segment", "[REDACTED]"), + ) + monkeypatch.setattr(routes, "_dispatch_turn", lambda _state, _slot, _message: None) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 201, body + assert "secret-segment" not in body["spec_dir"] + assert "[REDACTED]" in body["spec_dir"] + assert Path(routes._load_index()["live-copy"]["spec_dir"]).is_dir() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_publishes_recovery_provenance_before_index_reservation( + tmp_path, monkeypatch +): + """A crash after reservation must leave proof that recovery may discard it.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + real_mutate = routes._mutate_index + + async def _assert_provenance(fn): + changed = await real_mutate(fn) + if changed and getattr(fn, "__name__", "") == "_insert": + meta = routes._load_index()["live-copy"] + held = meta[routes._DUPLICATING] + assert routes._duplicate_marker_matches(Path(held["stage_dir"]), held["token"]) + return changed + + monkeypatch.setattr(routes, "_mutate_index", _assert_provenance) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: None) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 201, body + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_cancelled_duplicate_finishes_its_published_transaction(tmp_path, monkeypatch): + """Request cancellation cannot hide a published copy behind our reservation.""" + _redirect_state(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + state, _ = _state_for("live") + published = threading.Event() + release_write = threading.Event() + real_write = routes._write_and_publish_duplicate + + def _publish_then_block(*args, **kwargs): + result = real_write(*args, **kwargs) + assert result[0] == "" + published.set() + assert release_write.wait(5), "cancelled transaction was never released" + return result + + async def _body(_request): + return {"new_name": "cancelled-copy"} + + async def _fresh(_request, _name, _body): + return routes._load_index()["live"] + + monkeypatch.setattr(routes, "_require_auth", lambda _request: None) + monkeypatch.setattr(routes, "_read_json", _body) + monkeypatch.setattr(routes, "_pinned_entry", _fresh) + monkeypatch.setattr(routes, "_write_and_publish_duplicate", _publish_then_block) + request = cast( + web.Request, + types.SimpleNamespace(match_info={"name": "live"}, app={"state": state}), + ) + + handler = asyncio.create_task(routes._handle_duplicate(request)) + try: + assert await asyncio.to_thread(published.wait, 5) + handler.cancel() + release_write.set() + with pytest.raises(asyncio.CancelledError): + await handler + finally: + release_write.set() + + meta = routes._load_index()["cancelled-copy"] + target = Path(meta["spec_dir"]) + assert routes._DUPLICATING not in meta + assert (target / "requirements.md").read_text() == "# copied" + assert not (target / routes._DUPLICATE_MARKER).exists() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_preserves_an_existing_empty_document(tmp_path, monkeypatch): + """An empty phase file is data, not the same thing as a missing document.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# reqs", "design.md": ""}) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: None) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 201, body + copy_dir = Path(body["spec_dir"]) + assert (copy_dir / "requirements.md").read_text() == "# reqs" + assert (copy_dir / "design.md").is_file() + assert (copy_dir / "design.md").read_text() == "" + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_refuses_an_existing_document_that_cannot_be_read(tmp_path, monkeypatch): + """An oversized source document must not be silently omitted from a copy.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec( + tmp_path, + files={ + "requirements.md": "# copied", + "design.md": "x" * (routes._MAX_SPEC_BYTES + 1), + }, + ) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args: None) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "incomplete-copy"}, + ) + assert resp.status == 409 + body = await resp.json() + finally: + await client.close() + + assert body["code"] == "spec_document_unreadable" + assert "incomplete-copy" not in routes._load_index() + assert not (working_dir / ".kiro" / "specs" / "incomplete-copy").exists() + + +@pytest.mark.asyncio +async def test_duplicate_refuses_a_name_already_in_use(tmp_path, monkeypatch): + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# reqs"}) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + same = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live"}, + ) + same_body = await same.json() + bad = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "../escape"}, + ) + bad_body = await bad.json() + finally: + await client.close() + + assert same.status == 409 and same_body["code"] == "spec_exists" + assert bad.status == 400 and bad_body["code"] == "invalid_name" + + +@pytest.mark.asyncio +async def test_duplicate_refuses_a_spec_with_no_documents_yet(tmp_path, monkeypatch): + """Nothing to copy is a refusal, not an empty spec created as a side effect.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "empty-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "nothing_to_copy" + assert "empty-copy" not in routes._load_index() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_reserves_its_name_before_populating_the_destination(tmp_path, monkeypatch): + """A concurrent create must not win the destination index entry while the + duplicate is already copying documents into that same directory.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + state, _ = _state_for("live") + real_create = routes._create_spec_doc + save_started = threading.Event() + release_save = threading.Event() + + def _held_save(*args, **kwargs): + save_started.set() + assert release_save.wait(5), "concurrent create never reached arbitration" + return real_create(*args, **kwargs) + + monkeypatch.setattr(routes, "_create_spec_doc", _held_save) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args, **_kwargs: None) + + await client.start_server() + try: + client.app["state"] = state + duplicate_request = asyncio.create_task( + client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "shared-name"}, + ) + ) + assert await asyncio.to_thread(save_started.wait, 5) + create = await client.post( + f"{_BASE}/specs", + json={ + "name": "shared-name", + "working_dir": str(working_dir), + "spec_type": "feature", + "description": "concurrent create", + "use_worktree": False, + }, + ) + create_body = await create.json() + release_save.set() + duplicate = await duplicate_request + duplicate_body = await duplicate.json() + finally: + release_save.set() + await client.close() + + assert create.status == 409 and create_body["code"] == "spec_exists" + assert duplicate.status == 201, duplicate_body + copy_dir = Path(duplicate_body["spec_dir"]) + assert (copy_dir / "requirements.md").read_text() == "# copied" + assert routes._load_index()["shared-name"]["spec_dir"] == str(copy_dir) + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_refuses_when_the_destination_changes_after_reservation( + tmp_path, monkeypatch +): + """The index reservation and copied files must always name one directory.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + moved_base = tmp_path / "moved-specs" + moved_base.mkdir() + state, _ = _state_for("live") + real_mutate = routes._mutate_index + moved = False + + async def _move_settings_after_reservation(fn): + nonlocal moved + changed = await real_mutate(fn) + if changed and not moved and "live-copy" in routes._load_index(): + moved = True + routes._save_settings({"base_path": str(moved_base), "model": ""}) + return changed + + monkeypatch.setattr(routes, "_mutate_index", _move_settings_after_reservation) + monkeypatch.setattr(routes, "_dispatch_turn", lambda *_args, **_kwargs: None) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "live-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + assert resp.status == 409 and body["code"] == "spec_destination_changed" + assert "live-copy" not in routes._load_index() + assert not (moved_base / "live-copy").exists() + assert not (working_dir / ".kiro" / "specs" / "live-copy").exists() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_rolls_back_only_files_it_created_after_a_partial_failure( + tmp_path, monkeypatch +): + """A failed copy must not become discoverable as an incomplete spec.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec( + tmp_path, + files={"requirements.md": "# copied", "design.md": "# fails"}, + ) + state, _ = _state_for("live") + real_create = routes._create_spec_doc + calls = 0 + + def _fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + return "write_failed", None + return real_create(*args, **kwargs) + + monkeypatch.setattr(routes, "_create_spec_doc", _fail_second) + + await client.start_server() + try: + client.app["state"] = state + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "partial-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + target = working_dir / ".kiro" / "specs" / "partial-copy" + assert resp.status == 400 and body["code"] == "doc_write_failed" + assert "partial-copy" not in routes._load_index() + assert not target.exists() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_preserves_a_published_copy_when_index_finalization_fails( + tmp_path, monkeypatch +): + """A post-rename failure remains recoverable instead of poisoning retries.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + real_mutate = routes._mutate_index + + async def _fail_finish(fn): + if getattr(fn, "__name__", "") == "_finish": + return False + return await real_mutate(fn) + + monkeypatch.setattr(routes, "_mutate_index", _fail_finish) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "recoverable-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + target = working_dir / ".kiro" / "specs" / "recoverable-copy" + assert resp.status == 409 and body["code"] == "spec_changed_during_create" + assert (target / "requirements.md").read_text() == "# copied" + assert (target / routes._DUPLICATE_MARKER).is_file() + assert routes._DUPLICATING in routes._load_index()["recoverable-copy"] + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_contains_an_index_finalization_exception(tmp_path, monkeypatch): + """A post-publication index error must return the recoverable-copy response.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + real_mutate = routes._mutate_index + + async def _raise_finish(fn): + if getattr(fn, "__name__", "") == "_finish": + raise OSError("index storage unavailable") + return await real_mutate(fn) + + monkeypatch.setattr(routes, "_mutate_index", _raise_finish) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "recoverable-copy"}, + ) + assert resp.status == 409 + body = await resp.json() + finally: + await client.close() + + target = working_dir / ".kiro" / "specs" / "recoverable-copy" + assert body["code"] == "spec_changed_during_create" + assert (target / "requirements.md").read_text() == "# copied" + assert (target / routes._DUPLICATE_MARKER).is_file() + assert routes._DUPLICATING in routes._load_index()["recoverable-copy"] + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not routes._CAN_PUBLISH_DIR_NOREPLACE, + reason="duplicate publication requires atomic no-replace directory publication", +) +async def test_duplicate_keeps_its_committed_copy_when_slot_setup_is_refused(tmp_path, monkeypatch): + """Session arbitration must not delete a copy already committed to the index.""" + client = _make_client(monkeypatch, tmp_path) + _, working_dir = _seed_spec(tmp_path, files={"requirements.md": "# copied"}) + + async def _refuse_slot(*_args, **_kwargs): + return None + + monkeypatch.setattr(routes, "_ensure_worker_slot", _refuse_slot) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await client.post( + f"{_BASE}/specs/live/duplicate", + json={**_spec_identity(), "new_name": "committed-copy"}, + ) + body = await resp.json() + finally: + await client.close() + + target = working_dir / ".kiro" / "specs" / "committed-copy" + assert resp.status == 409 and body["code"] == "slot_owned_by_another_app" + assert (target / "requirements.md").read_text() == "# copied" + assert "committed-copy" in routes._load_index() + + +# ── shared guards for the new mutations ────────────────────────────────────── + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ("post", "approve", {"phase": "requirements", "hash": "0" * 64}), + ("post", "task", {"index": 0, "hash": "0" * 64}), + ("post", "title", {"title": "t"}), + ("post", "archive", {"archived": True}), + ("post", "duplicate", {"new_name": "a-copy"}), + ], +) +async def test_new_mutations_refuse_a_stale_client_identity( + method, path, body, tmp_path, monkeypatch +): + """Every mutation carries the identity the CLIENT rendered, so a stale tab + cannot drive a same-name spec that was deleted and recreated elsewhere. The + per-creation slot key is the decisive field: two specs can share a directory + across a delete + re-import, but never a key.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# r", "tasks.md": "- [ ] t\n"}) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await getattr(client, method)( + f"{_BASE}/specs/live/{path}", + json={**body, "spec_dir": "/somewhere/else", "slot_key": "spec-builder-live-deadbeef"}, + ) + payload = await resp.json() + finally: + await client.close() + + assert resp.status == 409, payload + assert payload["code"] == "stale_client" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ("post", "approve", {"phase": "requirements", "hash": "0" * 64}), + ("post", "task", {"index": 0, "hash": "0" * 64}), + ("post", "title", {"title": "t"}), + ("post", "archive", {"archived": True}), + ("post", "duplicate", {"new_name": "a-copy"}), + ], +) +async def test_new_mutations_require_a_complete_client_identity( + method, path, body, tmp_path, monkeypatch +): + """A control rendered before detail loads must not mutate whatever creation + currently happens to own the same name.""" + client = _make_client(monkeypatch, tmp_path) + _seed_spec(tmp_path, files={"requirements.md": "# r", "tasks.md": "- [ ] t\n"}) + + await client.start_server() + try: + client.app["state"] = _state_for("live")[0] + resp = await getattr(client, method)(f"{_BASE}/specs/live/{path}", json=body) + payload = await resp.json() + finally: + await client.close() + + assert resp.status == 409, payload + assert payload["code"] == "stale_client" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "path"), + [ + ("post", "approve"), + ("post", "task"), + ("post", "title"), + ("post", "archive"), + ("post", "duplicate"), + ], +) +async def test_new_mutations_require_authentication(method, path, tmp_path, monkeypatch): + """No auth middleware, so request['user'] is unset: every new write must 401 + rather than fall through to the filesystem.""" + _redirect_state(monkeypatch, tmp_path) + app = web.Application() # deliberately WITHOUT _auth_mw + routes.register_routes(app) + async with TestClient(TestServer(app)) as client: + resp = await getattr(client, method)(f"{_BASE}/specs/live/{path}", json={}) + assert resp.status == 401 + + +def test_new_mutations_do_not_write_the_index_from_a_stale_snapshot(): + """Extends the existing class guard to the new handlers: each awaits (a body + read at minimum), so none of them may call _save_index directly.""" + for handler in ( + routes._handle_approve, + routes._handle_run_task, + routes._handle_title, + routes._handle_archive, + routes._handle_duplicate, + ): + assert "_save_index(" not in inspect.getsource( + handler + ), f"{handler.__name__} writes the index directly; it must go through _mutate_index" + + +def test_document_and_task_reads_stay_off_the_event_loop(): + """Source guard, same reason the detail handler has one: these handlers read + and write caller-supplied paths, and the detail endpoint beside them is polled + every 2.5s. Blocking filesystem work must ride a worker thread.""" + for handler, blocking in ( + (routes._handle_approve, "_current_hash"), + (routes._handle_run_task, "_tasks"), + (routes._handle_duplicate, "_copy"), + ): + src = inspect.getsource(handler) + # Matched on the two tokens rather than one formatted call string: the + # arguments wrap, so pinning the exact whitespace would fail on a reformat + # while saying nothing about whether the work is still offloaded. + assert ( + "asyncio.to_thread(" in src and blocking in src + ), f"{handler.__name__} may be doing filesystem work inline" diff --git a/src/kiro_crew/platform_compat.py b/src/kiro_crew/platform_compat.py index 770e284a6d0..6150030a875 100644 --- a/src/kiro_crew/platform_compat.py +++ b/src/kiro_crew/platform_compat.py @@ -38,6 +38,32 @@ IS_LINUX: bool = sys.platform == "linux" IS_MACOS: bool = sys.platform == "darwin" +# Python's os.rename() replaces an existing empty directory on POSIX. Directory +# publication sometimes needs the stronger create-if-absent contract, which the +# kernel exposes but the stdlib does not: renameat2(RENAME_NOREPLACE) on Linux +# and renameatx_np(RENAME_EXCL) on macOS. Resolve the native seam once so callers +# can advertise the capability honestly and fail closed everywhere else. +_RENAME_NOREPLACE_FN: Any = None +_RENAME_NOREPLACE_FLAG = 0 +if IS_LINUX or IS_MACOS: + try: + _rename_libc = ctypes.CDLL(None, use_errno=True) + _rename_symbol = "renameat2" if IS_LINUX else "renameatx_np" + _RENAME_NOREPLACE_FN = getattr(_rename_libc, _rename_symbol) + _RENAME_NOREPLACE_FN.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + _RENAME_NOREPLACE_FN.restype = ctypes.c_int + _RENAME_NOREPLACE_FLAG = 1 if IS_LINUX else 4 + except (AttributeError, OSError): + _RENAME_NOREPLACE_FN = None + +RENAME_NOREPLACE_AVAILABLE: bool = _RENAME_NOREPLACE_FN is not None + # Portable signal constants — signal.SIGKILL is undefined on Windows. SIGKILL: int = getattr(signal, "SIGKILL", 9) @@ -177,6 +203,47 @@ def is_bundled_interpreter() -> bool: _LIBRARY_DIR = "Library" +def rename_noreplace( + src: str | os.PathLike, + dst: str | os.PathLike, + *, + src_dir_fd: int, + dst_dir_fd: int, +) -> None: + """Atomically rename *src* to an absent *dst*, or raise. + + Unlike :func:`os.rename`, an existing destination is never replaced. Both + names are resolved relative to caller-pinned directory descriptors. A + filesystem or platform that cannot preserve that contract raises + :class:`NotImplementedError`; callers must not fall back to a check followed + by ordinary rename because another writer can create the destination between + those two operations. + """ + fn = _RENAME_NOREPLACE_FN + if fn is None: + raise NotImplementedError("atomic no-replace rename is unavailable") + src_bytes = os.fsencode(src) + dst_bytes = os.fsencode(dst) + ctypes.set_errno(0) + if fn( + src_dir_fd, + src_bytes, + dst_dir_fd, + dst_bytes, + _RENAME_NOREPLACE_FLAG, + ) == 0: + return + error = ctypes.get_errno() + if error in {errno.EEXIST, errno.ENOTEMPTY}: + raise FileExistsError(error, os.strerror(error), os.fspath(dst)) + unsupported = {errno.ENOSYS, errno.EINVAL} + unsupported.add(getattr(errno, "EOPNOTSUPP", errno.EINVAL)) + unsupported.add(getattr(errno, "ENOTSUP", errno.EINVAL)) + if error in unsupported: + raise NotImplementedError("filesystem lacks atomic no-replace rename") + raise OSError(error, os.strerror(error), os.fspath(dst)) + + def tcc_protected_dirs_for_walk(root: str | os.PathLike) -> frozenset[str]: """Return the TCC-protected dir names to prune when walking *root*. diff --git a/temp-screenshots/spec-builder-execution-controls/01-task-controls-dark.png b/temp-screenshots/spec-builder-execution-controls/01-task-controls-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..43d3cb78babdf4d359a5853b53f2c40f4ff1a820 GIT binary patch literal 215342 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

(q|xK2qq=3I?5 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 specApi.list(), + queryFn: ({ signal }) => specApi.list(signal), refetchInterval: 15000, }) // useMemo so the array identity is stable across renders — otherwise the @@ -73,9 +73,9 @@ function SpecBuilderInner() { // Drop a restored selection that no longer exists (spec removed elsewhere). useEffect(() => { - if (specsQuery.isPending || !sel) return + if (specsQuery.isPending || specsQuery.isFetching || !sel) return if (!specs.some((s) => s.name === sel)) setSelRaw(null) - }, [specsQuery.isPending, specs, sel]) + }, [specsQuery.isFetching, specsQuery.isPending, specs, sel]) return (

+ /** Stored-content hash per document, keyed like `files`. */ + docs?: Record + /** tasks.md's checklist, enumerated. Derived by re-parsing the markdown, which + * stays the source of truth — there is no separate task store. */ + tasks?: SpecTask[] + task_progress?: { done: number; total: number } + /** Recorded human approvals, keyed by phase ('requirements' | 'design'). */ + approvals?: Record + title?: string + archived?: boolean + /** False where crash-safe duplicate publication cannot pin directories. */ + duplicate_supported?: boolean state?: SpecState context?: SpecContextStats running?: boolean @@ -99,8 +142,9 @@ export interface CreateSpecBody { } /** What the client rendered, sent back with every mutation so the server can - * refuse a stale control. Both fields are optional: omitting them means - * "unpinned", which is what an older tab does. */ + * refuse a stale control. Lifecycle mutations require the complete pair; the + * optional shape reflects that detail may still be loading, in which case the + * UI keeps those controls unavailable and the server rejects a direct call. */ export interface SpecIdentity { spec_dir?: string slot_key?: string @@ -118,6 +162,16 @@ import { i18nT } from '../../i18n/t' // ── fetch helper ──────────────────────────────────────────────────────────── +/** An error carrying the backend's required machine-readable `code`. */ +export class SpecApiError extends Error { + code: string + constructor(message: string, code: string) { + super(message) + this.name = 'SpecApiError' + this.code = code + } +} + async function req(path: string, opts?: RequestInit): Promise { const r = await fetch(API + path, { credentials: 'same-origin', @@ -126,12 +180,15 @@ async function req(path: string, opts?: RequestInit): Promise { }) if (!r.ok) { let msg = i18nT('apps.specBuilder.api.something_went_wrong', { status: r.status }) + let code = '' try { - msg = ((await r.json()) as { error?: string }).error || msg + const parsed = (await r.json()) as { error?: string; code?: string } + msg = parsed.error || msg + code = parsed.code || '' } catch { /* non-JSON error body — keep the generic message */ } - throw new Error(msg) + throw new SpecApiError(msg, code) } if (r.status === 204) return undefined as T const text = await r.text() @@ -140,10 +197,17 @@ async function req(path: string, opts?: RequestInit): Promise { const enc = (name: string) => encodeURIComponent(name) +// Reads take an optional AbortSignal so react-query can cancel a fetch that is +// no longer wanted -- switching specs while a poll is in flight otherwise lets +// the older response resolve last and overwrite the newer one. Writes +// deliberately take no signal: the request has already reached the server by the +// time a component unmounts, so cancelling the client side would hide the +// outcome of a mutation that still lands. export const specApi = { - list: () => req('/specs'), + // One response carries both sets; the rail already groups on `archived`. + list: (signal?: AbortSignal) => req('/specs', { signal }), create: (body: CreateSpecBody) => req<{ name?: string }>('/specs', { method: 'POST', body: JSON.stringify(body) }), - get: (name: string) => req('/specs/' + enc(name)), + get: (name: string, signal?: AbortSignal) => req('/specs/' + enc(name), { signal }), // specDir is the identity the CLIENT rendered: the backend compares it against // the live index so a stale tab cannot drive a same-name spec that was deleted // and recreated pointing somewhere else. @@ -171,14 +235,49 @@ export const specApi = { const q = new URLSearchParams(identity(id) as Record).toString() return req('/specs/' + enc(name) + (q ? '?' + q : ''), { method: 'DELETE' }) }, - getSettings: () => req<{ base_path: string; model?: string }>('/settings'), + // ── direct authority over recorded approvals and lifecycle ── + /** Record an approval of `phase` against the exact document hash reviewed. The + * server rejects a hash that is not the current one, so an approval always + * names a version somebody actually saw. */ + approve: (name: string, phase: string, hash: string, id?: SpecIdentity) => + req<{ ok: boolean }>('/specs/' + enc(name) + '/approve', { + method: 'POST', + body: JSON.stringify({ phase, hash, ...identity(id) }), + }), + /** Run ONE task as a single turn. Both index and hash are sent: the agent + * rewrites tasks.md between polls, so an index alone could dispatch whatever + * ended up in that position. */ + runTask: (name: string, index: number, hash: string, id?: SpecIdentity) => + req<{ ok: boolean }>('/specs/' + enc(name) + '/task', { + method: 'POST', + body: JSON.stringify({ index, hash, ...identity(id) }), + }), + /** Set the display label. '' clears it and the UI falls back to the name. */ + setTitle: (name: string, title: string, id?: SpecIdentity) => + req<{ ok: boolean }>('/specs/' + enc(name) + '/title', { + method: 'POST', + body: JSON.stringify({ title, ...identity(id) }), + }), + /** Move a spec out of the working set, or bring it back. Non-destructive. */ + setArchived: (name: string, archived: boolean, id?: SpecIdentity) => + req<{ ok: boolean }>('/specs/' + enc(name) + '/archive', { + method: 'POST', + body: JSON.stringify({ archived, ...identity(id) }), + }), + /** Copy the documents into a new spec. The copy gets a fresh conversation. */ + duplicate: (name: string, new_name: string, id?: SpecIdentity) => + req<{ name: string }>('/specs/' + enc(name) + '/duplicate', { + method: 'POST', + body: JSON.stringify({ new_name, ...identity(id) }), + }), + getSettings: (signal?: AbortSignal) => req<{ base_path: string; model?: string }>('/settings', { signal }), saveSettings: (base_path: string, model: string) => req<{ ok: boolean }>('/settings', { method: 'POST', body: JSON.stringify({ base_path, model }) }), - browse: (path: string) => { + browse: (path: string, signal?: AbortSignal) => { // Not copy: a URL. Built through URLSearchParams so the remaining literal has // the same shape as every other endpoint path in this file. const q = new URLSearchParams({ path: path || '' }).toString() - return req('/browse' + (q ? '?' + q : '')) + return req('/browse' + (q ? '?' + q : ''), { signal }) }, } diff --git a/website/src/apps/spec-builder/components/DocView.tsx b/website/src/apps/spec-builder/components/DocView.tsx index 1aed8fd2358..f5c3cbb1e80 100644 --- a/website/src/apps/spec-builder/components/DocView.tsx +++ b/website/src/apps/spec-builder/components/DocView.tsx @@ -4,12 +4,13 @@ // comment (with file attribution) into the parent's tray — nothing is sent to // the agent until "Send all to agent". import { useEffect, useRef, useState } from 'react' -import { MessageSquare, Plus, X, FileText } from 'lucide-react' +import { MessageSquare, Plus, X, FileText, ListChecks } from 'lucide-react' import MarkdownRenderer from '../../../components/MarkdownRenderer' import { Input } from '../../../components/ui' -import type { SpecDetail } from '../api' +import type { SpecDetail, SpecTask } from '../api' import { ACCENT, SEL_BG, Btn } from './shared' import { DocSkeleton } from './Shimmer' +import TaskList from './TaskList' import { i18nT } from '../../../i18n/t' import { useImeGuard } from '../../../hooks/useImeGuard' @@ -40,15 +41,27 @@ export interface DocViewProps { * empty state, so an in-flight document reads as pending, not absent. */ running?: boolean addComment: (c: { file: string; quote: string; note: string }) => void + /** Dispatch a single task. Absent = the run controls are not offered. */ + runTask?: (task: SpecTask) => void + pendingTaskIndex?: number | null } -export default function DocView({ detail, tab, addComment, running = false }: DocViewProps) { +export default function DocView({ + detail, + tab, + addComment, + running = false, + runTask, + pendingTaskIndex = null, +}: DocViewProps) { const ime = useImeGuard() - const content = detail?.files?.[tab + '.md'] + const fname = tab + '.md' + const content = detail?.files?.[fname] const boxRef = useRef(null) const [sel, setSel] = useState(null) const [note, setNote] = useState(null) const [draft, setDraft] = useState('') + const [taskDocument, setTaskDocument] = useState(false) const onSelectionSettled = () => { const s = window.getSelection() @@ -82,10 +95,35 @@ export default function DocView({ detail, tab, addComment, running = false }: Do } }) + const hasTaskControls = tab === 'tasks' && !!runTask && !!detail?.tasks?.length + const showTasks = hasTaskControls && !taskDocument + return (
+ {hasTaskControls && ( +
+ setTaskDocument(false)} + label={<> {i18nT('apps.specBuilder.components.taskList.task_progress')}} + /> + setTaskDocument(true)} + label={<> {i18nT('apps.specBuilder.components.docView.document_file_name', { name: 'tasks' })}} + /> +
+ )}
- {content ? ( + {showTasks ? ( + runTask?.(t)} + /> + ) : content ? (
diff --git a/website/src/apps/spec-builder/components/NewSpecView.tsx b/website/src/apps/spec-builder/components/NewSpecView.tsx index f3db02b6877..0ad35c5c608 100644 --- a/website/src/apps/spec-builder/components/NewSpecView.tsx +++ b/website/src/apps/spec-builder/components/NewSpecView.tsx @@ -51,7 +51,7 @@ export default function NewSpecView({ onCancel, onCreated, setErr, onSettings }: // last and mislabel the current one (repo `use-react-query` rule). const gitQuery = useQuery({ queryKey: ['spec-builder', 'browse', wd], - queryFn: () => specApi.browse(wd), + queryFn: ({ signal }) => specApi.browse(wd, signal), enabled: !!wd, }) const isGit: boolean | null = !wd || gitQuery.isPending ? null : !!gitQuery.data?.is_git diff --git a/website/src/apps/spec-builder/components/ProjectPicker.tsx b/website/src/apps/spec-builder/components/ProjectPicker.tsx index 7c78c767189..f53befdaba8 100644 --- a/website/src/apps/spec-builder/components/ProjectPicker.tsx +++ b/website/src/apps/spec-builder/components/ProjectPicker.tsx @@ -28,7 +28,7 @@ export default function ProjectPicker({ value, onChange }: { value: string; onCh // browse response carries a fresh list for the folder being viewed. const recentsQuery = useQuery({ queryKey: ['spec-builder', 'browse', ''], - queryFn: () => specApi.browse(''), + queryFn: ({ signal }) => specApi.browse('', signal), }) useEffect(() => { if (recentsQuery.data) setRecents(recentsQuery.data.recents || []) @@ -41,7 +41,7 @@ export default function ProjectPicker({ value, onChange }: { value: string; onCh const [navPath, setNavPath] = useState(null) const navQuery = useQuery({ queryKey: ['spec-builder', 'browse', navPath ?? ''], - queryFn: () => specApi.browse(navPath ?? ''), + queryFn: ({ signal }) => specApi.browse(navPath ?? '', signal), enabled: navPath !== null, }) useEffect(() => { diff --git a/website/src/apps/spec-builder/components/SettingsModal.tsx b/website/src/apps/spec-builder/components/SettingsModal.tsx index b3c5af2df34..46f26c00d0b 100644 --- a/website/src/apps/spec-builder/components/SettingsModal.tsx +++ b/website/src/apps/spec-builder/components/SettingsModal.tsx @@ -31,7 +31,7 @@ export default function SettingsModal({ onClose, setErr }: SettingsModalProps) { // stay local state because they are edit buffers, seeded once the read lands. const settingsQuery = useQuery({ queryKey: ['spec-builder', 'settings'], - queryFn: () => specApi.getSettings(), + queryFn: ({ signal }) => specApi.getSettings(signal), }) useEffect(() => { if (settingsQuery.data) { diff --git a/website/src/apps/spec-builder/components/SpecDetail.tsx b/website/src/apps/spec-builder/components/SpecDetail.tsx index 1ef6030a3e7..99f0426f021 100644 --- a/website/src/apps/spec-builder/components/SpecDetail.tsx +++ b/website/src/apps/spec-builder/components/SpecDetail.tsx @@ -5,9 +5,22 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { motion } from 'framer-motion' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Maximize2, Minimize2, Play, Pause, MessageSquare, X } from 'lucide-react' +import { + Maximize2, Minimize2, Play, Pause, MessageSquare, X, + Check, AlertTriangle, Pencil, Archive, ArchiveRestore, Copy, MoreHorizontal, +} from 'lucide-react' import { ADVANCE_PROMPT } from '../prompts' -import { specApi, LS, phaseLabel, PHASE_BUILDING_KEY, type SpecDetail as SpecDetailData } from '../api' +import { + specApi, LS, phaseLabel, PHASE_BUILDING_KEY, + type SpecDetail as SpecDetailData, type SpecTask, +} from '../api' +import { Input } from '../../../components/ui' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../../../components/ui/dropdown-menu' import { ACCENT, SEL_BG, SEL_BORDER, PULSE_MOTION, Btn } from './shared' import SegmentedControl, { type Segment } from '../../../components/SegmentedControl' import { useIsMobile } from '../../../hooks/useIsMobile' @@ -65,9 +78,10 @@ const ADVANCE: Record void + onDuplicated?: (name: string) => void } -export default function SpecDetail({ name, setErr }: SpecDetailProps) { +export default function SpecDetail({ name, setErr, onDuplicated }: SpecDetailProps) { const [tab, setTab] = useState('requirements') const [expanded, setExpanded] = useState(false) // Narrow: the document column steps aside and the chat takes the full width. @@ -94,7 +108,7 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { const detailQuery = useQuery({ queryKey: ['spec-builder', 'spec', name], - queryFn: () => specApi.get(name), + queryFn: ({ signal }) => specApi.get(name, signal), refetchInterval: (q) => { const d = q.state.data if (d?.running || d?.status === 'executing') return 2500 @@ -112,6 +126,7 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { const running = !!detail?.running const executing = detail?.status === 'executing' + const hasIdentity = !!(detail?.spec_dir && detail?.slot_key) // Draggable split: % of the body width given to the docs column (persisted). const bodyRef = useRef(null) @@ -164,7 +179,7 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { const queryClient = useQueryClient() const invalidate = useCallback(() => { void detailQuery.refetch() - void queryClient.invalidateQueries({ queryKey: ['spec-builder', 'specs'] }) + return queryClient.invalidateQueries({ queryKey: ['spec-builder', 'specs'] }) }, [detailQuery, queryClient]) const executeMutation = useMutation({ @@ -188,6 +203,64 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { onSettled: invalidate, }) + // ── direct authority over recorded approvals and lifecycle ── + const approveMutation = useMutation({ + mutationFn: (v: { phase: string; hash: string }) => + specApi.approve(name, v.phase, v.hash, specId()), + onError: (e) => setErr((e as Error).message), + onSettled: invalidate, + }) + const runTaskMutation = useMutation({ + mutationFn: (task: SpecTask) => specApi.runTask(name, task.index, task.hash, specId()), + onMutate: () => { lastSendAt.current = Date.now() }, + onError: (e) => setErr((e as Error).message), + onSettled: invalidate, + }) + const titleMutation = useMutation({ + mutationFn: (title: string) => specApi.setTitle(name, title, specId()), + onError: (e) => setErr((e as Error).message), + onSettled: invalidate, + }) + const archiveMutation = useMutation({ + mutationFn: (archived: boolean) => specApi.setArchived(name, archived, specId()), + onError: (e) => setErr((e as Error).message), + onSettled: invalidate, + }) + const duplicateMutation = useMutation({ + mutationFn: (newName: string) => specApi.duplicate(name, newName, specId()), + onSuccess: async (copy) => { + await invalidate() + onDuplicated?.(copy.name) + }, + onError: (e) => setErr((e as Error).message), + }) + + // The task whose run is being dispatched, so the list can mark just that row. + const [pendingTask, setPendingTask] = useState(null) + const runTask = (task: SpecTask) => { + setPendingTask(task.index) + runTaskMutation.mutate(task, { onSettled: () => setPendingTask(null) }) + } + + // Inline label editing. `null` = not editing; the NAME is never touched, only + // this label (see the backend's title handler for why the name is immutable). + const [titleDraft, setTitleDraft] = useState(null) + // Duplicate asks for the copy's name inline. It has to be asked for rather than + // derived, because the name IS the new spec's directory and branch — the one + // field a copy cannot inherit. + const [dupDraft, setDupDraft] = useState(null) + const commitTitle = () => { + const next = (titleDraft ?? '').trim() + setTitleDraft(null) + if (next !== (detail?.title ?? '')) titleMutation.mutate(next) + } + const commitDuplicate = () => { + const next = (dupDraft ?? '').trim() + if (!next) return + duplicateMutation.mutate(next) + setDupDraft(null) + } + // Whether the instruction currently in flight is THIS view's phase approval. // messageMutation is shared with the decision tray and the review-comment // tray, so keying the button's "Sending…" label on its isPending flag made the @@ -213,10 +286,22 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { // second approval turn. The promise is per call, so it cannot be displaced. const advance = async () => { const phase = detail?.phase + // Approval evidence is meaningful only for the document the user can see. + // A reload can restore a later phase while this local tab starts on + // requirements, so both the control and this handler must reject that gap. + if (!phase || tab !== phase) return const a = phase ? ADVANCE[phase] : undefined - if (!a || !phase) return + const hash = phase ? detail?.docs?.[phase + '.md']?.hash : undefined + if (!a || !hash) return setAdvancing(true) try { + // RECORD the approval before instructing the agent, and let a failure stop + // the instruction. Approval used to be nothing but this chat message, so the + // server never knew a phase had been signed off, by whom, or against which + // text — the only trace was a line in the transcript. Ordering matters: if + // the record fails the agent must not move on, otherwise the spec advances + // with no evidence anyone approved it. + await approveMutation.mutateAsync({ phase, hash }) await messageMutation.mutateAsync(a.msg) setApproved(phase) // Switch to the document being written: DocView holds its shape with a @@ -288,6 +373,37 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { onChange={setTab} layoutId={fullscreen ? 'sb-doc-tabs-full' : 'sb-doc-tabs'} /> + {/* Recorded approval for the document on screen. `stale` is derived by the + backend from the hash, so a document the agent rewrote after sign-off + says so instead of continuing to look approved — which is the whole + reason the approved VERSION is recorded rather than a bare flag. */} + {(() => { + const ap = detail?.approvals?.[tab] + if (!ap) return null + return ap.stale + ? ( + + + {i18nT('apps.specBuilder.components.specDetail.changed_since_approval')} + + ) + : ( + + + {i18nT('apps.specBuilder.components.specDetail.approved')} + + ) + })()} setExpanded(!fullscreen)} @@ -299,6 +415,8 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { (() => { const a = ADVANCE[detail.phase] const waiting = approved === detail.phase + const hasReviewHash = !!detail.docs?.[detail.phase + '.md']?.hash + const reviewingPhase = tab === detail.phase return ( {i18nT(a.labelKey)}} primary={!waiting} - disabled={advancing || messageMutation.isPending || waiting} - title={waiting - ? i18nT('apps.specBuilder.components.specDetail.the_agent_is_writing_the_next_document') - : i18nT('apps.specBuilder.components.specDetail.tells_the_agent_this_phase_is_approved_and_to_mo')} + disabled={ + advancing + || messageMutation.isPending + || waiting + || !hasReviewHash + || !reviewingPhase + } + title={!hasReviewHash + ? i18nT('apps.specBuilder.components.docView.nothing_here_yet') + : waiting + ? i18nT('apps.specBuilder.components.specDetail.the_agent_is_writing_the_next_document') + : i18nT('apps.specBuilder.components.specDetail.tells_the_agent_this_phase_is_approved_and_to_mo')} onClick={() => { void advance() }} /> ) @@ -353,7 +479,28 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { header, and the headers line up. */}
- {name} + {isMobile + ? ( + + {detail?.title || name} + + ) + : ( + // The label is what is shown; the NAME is the identity and stays put. + // A misnamed spec was previously unfixable without deleting it, which + // threw away the conversation along with the name. + + )} } /> )} + {/* Lifecycle beyond create-and-delete. Delete used to be the only way to + get a spec out of the rail, so tidying up and destroying the work were + the same action. */} + {hasIdentity && titleDraft === null && dupDraft === null && ( + + + + + + { + setDupDraft(null) + setTitleDraft(detail?.title ?? '') + }} + > + + {i18nT('apps.specBuilder.components.specDetail.rename_this_spec_label_the_folder_name_stays')} + + {detail?.duplicate_supported !== false && ( + { + setTitleDraft(null) + setDupDraft(name + '-copy') + }} + > + + {i18nT('apps.specBuilder.components.specDetail.duplicate_this_spec')} + + )} + archiveMutation.mutate(!detail?.archived)} + > + {detail?.archived + ? + : } + {detail?.archived + ? i18nT('apps.specBuilder.components.specDetail.restore_this_spec') + : i18nT('apps.specBuilder.components.specDetail.archive_this_spec')} + + + + )}
+ {titleDraft !== null && ( +
+ setTitleDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle() + if (e.key === 'Escape') setTitleDraft(null) + }} + maxLength={120} + placeholder={name} + aria-label={i18nT('apps.specBuilder.components.specDetail.spec_label')} + className="w-full min-w-0" + /> +
+ } + ariaLabel={i18nT('apps.specBuilder.components.specDetail.close_esc')} + title={i18nT('apps.specBuilder.components.specDetail.close_esc')} + onClick={() => setTitleDraft(null)} + /> + +
+
+ )} + {dupDraft !== null && ( +
+ setDupDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitDuplicate() + if (e.key === 'Escape') setDupDraft(null) + }} + maxLength={64} + placeholder={i18nT('apps.specBuilder.components.specDetail.name_for_the_copy')} + aria-label={i18nT('apps.specBuilder.components.specDetail.name_for_the_copy')} + className="w-full min-w-0" + /> +
+ } + ariaLabel={i18nT('apps.specBuilder.components.specDetail.close_esc')} + title={i18nT('apps.specBuilder.components.specDetail.close_esc')} + onClick={() => setDupDraft(null)} + /> + +
+
+ )}
{/* Gated on the detail load. ChatColumn's embedded chat talks to /api/chat, and for a spec DISCOVERED on disk the worker slot does @@ -472,7 +733,14 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) { {/* Body HIDDEN, not unmounted: the document itself moves to the overlay, but DocView holds an in-progress comment draft. */}
- +
{/* Visible at every width. This is the only surface that shows a @@ -553,7 +821,18 @@ export default function SpecDetail({ name, setErr }: SpecDetailProps) {
- + {/* The overlay is the review surface, so it carries the same editor + and task controls. Omitting them here would mean the fullscreen + view — the one with room to actually read a document — was the + only place you could not act on it. */} +
diff --git a/website/src/apps/spec-builder/components/SpecRail.tsx b/website/src/apps/spec-builder/components/SpecRail.tsx index ea8cd3120de..44c0239ee81 100644 --- a/website/src/apps/spec-builder/components/SpecRail.tsx +++ b/website/src/apps/spec-builder/components/SpecRail.tsx @@ -137,9 +137,15 @@ export default function SpecRail({ ) } - const match = (s: SpecSummary) => s.name.toLowerCase().includes(filter.toLowerCase()) - const active = specs.filter((s) => match(s) && s.phase !== 'tasks') - const ready = specs.filter((s) => match(s) && s.phase === 'tasks') + const needle = filter.toLowerCase() + const match = (s: SpecSummary) => ( + s.name.toLowerCase().includes(needle) + || (s.title ?? '').toLowerCase().includes(needle) + ) + const working = specs.filter((s) => match(s) && !s.archived) + const active = working.filter((s) => s.phase !== 'tasks') + const ready = working.filter((s) => s.phase === 'tasks') + const archived = specs.filter((s) => match(s) && s.archived) const groupHeader = (label: string, n: number) => (
setSel(s.name)} aria-current={selected || undefined} - aria-label={s.name + ' — ' + phaseLabel} + aria-label={(s.title || s.name) + ' — ' + phaseLabel} className="flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer mb-0.5 focus-ring" style={{ background: selected ? SEL_BG : 'transparent', @@ -178,7 +184,7 @@ export default function SpecRail({ ? : } - {s.name} + {s.title || s.name} 0 && groupHeader(i18nT('apps.specBuilder.components.specRail.plan_ready'), ready.length)} {ready.map(row)} + {archived.length > 0 && groupHeader(i18nT('apps.meetings.review.archivedSection'), archived.length)} + {archived.map(row)} )}
diff --git a/website/src/apps/spec-builder/components/TaskList.tsx b/website/src/apps/spec-builder/components/TaskList.tsx new file mode 100644 index 00000000000..dac8090b331 --- /dev/null +++ b/website/src/apps/spec-builder/components/TaskList.tsx @@ -0,0 +1,134 @@ +// TaskList — tasks.md rendered as addressable work rather than static markdown. +// +// The app could previously only hand the WHOLE list to an autonudge loop: there +// was no way to run one task, no way to see which task a run was on, and no +// progress anywhere. The checkboxes were markdown, so they showed state without +// offering any. +// +// Everything here is DERIVED from tasks.md, which stays the source of truth. That +// file is the interop contract with the Kiro IDE and CLI, so a sidecar task store +// would have bought addressability by breaking the thing this app gets right — and +// a box checked by the agent, or by hand in an editor, shows up here on the next +// poll without anything having to be told. +import { CheckCircle2, Circle, Play, ListChecks } from 'lucide-react' +import { motion } from 'framer-motion' +import type { SpecTask } from '../api' +import { ACCENT, SEL_BG, PULSE_MOTION, Btn } from './shared' + +import { i18nT } from '../../../i18n/t' +import { fmtPercent } from '../../../i18n/format' + +export interface TaskListProps { + tasks: SpecTask[] + progress?: { done: number; total: number } + /** Index currently being dispatched, or null. */ + pendingIndex: number | null + /** True while an autonudge loop is working the whole list, or a turn is in + * flight. Single-task runs are refused then, so the controls say why instead + * of failing on click. */ + busy: boolean + onRun: (task: SpecTask) => void +} + +export default function TaskList({ tasks, progress, pendingIndex, busy, onRun }: TaskListProps) { + if (!tasks.length) { + return ( +
+ +
+ {i18nT('apps.specBuilder.components.taskList.no_tasks_yet')} +
+
+ ) + } + + const done = progress?.done ?? tasks.filter((t) => t.done).length + const total = progress?.total ?? tasks.length + // Kept as a ratio for display, and separately as a whole number for the CSS + // width: the readable label goes through fmtPercent so the digits and the sign + // are the locale's, while the bar's width has to stay bare digits to be a + // length. The two must not be collapsed into one value. + const ratio = total ? done / total : 0 + const pctWidth = Math.round(ratio * 100) + + return ( +
+ {/* Progress header. A determinate bar rather than a spinner: the counter in + the CONTEXT card only ever showed turns and tool calls, so during a long + build the user watched a number climb with no idea how far along the plan + it was. */} +
+
+ + {i18nT('apps.specBuilder.components.taskList.done_of_total', { done, total })} + + + {fmtPercent(ratio)} +
+
+
+
+
+ +
    + {tasks.map((task) => { + const pending = pendingIndex === task.index + return ( +
  • + + {task.done + ? + : pending + ? + : } + + + {task.text} + + {task.done + ? ( + + {i18nT('apps.specBuilder.components.taskList.done')} + + ) + : ( + // Disabled while ANY turn is in flight, not just this one: the + // backend refuses a single-task run during a whole-list build + // because both write the same files and check the same boxes, + // so the control explains that instead of failing on click. + {pending + ? i18nT('apps.specBuilder.components.taskList.starting') + : i18nT('apps.specBuilder.components.taskList.run')}} + disabled={busy || pendingIndex !== null} + ariaLabel={i18nT('apps.specBuilder.components.taskList.run_task', { task: task.text })} + title={busy + ? i18nT('apps.specBuilder.components.taskList.pause_the_build_first') + : i18nT('apps.specBuilder.components.taskList.run_only_this_task_and_stop')} + onClick={() => onRun(task)} + /> + )} +
  • + ) + })} +
+
+ ) +} diff --git a/website/src/apps/spec-builder/components/Workspace.tsx b/website/src/apps/spec-builder/components/Workspace.tsx index 1131573e7e5..edcb01cabca 100644 --- a/website/src/apps/spec-builder/components/Workspace.tsx +++ b/website/src/apps/spec-builder/components/Workspace.tsx @@ -123,7 +123,7 @@ export default function Workspace({ specs, sel, setSel, setErr, onNew, loading =
) : sel && specs.some((s) => s.name === sel) ? ( - + ) : (
{/* Points at where the list actually IS: beside this pane on a desktop, diff --git a/website/src/components/CliPanel.tsx b/website/src/components/CliPanel.tsx index 93760def4a6..300041c49a6 100644 --- a/website/src/components/CliPanel.tsx +++ b/website/src/components/CliPanel.tsx @@ -97,6 +97,14 @@ function ensureThemeObserver() { _themeObserver.observe(document.head, { childList: true }) } +/** Test-only: release the document-scoped observer and any queued frame. */ +export function __resetTerminalThemeSyncForTests(): void { + if (_themeRaf) cancelAnimationFrame(_themeRaf) + _themeRaf = 0 + _themeObserver?.disconnect() + _themeObserver = null +} + /* ── Terminal font sync ── * Push the app-wide terminal font preference (useTerminalFont) onto every * cached xterm instance when it changes. Font family and size are canvas cell diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index bc13b558ed2..4e95146a9bc 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -72,6 +72,7 @@ "apps.pptxMaker.libraryPanel.font_specimen": "Type specimen shown large in the template preview, filled with that template's own theme font and text colour. Not a label: the two characters exist so the user can judge the TYPEFACE's letterforms at a glance, the role an \"Aa\" ramp plays in a font picker. Keep the Latin pair unless your readers would judge a typeface better from one upper- and one lower-case glyph of their own script; never render it as a word.", "apps.specBuilder.components.projectPicker.up": "Spec Builder's project-folder picker: the button that navigates one directory level up. 'Up' means the parent folder, not upward motion or an increase.", "apps.specBuilder.components.specRail.v": "Spec Builder's sidebar footer: the single-letter prefix before the app version number, as in v1.2.3. Short for version, not the letter itself.", + "apps.specBuilder.components.taskList.run": "Verb on the button beside ONE task in Spec Builder's task list: have the agent do that single task and then stop. NOT the noun (\"a run\"), which several locales translate differently, and not \"start the whole build\" — the sibling \"Start building\" works the entire list.", "apps.workflows.workflowsPage.run": "Verb on a button: execute this job/workflow now. NOT the noun (\"a run\"), which several locales translate differently.", "autoImprovement.prTitlePrefix": "\"PR\" = pull request, prefixing a generated pull-request title. Keep the abbreviation the target language's developers actually use; do not expand it if that would break the layout.", "components.appstore.featuredSpotlight.get": "Verb on an App Store button: install this app. Not the sense of obtain or receive.", diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 21cbc9415b1..3b3cba9410d 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -4148,6 +4148,10 @@ "advance_to_design": "অনুমোদন → ডিজাইন", "advance_to_tasks": "অনুমোদন → কাজ", "an_agent_will_work_through_the_task_list": "একজন এজেন্ট কাজের তালিকা ধরে এগোবে", + "approved": "অনুমোদিত", + "approved_by_user": "{{user}} অনুমোদন করেছে", + "archive_this_spec": "এই spec সংরক্ষণাগারে রাখো", + "changed_since_approval": "অনুমোদনের পরে বদলেছে", "clear": "খালি করুন", "close_esc": "বন্ধ করুন (Esc)", "close_review_view": "পর্যালোচনা ভিউ বন্ধ করুন", @@ -4155,24 +4159,30 @@ "drafting_design": "ডিজাইন লেখা হচ্ছে…", "drafting_tasks": "কাজ লেখা হচ্ছে…", "drag_or_use_to_resize": "আকার বদলাতে টানুন বা ← → ব্যবহার করুন", + "duplicate_this_spec": "এই spec-এর নকল বানাও", "expand_document_for_review": "পর্যালোচনার জন্য নথি বড় করো", "expand_for_review_esc_to_close": "পর্যালোচনার জন্য বড় করো (Esc দিয়ে বন্ধ)", + "name_for_the_copy": "নকলের নাম", "pause": "থামাও", "pausing": "থামানো হচ্ছে…", "pending_comment_one": "{{count}}টি অপেক্ষমাণ মন্তব্য", "pending_comment_other": "{{count}}টি অপেক্ষমাণ মন্তব্য", "remove_comment_on": "{{document}}-এর মন্তব্য সরাও", + "rename_this_spec_label_the_folder_name_stays": "এই spec-এর লেবেল বদলাও। এর ফোল্ডার আর ব্রাঞ্চের নাম একই থাকবে।", "resize_document_panel": "নথি প্যানেলের আকার বদলান", + "restore_this_spec": "এই spec ফিরিয়ে আনো", "review_document": "{{document}} পর্যালোচনা করো", "send_all_to_agent": "সব এজেন্টকে পাঠান", "sending": "পাঠানো হচ্ছে…", + "spec_label": "Spec-এর লেবেল", "start_building": "বানানো শুরু করো", "starting": "শুরু হচ্ছে…", "tab_design": "ডিজাইন", "tab_requirements": "চাহিদা", "tab_tasks": "কাজ", - "tells_the_agent_this_phase_is_approved_and_to_mo": "এজেন্টকে জানায় যে এই ধাপটি অনুমোদিত এবং সে এগিয়ে যেতে পারে — চ্যাটে লেখার মতোই", + "tells_the_agent_this_phase_is_approved_and_to_mo": "এই সংস্করণে তোমার অনুমোদন লিখে রাখে, তারপর এজেন্টকে এগিয়ে যেতে বলে", "the_agent_is_writing_the_next_document": "অনুমোদিত — এজেন্ট পরবর্তী নথি লিখছে", + "this_document_changed_after_it_was_approved": "এই নথিটি অনুমোদনের পরে বদলে গেছে", "working": "কাজ চলছে…" }, "specRail": { @@ -4206,6 +4216,17 @@ "turns": "পর্ব", "worktree": "Worktree" }, + "taskList": { + "done": "সম্পন্ন", + "done_of_total": "{{total}}টির মধ্যে {{done}}টি কাজ সম্পন্ন", + "no_tasks_yet": "এখনও কোনো কাজ নেই। ডিজাইন অনুমোদন করার পরে সাজানো কাজের তালিকা এখানে আসবে আর তুমি একটি একটি করে কাজ চালাতে পারবে।", + "pause_the_build_first": "একটি কাজ চালানোর আগে বানানো থামাও", + "run": "চালাও", + "run_only_this_task_and_stop": "শুধু এই কাজটি চালাও, তারপর থামো", + "run_task": "কাজ চালাও: {{task}}", + "starting": "শুরু হচ্ছে…", + "task_progress": "কাজের অগ্রগতি" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "আপনি কী বানাতে চান তা লিখুন, কয়েকটি প্রশ্নের উত্তর দিন, এবং একটি পর্যালোচিত পরিকল্পনা পান — চাহিদা, ডিজাইন ও কাজ — যা একজন এজেন্ট সম্পন্ন করতে পারে।", "pick_a_spec_to_continue_where_you_left_off": "যেখানে থেমেছিলেন সেখান থেকে চালিয়ে যেতে একটি spec বেছে নিন", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index a7ba6f23825..07f32c40929 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -4180,6 +4180,10 @@ "advance_to_design": "Freigeben → Design", "advance_to_tasks": "Freigeben → Aufgaben", "an_agent_will_work_through_the_task_list": "Ein Agent arbeitet die Aufgabenliste ab", + "approved": "Freigegeben", + "approved_by_user": "Freigegeben von {{user}}", + "archive_this_spec": "Diese Spec archivieren", + "changed_since_approval": "Seit der Freigabe geändert", "clear": "Leeren", "close_esc": "Schließen (Esc)", "close_review_view": "Prüfansicht schließen", @@ -4187,24 +4191,30 @@ "drafting_design": "Design wird erstellt…", "drafting_tasks": "Aufgaben werden erstellt…", "drag_or_use_to_resize": "Ziehen oder ← → verwenden, um die Größe zu ändern", + "duplicate_this_spec": "Diese Spec duplizieren", "expand_document_for_review": "Dokument zur Prüfung vergrößern", "expand_for_review_esc_to_close": "Zur Prüfung vergrößern (Esc schließt)", + "name_for_the_copy": "Name für die Kopie", "pause": "Pausieren", "pausing": "Wird pausiert…", "pending_comment_one": "{{count}} offener Kommentar", "pending_comment_other": "{{count}} offene Kommentare", "remove_comment_on": "Kommentar zu {{document}} entfernen", + "rename_this_spec_label_the_folder_name_stays": "Benenne die Bezeichnung dieser Spec um. Ordner- und Branch-Name bleiben gleich.", "resize_document_panel": "Größe des Dokumentbereichs ändern", + "restore_this_spec": "Diese Spec wiederherstellen", "review_document": "{{document}} prüfen", "send_all_to_agent": "Alle an den Agenten senden", "sending": "Wird gesendet…", + "spec_label": "Spec-Bezeichnung", "start_building": "Umsetzung starten", "starting": "Wird gestartet…", "tab_design": "Design", "tab_requirements": "Anforderungen", "tab_tasks": "Aufgaben", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Teilt dem Agenten mit, dass diese Phase freigegeben ist und er weitermachen soll — genau wie eine Eingabe im Chat", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Hält deine Freigabe dieser Version fest und sagt dem Agenten dann, dass er weitermachen soll", "the_agent_is_writing_the_next_document": "Genehmigt — der Agent schreibt das nächste Dokument", + "this_document_changed_after_it_was_approved": "Dieses Dokument hat sich nach der Freigabe geändert", "working": "arbeitet…" }, "specRail": { @@ -4238,6 +4248,17 @@ "turns": "Runden", "worktree": "Worktree" }, + "taskList": { + "done": "Fertig", + "done_of_total": "{{done}} von {{total}} Aufgaben fertig", + "no_tasks_yet": "Noch keine Aufgaben. Sobald das Design freigegeben ist, landet die geordnete Aufgabenliste hier und du kannst Aufgaben einzeln ausführen.", + "pause_the_build_first": "Pausiere die Umsetzung, bevor du eine einzelne Aufgabe ausführst", + "run": "Ausführen", + "run_only_this_task_and_stop": "Nur diese Aufgabe ausführen und dann anhalten", + "run_task": "Aufgabe ausführen: {{task}}", + "starting": "Wird gestartet…", + "task_progress": "Aufgabenfortschritt" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Beschreiben Sie, was Sie bauen möchten, beantworten Sie einige Fragen und erhalten Sie einen geprüften Plan — Anforderungen, Design und Aufgaben — den ein Agent umsetzen kann.", "pick_a_spec_to_continue_where_you_left_off": "Wählen Sie eine Spec, um dort weiterzumachen, wo Sie aufgehört haben", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index cdf7dab1f29..edc67387e91 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -4168,6 +4168,10 @@ "advance_to_design": "[Àþþŕøṽè → Ðèşìğñ ··············]", "advance_to_tasks": "[Àþþŕøṽè → Ţàşķş ··············]", "an_agent_will_work_through_the_task_list": "[Àñ àğèñţ ẁìĺĺ ẁøŕķ ţĥŕøùğĥ ţĥè ţàşķ ĺìşţ ····················]", + "approved": "[Àþþŕøṽèð ············]", + "approved_by_user": "[Àþþŕøṽèð ƀý {{user}} ···········]", + "archive_this_spec": "[Àŕçĥìṽè ţĥìş şþèç ···············]", + "changed_since_approval": "[Çĥàñğèð şìñçè àþþŕøṽàĺ ···············]", "clear": "[Çĺèàŕ ········]", "close_esc": "[Çĺøşè (Èşç) ··········]", "close_review_view": "[Çĺøşè ŕèṽìèẁ ṽìèẁ ···············]", @@ -4175,24 +4179,30 @@ "drafting_design": "[Ðŕàƒţìñğ ðèşìğñ… ··············]", "drafting_tasks": "[Ðŕàƒţìñğ ţàşķş… ··············]", "drag_or_use_to_resize": "[Ðŕàğ øŕ ùşè ← → ţø ŕèşìžè ··················]", + "duplicate_this_spec": "[Ðùþĺìçàţè ţĥìş şþèç ·················]", "expand_document_for_review": "[Èẋþàñð ðøçùɱèñţ ƒøŕ ŕèṽìèẁ ··················]", "expand_for_review_esc_to_close": "[Èẋþàñð ƒøŕ ŕèṽìèẁ (Èşç ţø çĺøşè) ················]", + "name_for_the_copy": "[Ñàɱè ƒøŕ ţĥè çøþý ···············]", "pause": "[Þàùşè ········]", "pausing": "[Þàùşìñğ… ············]", "pending_comment_one": "[{{count}} þèñðìñğ çøɱɱèñţ ··············]", "pending_comment_other": "[{{count}} þèñðìñğ çøɱɱèñţş ···············]", "remove_comment_on": "[Ŕèɱøṽè çøɱɱèñţ øñ {{document}} ················]", + "rename_this_spec_label_the_folder_name_stays": "[Ŕèñàɱè ţĥìş şþèç’ş ĺàƀèĺ. Ìţş ƒøĺðèŕ àñð ƀŕàñçĥ ñàɱè şţàý ţĥè şàɱè. ·······················]", "resize_document_panel": "[Ŕèşìžè ðøçùɱèñţ þàñèĺ ···············]", + "restore_this_spec": "[Ŕèşţøŕè ţĥìş şþèç ···············]", "review_document": "[Ŕèṽìèẁ {{document}} ···········]", "send_all_to_agent": "[Şèñð àĺĺ ţø àğèñţ ···············]", "sending": "[Şèñðìñğ… ············]", + "spec_label": "[Şþèç ĺàƀèĺ ···············]", "start_building": "[Şţàŕţ ƀùìĺðìñğ ·············]", "starting": "[Şţàŕţìñğ… ··············]", "tab_design": "[Ðèşìğñ ·········]", "tab_requirements": "[Ŕèǫùìŕèɱèñţş ···········]", "tab_tasks": "[Ţàşķş ········]", - "tells_the_agent_this_phase_is_approved_and_to_mo": "[Ţèĺĺş ţĥè àğèñţ ţĥìş þĥàşè ìş àþþŕøṽèð àñð ţø ɱøṽè øñ — şàɱè àş ţýþìñğ ìţ ìñ ţĥè çĥàţ ··························]", + "tells_the_agent_this_phase_is_approved_and_to_mo": "[Ŕèçøŕðş ýøùŕ àþþŕøṽàĺ øƒ ţĥìş ṽèŕşìøñ, ţĥèñ ţèĺĺş ţĥè àğèñţ ţø ɱøṽè øñ ·························]", "the_agent_is_writing_the_next_document": "[Àþþŕøṽèð — ţĥè àğèñţ ìş ẁŕìţìñğ ţĥè ñèẋţ ðøçùɱèñţ ·························]", + "this_document_changed_after_it_was_approved": "[Ţĥìş ðøçùɱèñţ çĥàñğèð àƒţèŕ ìţ ẁàş àþþŕøṽèð ······················]", "working": "[ẁøŕķìñğ… ············]" }, "specRail": { @@ -4226,6 +4236,17 @@ "turns": "[Ţùŕñş ········]", "worktree": "[Ẁøŕķţŕèè ············]" }, + "taskList": { + "done": "[Ðøñè ······]", + "done_of_total": "[{{done}} øƒ {{total}} ţàşķş ðøñè ··············]", + "no_tasks_yet": "[Ñø ţàşķş ýèţ. Øñçè ţĥè ðèşìğñ ìş àþþŕøṽèð, ţĥè øŕðèŕèð ţàşķ ĺìşţ ĺàñðş ĥèŕè àñð ýøù çàñ ŕùñ ţàşķş øñè àţ à ţìɱè. ··································]", + "pause_the_build_first": "[Þàùşè ţĥè ƀùìĺð ƀèƒøŕè ŕùññìñğ à şìñğĺè ţàşķ ······················]", + "run": "[Ŕùñ ·····]", + "run_only_this_task_and_stop": "[Ŕùñ øñĺý ţĥìş ţàşķ, ţĥèñ şţøþ ····················]", + "run_task": "[Ŕùñ ţàşķ: {{task}} ···············]", + "starting": "[Şţàŕţìñğ… ··············]", + "task_progress": "[Ţàşķ þŕøğŕèşş ············]" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "[Ðèşçŕìƀè ẁĥàţ ýøù ẁàñţ ţø ƀùìĺð, àñşẁèŕ à ƒèẁ ǫùèşţìøñş, àñð ğèţ à ŕèṽìèẁèð þĺàñ — ŕèǫùìŕèɱèñţş, ðèşìğñ, àñð ţàşķş — ţĥàţ àñ àğèñţ çàñ èẋèçùţè ƒøŕ ýøù. ·············································]", "pick_a_spec_to_continue_where_you_left_off": "[Þìçķ à şþèç ţø çøñţìñùè ẁĥèŕè ýøù ĺèƒţ øƒƒ ·····················]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index 1a313ad3c7e..6d0cb2e0f2d 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -3127,6 +3127,10 @@ "advance_to_design": "Approve → Design", "advance_to_tasks": "Approve → Tasks", "an_agent_will_work_through_the_task_list": "An agent will work through the task list", + "approved": "Approved", + "approved_by_user": "Approved by {{user}}", + "archive_this_spec": "Archive this spec", + "changed_since_approval": "Changed since approval", "clear": "Clear", "close_esc": "Close (Esc)", "close_review_view": "Close review view", @@ -3134,24 +3138,30 @@ "drafting_design": "Drafting design…", "drafting_tasks": "Drafting tasks…", "drag_or_use_to_resize": "Drag or use ← → to resize", + "duplicate_this_spec": "Duplicate this spec", "expand_document_for_review": "Expand document for review", "expand_for_review_esc_to_close": "Expand for review (Esc to close)", + "name_for_the_copy": "Name for the copy", "pause": "Pause", "pausing": "Pausing…", "pending_comment_one": "{{count}} pending comment", "pending_comment_other": "{{count}} pending comments", "remove_comment_on": "Remove comment on {{document}}", + "rename_this_spec_label_the_folder_name_stays": "Rename this spec’s label. Its folder and branch name stay the same.", "resize_document_panel": "Resize document panel", + "restore_this_spec": "Restore this spec", "review_document": "Review {{document}}", "send_all_to_agent": "Send all to agent", "sending": "Sending…", + "spec_label": "Spec label", "start_building": "Start building", "starting": "Starting…", "tab_design": "Design", "tab_requirements": "Requirements", "tab_tasks": "Tasks", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Tells the agent this phase is approved and to move on — same as typing it in the chat", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Records your approval of this version, then tells the agent to move on", "the_agent_is_writing_the_next_document": "Approved — the agent is writing the next document", + "this_document_changed_after_it_was_approved": "This document changed after it was approved", "working": "working…" }, "specRail": { @@ -3185,6 +3195,17 @@ "turns": "Turns", "worktree": "Worktree" }, + "taskList": { + "done": "Done", + "done_of_total": "{{done}} of {{total}} tasks done", + "no_tasks_yet": "No tasks yet. Once the design is approved, the ordered task list lands here and you can run tasks one at a time.", + "pause_the_build_first": "Pause the build before running a single task", + "run": "Run", + "run_only_this_task_and_stop": "Run only this task, then stop", + "run_task": "Run task: {{task}}", + "starting": "Starting…", + "task_progress": "Task progress" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Describe what you want to build, answer a few questions, and get a reviewed plan — requirements, design, and tasks — that an agent can execute for you.", "pick_a_spec_to_continue_where_you_left_off": "Pick a spec to continue where you left off", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index e3b32c72359..e6fcec7a0ae 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -4211,6 +4211,10 @@ "advance_to_design": "Aprobar → Diseño", "advance_to_tasks": "Aprobar → Tareas", "an_agent_will_work_through_the_task_list": "Un agente irá completando la lista de tareas", + "approved": "Aprobado", + "approved_by_user": "Aprobado por {{user}}", + "archive_this_spec": "Archivar esta spec", + "changed_since_approval": "Cambió tras la aprobación", "clear": "Vaciar", "close_esc": "Cerrar (Esc)", "close_review_view": "Cerrar la vista de revisión", @@ -4218,25 +4222,31 @@ "drafting_design": "Redactando el diseño…", "drafting_tasks": "Redactando las tareas…", "drag_or_use_to_resize": "Arrastra o usa ← → para redimensionar", + "duplicate_this_spec": "Duplicar esta spec", "expand_document_for_review": "Ampliar el documento para revisarlo", "expand_for_review_esc_to_close": "Ampliar para revisar (Esc para cerrar)", + "name_for_the_copy": "Nombre de la copia", "pause": "Pausar", "pausing": "Pausando…", "pending_comment_many": "{{count}} comentarios pendientes", "pending_comment_one": "{{count}} comentario pendiente", "pending_comment_other": "{{count}} comentarios pendientes", "remove_comment_on": "Quitar el comentario sobre {{document}}", + "rename_this_spec_label_the_folder_name_stays": "Cambia la etiqueta de esta spec. Su carpeta y su rama conservan el mismo nombre.", "resize_document_panel": "Redimensionar el panel del documento", + "restore_this_spec": "Restaurar esta spec", "review_document": "Revisar {{document}}", "send_all_to_agent": "Enviar todo al agente", "sending": "Enviando…", + "spec_label": "Etiqueta de la spec", "start_building": "Empezar a construir", "starting": "Iniciando…", "tab_design": "Diseño", "tab_requirements": "Requisitos", "tab_tasks": "Tareas", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Indica al agente que esta fase está aprobada y que puede continuar, igual que si lo escribieras en el chat", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Registra tu aprobación de esta versión y luego le dice al agente que siga adelante", "the_agent_is_writing_the_next_document": "Aprobado: el agente está escribiendo el siguiente documento", + "this_document_changed_after_it_was_approved": "Este documento cambió después de aprobarse", "working": "trabajando…" }, "specRail": { @@ -4270,6 +4280,17 @@ "turns": "Turnos", "worktree": "Worktree" }, + "taskList": { + "done": "Hecha", + "done_of_total": "{{done}} de {{total}} tareas hechas", + "no_tasks_yet": "Todavía no hay tareas. Cuando apruebes el diseño, la lista ordenada de tareas aparecerá aquí y podrás ejecutarlas de una en una.", + "pause_the_build_first": "Pausa la construcción antes de ejecutar una sola tarea", + "run": "Ejecutar", + "run_only_this_task_and_stop": "Ejecutar solo esta tarea y luego parar", + "run_task": "Ejecutar la tarea: {{task}}", + "starting": "Iniciando…", + "task_progress": "Avance de las tareas" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Describe lo que quieres construir, responde unas preguntas y obtén un plan revisado — requisitos, diseño y tareas — que un agente puede llevar a cabo.", "pick_a_spec_to_continue_where_you_left_off": "Elige una spec para continuar donde lo dejaste", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index 8db83de5f6c..11d17031b71 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -4243,6 +4243,10 @@ "advance_to_design": "Approuver → Conception", "advance_to_tasks": "Approuver → Tâches", "an_agent_will_work_through_the_task_list": "Un agent va dérouler la liste des tâches", + "approved": "Approuvé", + "approved_by_user": "Approuvé par {{user}}", + "archive_this_spec": "Archiver cette spec", + "changed_since_approval": "Modifié depuis l’approbation", "clear": "Effacer", "close_esc": "Fermer (Échap)", "close_review_view": "Fermer la vue de relecture", @@ -4250,25 +4254,31 @@ "drafting_design": "Rédaction de la conception…", "drafting_tasks": "Rédaction des tâches…", "drag_or_use_to_resize": "Faites glisser ou utilisez ← → pour redimensionner", + "duplicate_this_spec": "Dupliquer cette spec", "expand_document_for_review": "Agrandir le document pour la relecture", "expand_for_review_esc_to_close": "Agrandir pour relire (Échap pour fermer)", + "name_for_the_copy": "Nom de la copie", "pause": "Mettre en pause", "pausing": "Mise en pause…", "pending_comment_many": "{{count}} commentaires en attente", "pending_comment_one": "{{count}} commentaire en attente", "pending_comment_other": "{{count}} commentaires en attente", "remove_comment_on": "Supprimer le commentaire sur {{document}}", + "rename_this_spec_label_the_folder_name_stays": "Renommez le libellé de cette spec. Son dossier et sa branche gardent le même nom.", "resize_document_panel": "Redimensionner le panneau du document", + "restore_this_spec": "Restaurer cette spec", "review_document": "Relire {{document}}", "send_all_to_agent": "Tout envoyer à l'agent", "sending": "Envoi…", + "spec_label": "Libellé de la spec", "start_building": "Lancer la construction", "starting": "Démarrage…", "tab_design": "Conception", "tab_requirements": "Exigences", "tab_tasks": "Tâches", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Indique à l'agent que cette phase est validée et qu'il peut continuer — comme si tu l'écrivais dans le chat", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Enregistre votre approbation de cette version, puis demande à l’agent de continuer", "the_agent_is_writing_the_next_document": "Approuvé — l’agent rédige le document suivant", + "this_document_changed_after_it_was_approved": "Ce document a changé après son approbation", "working": "en cours…" }, "specRail": { @@ -4302,6 +4312,17 @@ "turns": "Tours", "worktree": "Worktree" }, + "taskList": { + "done": "Terminée", + "done_of_total": "{{done}} tâches terminées sur {{total}}", + "no_tasks_yet": "Pas encore de tâches. Une fois la conception approuvée, la liste ordonnée des tâches apparaît ici et vous pouvez les lancer une par une.", + "pause_the_build_first": "Mettez la construction en pause avant de lancer une seule tâche", + "run": "Lancer", + "run_only_this_task_and_stop": "Lancer uniquement cette tâche, puis s’arrêter", + "run_task": "Lancer la tâche : {{task}}", + "starting": "Démarrage…", + "task_progress": "Avancement des tâches" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Décris ce que tu veux construire, réponds à quelques questions et obtiens un plan relu — exigences, conception et tâches — qu'un agent peut réaliser.", "pick_a_spec_to_continue_where_you_left_off": "Choisis une spec pour reprendre là où tu en étais", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index f01dd1763a4..a5dbbb32a3f 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -4148,6 +4148,10 @@ "advance_to_design": "स्वीकृत करो → डिज़ाइन", "advance_to_tasks": "स्वीकृत करो → कार्य", "an_agent_will_work_through_the_task_list": "एक एजेंट कार्य सूची पूरी करेगा", + "approved": "स्वीकृत", + "approved_by_user": "{{user}} ने स्वीकृत किया", + "archive_this_spec": "इस spec को संग्रहित करो", + "changed_since_approval": "स्वीकृति के बाद बदला", "clear": "खाली करें", "close_esc": "बंद करें (Esc)", "close_review_view": "समीक्षा दृश्य बंद करें", @@ -4155,24 +4159,30 @@ "drafting_design": "डिज़ाइन लिखा जा रहा है…", "drafting_tasks": "कार्य लिखे जा रहे हैं…", "drag_or_use_to_resize": "आकार बदलने के लिए खींचें या ← → का उपयोग करें", + "duplicate_this_spec": "इस spec की नकल बनाओ", "expand_document_for_review": "समीक्षा के लिए दस्तावेज़ बड़ा करो", "expand_for_review_esc_to_close": "समीक्षा के लिए बड़ा करो (Esc से बंद)", + "name_for_the_copy": "नकल का नाम", "pause": "रोको", "pausing": "रोका जा रहा है…", "pending_comment_one": "{{count}} लंबित टिप्पणी", "pending_comment_other": "{{count}} लंबित टिप्पणियाँ", "remove_comment_on": "{{document}} पर की गई टिप्पणी हटाओ", + "rename_this_spec_label_the_folder_name_stays": "इस spec का लेबल बदलो। इसके फ़ोल्डर और ब्रांच का नाम वही रहेगा।", "resize_document_panel": "दस्तावेज़ पैनल का आकार बदलें", + "restore_this_spec": "इस spec को बहाल करो", "review_document": "{{document}} की समीक्षा करो", "send_all_to_agent": "सब कुछ एजेंट को भेजें", "sending": "भेजा जा रहा है…", + "spec_label": "Spec का लेबल", "start_building": "बनाना शुरू करो", "starting": "शुरू हो रहा है…", "tab_design": "डिज़ाइन", "tab_requirements": "आवश्यकताएं", "tab_tasks": "कार्य", - "tells_the_agent_this_phase_is_approved_and_to_mo": "एजेंट को बताता है कि यह चरण स्वीकृत है और वह आगे बढ़ सकता है — चैट में लिखने के समान", + "tells_the_agent_this_phase_is_approved_and_to_mo": "इस संस्करण के लिए तुम्हारी स्वीकृति दर्ज करता है, फिर एजेंट से आगे बढ़ने को कहता है", "the_agent_is_writing_the_next_document": "स्वीकृत — एजेंट अगला दस्तावेज़ लिख रहा है", + "this_document_changed_after_it_was_approved": "यह दस्तावेज़ स्वीकृत होने के बाद बदल गया", "working": "काम चल रहा है…" }, "specRail": { @@ -4206,6 +4216,17 @@ "turns": "चक्र", "worktree": "Worktree" }, + "taskList": { + "done": "पूरा", + "done_of_total": "{{total}} में से {{done}} कार्य पूरे", + "no_tasks_yet": "अभी कोई कार्य नहीं है। डिज़ाइन स्वीकृत करने के बाद क्रमबद्ध कार्य सूची यहाँ आ जाएगी और तुम एक-एक कार्य चला सकोगे।", + "pause_the_build_first": "एक कार्य चलाने से पहले निर्माण रोको", + "run": "चलाओ", + "run_only_this_task_and_stop": "सिर्फ़ यह कार्य चलाओ, फिर रुक जाओ", + "run_task": "कार्य चलाओ: {{task}}", + "starting": "शुरू हो रहा है…", + "task_progress": "कार्य की प्रगति" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "बताओ कि तुम क्या बनाना चाहते हो, कुछ प्रश्नों के उत्तर दो, और एक समीक्षित योजना पाओ — आवश्यकताएं, डिज़ाइन और कार्य — जिसे कोई एजेंट पूरा कर सके।", "pick_a_spec_to_continue_where_you_left_off": "जहाँ छोड़ा था वहीं से जारी रखने के लिए एक spec चुनो", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index 422d109215c..465f1bc4551 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -4211,6 +4211,10 @@ "advance_to_design": "Approva → Progettazione", "advance_to_tasks": "Approva → Attività", "an_agent_will_work_through_the_task_list": "Un agente lavorerà sull'elenco delle attività", + "approved": "Approvato", + "approved_by_user": "Approvato da {{user}}", + "archive_this_spec": "Archivia questa spec", + "changed_since_approval": "Cambiato dopo l’approvazione", "clear": "Svuota", "close_esc": "Chiudi (Esc)", "close_review_view": "Chiudi la vista di revisione", @@ -4218,25 +4222,31 @@ "drafting_design": "Redazione del design…", "drafting_tasks": "Redazione delle attività…", "drag_or_use_to_resize": "Trascina o usa ← → per ridimensionare", + "duplicate_this_spec": "Duplica questa spec", "expand_document_for_review": "Ingrandisci il documento per la revisione", "expand_for_review_esc_to_close": "Ingrandisci per revisionare (Esc per chiudere)", + "name_for_the_copy": "Nome della copia", "pause": "Metti in pausa", "pausing": "Messa in pausa…", "pending_comment_many": "{{count}} commenti in attesa", "pending_comment_one": "{{count}} commento in attesa", "pending_comment_other": "{{count}} commenti in attesa", "remove_comment_on": "Rimuovi il commento su {{document}}", + "rename_this_spec_label_the_folder_name_stays": "Rinomina l’etichetta di questa spec. La cartella e il branch mantengono lo stesso nome.", "resize_document_panel": "Ridimensiona il pannello del documento", + "restore_this_spec": "Ripristina questa spec", "review_document": "Revisiona {{document}}", "send_all_to_agent": "Invia tutto all'agente", "sending": "Invio…", + "spec_label": "Etichetta della spec", "start_building": "Inizia a costruire", "starting": "Avvio…", "tab_design": "Progettazione", "tab_requirements": "Requisiti", "tab_tasks": "Attività", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Comunica all'agente che questa fase è approvata e che può procedere — come scriverlo in chat", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Registra la tua approvazione di questa versione e poi dice all’agente di proseguire", "the_agent_is_writing_the_next_document": "Approvato — l’agente sta scrivendo il documento successivo", + "this_document_changed_after_it_was_approved": "Questo documento è cambiato dopo essere stato approvato", "working": "al lavoro…" }, "specRail": { @@ -4270,6 +4280,17 @@ "turns": "Turni", "worktree": "Worktree" }, + "taskList": { + "done": "Completata", + "done_of_total": "{{done}} di {{total}} attività completate", + "no_tasks_yet": "Ancora nessuna attività. Dopo l’approvazione della progettazione, l’elenco ordinato delle attività comparirà qui e potrai eseguirle una alla volta.", + "pause_the_build_first": "Metti in pausa la costruzione prima di eseguire una singola attività", + "run": "Esegui", + "run_only_this_task_and_stop": "Esegui solo questa attività, poi fermati", + "run_task": "Esegui l’attività: {{task}}", + "starting": "Avvio…", + "task_progress": "Avanzamento delle attività" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Descrivi cosa vuoi costruire, rispondi a qualche domanda e ottieni un piano revisionato — requisiti, progettazione e attività — che un agente può realizzare.", "pick_a_spec_to_continue_where_you_left_off": "Scegli una spec per riprendere da dove avevi interrotto", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 608cfce44cc..604a769907d 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -4117,6 +4117,10 @@ "advance_to_design": "承認 → デザイン", "advance_to_tasks": "承認 → タスク", "an_agent_will_work_through_the_task_list": "エージェントがタスクリストを処理します", + "approved": "承認済み", + "approved_by_user": "{{user}} が承認", + "archive_this_spec": "このスペックをアーカイブ", + "changed_since_approval": "承認後に変更あり", "clear": "クリア", "close_esc": "閉じる(Esc)", "close_review_view": "レビュービューを閉じる", @@ -4124,23 +4128,29 @@ "drafting_design": "設計を作成中…", "drafting_tasks": "タスクを作成中…", "drag_or_use_to_resize": "ドラッグまたは ← → を使用してサイズ変更", + "duplicate_this_spec": "このスペックを複製", "expand_document_for_review": "ドキュメントを展開してレビュー", "expand_for_review_esc_to_close": "レビュー用に展開(Esc で閉じる)", + "name_for_the_copy": "コピーの名前", "pause": "一時停止", "pausing": "一時停止中…", "pending_comment_other": "{{count}} 件の保留中のコメント", "remove_comment_on": "{{document}} のコメントを削除", + "rename_this_spec_label_the_folder_name_stays": "このスペックのラベルを変更します。フォルダー名とブランチ名はそのままです。", "resize_document_panel": "ドキュメントパネルのサイズ変更", + "restore_this_spec": "このスペックを復元", "review_document": "{{document}} をレビュー", "send_all_to_agent": "すべてをエージェントに送信", "sending": "送信中…", + "spec_label": "スペックのラベル", "start_building": "ビルド開始", "starting": "開始中…", "tab_design": "デザイン", "tab_requirements": "要件", "tab_tasks": "タスク", - "tells_the_agent_this_phase_is_approved_and_to_mo": "エージェントにこのフェーズが承認されたことを伝え、次に進むように指示します — チャットで入力するのと同じです", + "tells_the_agent_this_phase_is_approved_and_to_mo": "この版への承認を記録し、エージェントに次へ進むよう伝えます", "the_agent_is_writing_the_next_document": "承認済み — エージェントが次のドキュメントを作成しています", + "this_document_changed_after_it_was_approved": "このドキュメントは承認後に変更されました", "working": "処理中…" }, "specRail": { @@ -4174,6 +4184,17 @@ "turns": "ターン", "worktree": "ワークツリー" }, + "taskList": { + "done": "完了", + "done_of_total": "{{total}} 件中 {{done}} 件完了", + "no_tasks_yet": "タスクはまだありません。設計を承認すると、順序付けられたタスクリストがここに表示され、1 件ずつ実行できます。", + "pause_the_build_first": "1 件だけ実行する前にビルドを一時停止してください", + "run": "実行", + "run_only_this_task_and_stop": "このタスクだけを実行して停止します", + "run_task": "タスクを実行:{{task}}", + "starting": "開始中…", + "task_progress": "タスクの進捗" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "作成したいことを説明し、いくつかの質問に答えて、確認済みのプラン — 要件、デザイン、タスク — を取得すれば、エージェントがそれを実行できます。", "pick_a_spec_to_continue_where_you_left_off": "スペックを選択して前回の続きから開始", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 489d7e4da40..a20c08bfa13 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -4117,6 +4117,10 @@ "advance_to_design": "승인 → 설계", "advance_to_tasks": "승인 → 작업", "an_agent_will_work_through_the_task_list": "에이전트가 작업 목록을 처리합니다", + "approved": "승인됨", + "approved_by_user": "{{user}}이(가) 승인", + "archive_this_spec": "이 스펙 보관", + "changed_since_approval": "승인 후 변경됨", "clear": "지우기", "close_esc": "닫기(Esc)", "close_review_view": "검토 화면 닫기", @@ -4124,23 +4128,29 @@ "drafting_design": "설계 작성 중…", "drafting_tasks": "작업 작성 중…", "drag_or_use_to_resize": "드래그하거나 ← →로 크기 조절", + "duplicate_this_spec": "이 스펙 복제", "expand_document_for_review": "검토를 위해 문서 확대", "expand_for_review_esc_to_close": "검토를 위해 확대(Esc로 닫기)", + "name_for_the_copy": "사본 이름", "pause": "일시 중지", "pausing": "일시 중지 중…", "pending_comment_other": "보류 중인 코멘트 {{count}}개", "remove_comment_on": "{{document}}의 코멘트 삭제", + "rename_this_spec_label_the_folder_name_stays": "이 스펙의 라벨을 바꿉니다. 폴더 이름과 브랜치 이름은 그대로입니다.", "resize_document_panel": "문서 패널 크기 조절", + "restore_this_spec": "이 스펙 복원", "review_document": "{{document}} 검토", "send_all_to_agent": "전체를 에이전트에게 보내기", "sending": "보내는 중…", + "spec_label": "스펙 라벨", "start_building": "구축 시작", "starting": "시작 중…", "tab_design": "설계", "tab_requirements": "요구사항", "tab_tasks": "작업", - "tells_the_agent_this_phase_is_approved_and_to_mo": "이 단계가 승인되었고 다음으로 넘어가라고 에이전트에게 알립니다 — 채팅에 직접 입력하는 것과 같습니다", + "tells_the_agent_this_phase_is_approved_and_to_mo": "이 버전에 대한 승인을 기록한 뒤 에이전트에게 다음 단계로 넘어가라고 알립니다", "the_agent_is_writing_the_next_document": "승인됨 — 에이전트가 다음 문서를 작성하고 있습니다", + "this_document_changed_after_it_was_approved": "이 문서는 승인된 뒤에 변경되었습니다", "working": "처리 중…" }, "specRail": { @@ -4174,6 +4184,17 @@ "turns": "턴", "worktree": "워크트리" }, + "taskList": { + "done": "완료", + "done_of_total": "{{total}}개 중 {{done}}개 완료", + "no_tasks_yet": "아직 작업이 없습니다. 설계를 승인하면 순서가 정해진 작업 목록이 여기에 표시되고, 하나씩 실행할 수 있습니다.", + "pause_the_build_first": "작업 하나만 실행하려면 먼저 구축을 일시 중지하세요", + "run": "실행", + "run_only_this_task_and_stop": "이 작업만 실행한 뒤 멈춥니다", + "run_task": "작업 실행: {{task}}", + "starting": "시작 중…", + "task_progress": "작업 진행률" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "만들고 싶은 것을 설명하고 몇 가지 질문에 답하면, 에이전트가 실행할 수 있는 검토된 계획 — 요구사항·설계·작업 — 을 받습니다.", "pick_a_spec_to_continue_where_you_left_off": "스펙을 선택해 중단한 곳에서 계속하세요", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 24528022a19..299f3641567 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -4211,6 +4211,10 @@ "advance_to_design": "Aprovar → Design", "advance_to_tasks": "Aprovar → Tarefas", "an_agent_will_work_through_the_task_list": "Um agente vai percorrer a lista de tarefas", + "approved": "Aprovado", + "approved_by_user": "Aprovado por {{user}}", + "archive_this_spec": "Arquivar esta spec", + "changed_since_approval": "Mudou depois da aprovação", "clear": "Limpar", "close_esc": "Fechar (Esc)", "close_review_view": "Fechar a visualização de revisão", @@ -4218,25 +4222,31 @@ "drafting_design": "Redigindo o design…", "drafting_tasks": "Redigindo as tarefas…", "drag_or_use_to_resize": "Arraste ou use ← → para redimensionar", + "duplicate_this_spec": "Duplicar esta spec", "expand_document_for_review": "Ampliar o documento para revisão", "expand_for_review_esc_to_close": "Ampliar para revisar (Esc para fechar)", + "name_for_the_copy": "Nome da cópia", "pause": "Pausar", "pausing": "Pausando…", "pending_comment_many": "{{count}} comentários pendentes", "pending_comment_one": "{{count}} comentário pendente", "pending_comment_other": "{{count}} comentários pendentes", "remove_comment_on": "Remover o comentário sobre {{document}}", + "rename_this_spec_label_the_folder_name_stays": "Renomeie o rótulo desta spec. A pasta e o branch mantêm o mesmo nome.", "resize_document_panel": "Redimensionar o painel do documento", + "restore_this_spec": "Restaurar esta spec", "review_document": "Revisar {{document}}", "send_all_to_agent": "Enviar tudo ao agente", "sending": "Enviando…", + "spec_label": "Rótulo da spec", "start_building": "Começar a construir", "starting": "Iniciando…", "tab_design": "Design", "tab_requirements": "Requisitos", "tab_tasks": "Tarefas", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Informa ao agente que esta fase está aprovada e que ele pode seguir — o mesmo que digitar no chat", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Registra a sua aprovação desta versão e depois diz ao agente para seguir adiante", "the_agent_is_writing_the_next_document": "Aprovado — o agente está escrevendo o próximo documento", + "this_document_changed_after_it_was_approved": "Este documento mudou depois de ser aprovado", "working": "trabalhando…" }, "specRail": { @@ -4270,6 +4280,17 @@ "turns": "Turnos", "worktree": "Worktree" }, + "taskList": { + "done": "Concluída", + "done_of_total": "{{done}} de {{total}} tarefas concluídas", + "no_tasks_yet": "Ainda não há tarefas. Depois que o design for aprovado, a lista ordenada de tarefas aparece aqui e você pode executá-las uma a uma.", + "pause_the_build_first": "Pause a construção antes de executar uma única tarefa", + "run": "Executar", + "run_only_this_task_and_stop": "Executar apenas esta tarefa e depois parar", + "run_task": "Executar a tarefa: {{task}}", + "starting": "Iniciando…", + "task_progress": "Progresso das tarefas" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Descreva o que você quer construir, responda a algumas perguntas e receba um plano revisado — requisitos, design e tarefas — que um agente pode executar.", "pick_a_spec_to_continue_where_you_left_off": "Escolha uma spec para continuar de onde parou", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index 9e00bac8ee5..9bab6f06f81 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -4274,6 +4274,10 @@ "advance_to_design": "Согласовать → Дизайн", "advance_to_tasks": "Согласовать → Задачи", "an_agent_will_work_through_the_task_list": "Агент пройдёт по списку задач", + "approved": "Согласовано", + "approved_by_user": "Согласовал {{user}}", + "archive_this_spec": "Архивировать эту спеку", + "changed_since_approval": "Изменено после согласования", "clear": "Очистить", "close_esc": "Закрыть (Esc)", "close_review_view": "Закрыть режим просмотра", @@ -4281,8 +4285,10 @@ "drafting_design": "Составление дизайна…", "drafting_tasks": "Составление задач…", "drag_or_use_to_resize": "Потяните или используйте ← →, чтобы изменить размер", + "duplicate_this_spec": "Дублировать эту спеку", "expand_document_for_review": "Развернуть документ для проверки", "expand_for_review_esc_to_close": "Развернуть для проверки (Esc закрывает)", + "name_for_the_copy": "Название копии", "pause": "Пауза", "pausing": "Приостановка…", "pending_comment_few": "{{count}} неотправленных комментария", @@ -4290,17 +4296,21 @@ "pending_comment_one": "{{count}} неотправленный комментарий", "pending_comment_other": "{{count}} неотправленных комментария", "remove_comment_on": "Удалить комментарий к {{document}}", + "rename_this_spec_label_the_folder_name_stays": "Измените подпись этой спеки. Имя папки и ветки останутся теми же.", "resize_document_panel": "Изменить размер панели документа", + "restore_this_spec": "Восстановить эту спеку", "review_document": "Проверить {{document}}", "send_all_to_agent": "Отправить всё агенту", "sending": "Отправка…", + "spec_label": "Подпись спеки", "start_building": "Начать сборку", "starting": "Запуск…", "tab_design": "Дизайн", "tab_requirements": "Требования", "tab_tasks": "Задачи", - "tells_the_agent_this_phase_is_approved_and_to_mo": "Сообщает агенту, что этот этап согласован и можно двигаться дальше — то же, что написать это в чате", + "tells_the_agent_this_phase_is_approved_and_to_mo": "Записывает ваше согласование этой версии, а затем говорит агенту двигаться дальше", "the_agent_is_writing_the_next_document": "Одобрено — агент пишет следующий документ", + "this_document_changed_after_it_was_approved": "Этот документ изменился после согласования", "working": "работает…" }, "specRail": { @@ -4334,6 +4344,17 @@ "turns": "Циклы", "worktree": "Рабочее дерево" }, + "taskList": { + "done": "Готово", + "done_of_total": "Готово {{done}} из {{total}} задач", + "no_tasks_yet": "Задач пока нет. После согласования дизайна здесь появится упорядоченный список задач, и вы сможете запускать их по одной.", + "pause_the_build_first": "Поставьте сборку на паузу, прежде чем запускать отдельную задачу", + "run": "Запустить", + "run_only_this_task_and_stop": "Запустить только эту задачу и остановиться", + "run_task": "Запустить задачу: {{task}}", + "starting": "Запуск…", + "task_progress": "Прогресс по задачам" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "Опишите, что вы хотите создать, ответьте на несколько вопросов и получите проверенный план — требования, дизайн и задачи — который сможет выполнить агент.", "pick_a_spec_to_continue_where_you_left_off": "Выберите спеку, чтобы продолжить с того места, где остановились", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 44202bd09e8..d121386a864 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -4085,6 +4085,10 @@ "advance_to_design": "通过 → 设计", "advance_to_tasks": "通过 → 任务", "an_agent_will_work_through_the_task_list": "代理将逐项完成任务清单", + "approved": "已批准", + "approved_by_user": "由 {{user}} 批准", + "archive_this_spec": "归档此 spec", + "changed_since_approval": "批准后已变更", "clear": "清空", "close_esc": "关闭(Esc)", "close_review_view": "关闭审阅视图", @@ -4092,23 +4096,29 @@ "drafting_design": "正在撰写设计…", "drafting_tasks": "正在撰写任务…", "drag_or_use_to_resize": "拖动或使用 ← → 调整大小", + "duplicate_this_spec": "复制此 spec", "expand_document_for_review": "展开文档以便审阅", "expand_for_review_esc_to_close": "展开以便审阅(按 Esc 关闭)", + "name_for_the_copy": "副本的名称", "pause": "暂停", "pausing": "正在暂停…", "pending_comment_other": "{{count}} 条待处理评论", "remove_comment_on": "移除对 {{document}} 的评论", + "rename_this_spec_label_the_folder_name_stays": "重命名此 spec 的标签。它的文件夹名和分支名保持不变。", "resize_document_panel": "调整文档面板大小", + "restore_this_spec": "恢复此 spec", "review_document": "审阅 {{document}}", "send_all_to_agent": "全部发送给代理", "sending": "正在发送…", + "spec_label": "Spec 标签", "start_building": "开始构建", "starting": "正在启动…", "tab_design": "设计", "tab_requirements": "需求", "tab_tasks": "任务", - "tells_the_agent_this_phase_is_approved_and_to_mo": "告知代理此阶段已通过、可以继续——与在聊天中输入相同", + "tells_the_agent_this_phase_is_approved_and_to_mo": "记录你对此版本的批准,然后让代理继续下一步", "the_agent_is_writing_the_next_document": "已批准 — 代理正在撰写下一个文档", + "this_document_changed_after_it_was_approved": "此文档在批准之后发生了变更", "working": "正在处理…" }, "specRail": { @@ -4142,6 +4152,17 @@ "turns": "轮次", "worktree": "工作树" }, + "taskList": { + "done": "已完成", + "done_of_total": "已完成 {{done}} / {{total}} 个任务", + "no_tasks_yet": "还没有任务。设计通过之后,排好顺序的任务清单会出现在这里,你可以逐个运行任务。", + "pause_the_build_first": "运行单个任务前请先暂停构建", + "run": "运行", + "run_only_this_task_and_stop": "只运行此任务,然后停止", + "run_task": "运行任务:{{task}}", + "starting": "正在启动…", + "task_progress": "任务进度" + }, "workspace": { "describe_what_you_want_to_build_answer_a_few_que": "描述你想构建的内容,回答几个问题,然后获得一份经过审阅的方案——需求、设计和任务——供代理执行。", "pick_a_spec_to_continue_where_you_left_off": "选择一个 spec,从上次中断处继续", diff --git a/website/src/test/App.test.tsx b/website/src/test/App.test.tsx index 1a30c7b331f..266bb7ebba6 100644 --- a/website/src/test/App.test.tsx +++ b/website/src/test/App.test.tsx @@ -143,6 +143,25 @@ describe('App routing', () => { onboarded: false, import_onboarded: false, } as never) + // Keep the import chapter open after its scan. An empty scan deliberately + // auto-completes the chapter, so asserting on the transient dialog races + // that completion under a loaded test shard. + vi.mocked(api.onboardingImportScan).mockResolvedValueOnce({ + sources: [{ + id: 'codex', + name: 'Codex', + detected: true, + detail: '~/.codex', + categories: [{ + id: 'instructions', + label: 'Instructions', + count: 1, + description: 'Agent instructions', + }], + }], + skipped: [], + merge_only: true, + } as never) renderWithProviders(, { route: '/chat' }) diff --git a/website/src/test/CliPanelCoverage.test.tsx b/website/src/test/CliPanelCoverage.test.tsx index 9224c8f299a..55770d93e0f 100644 --- a/website/src/test/CliPanelCoverage.test.tsx +++ b/website/src/test/CliPanelCoverage.test.tsx @@ -103,6 +103,7 @@ const touch = vi.hoisted(() => ({ value: false })) vi.mock('../hooks/useIsTouchDevice', () => ({ useIsTouchDevice: () => touch.value })) import CliPanel, { + __resetTerminalThemeSyncForTests, disposeTerminalSession, isThemeSignal, useDeleteTerminalSession, @@ -194,12 +195,14 @@ beforeEach(() => { afterEach(() => { cleanup() + __resetTerminalThemeSyncForTests() for (const id of live) disposeTerminalSession(id) live.clear() restoreLayout() __resetTerminalFontStore() vi.unstubAllGlobals() document.documentElement.removeAttribute('data-theme') + document.documentElement.style.removeProperty('--accent') for (const s of Array.from(document.head.querySelectorAll('style'))) { if (s.id.startsWith('mc-custom-theme-') || s.id === 'unrelated-style') s.remove() } diff --git a/website/src/test/SpecBuilderApiAbort.test.tsx b/website/src/test/SpecBuilderApiAbort.test.tsx new file mode 100644 index 00000000000..d4eec377593 --- /dev/null +++ b/website/src/test/SpecBuilderApiAbort.test.tsx @@ -0,0 +1,96 @@ +// Reads must be cancellable and writes must not be. Switching specs while a poll +// is in flight otherwise lets the older response resolve last and overwrite the +// newer one, so every read takes react-query's AbortSignal. Writes deliberately +// take none: the request has already reached the server by the time a component +// unmounts, and cancelling the client side would hide the outcome of a mutation +// that still lands. +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import SettingsModal from '../apps/spec-builder/components/SettingsModal' +import { specApi } from '../apps/spec-builder/api' + +/** Captured RequestInit of the last fetch, so the signal can be inspected. */ +function stubFetch() { + const spy = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => '{}', + json: async () => ({}), + })) + vi.stubGlobal('fetch', spy as unknown as typeof fetch) + return spy +} + +const lastInit = (spy: ReturnType): RequestInit => + (spy.mock.calls[spy.mock.calls.length - 1] as unknown as [string, RequestInit])[1] + +describe('spec-builder api cancellation', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('threads an AbortSignal into every read', async () => { + const spy = stubFetch() + const ac = new AbortController() + + await specApi.list(ac.signal) + expect(lastInit(spy).signal).toBe(ac.signal) + + await specApi.get('login', ac.signal) + expect(lastInit(spy).signal).toBe(ac.signal) + + await specApi.getSettings(ac.signal) + expect(lastInit(spy).signal).toBe(ac.signal) + + await specApi.browse('/srv', ac.signal) + expect(lastInit(spy).signal).toBe(ac.signal) + }) + + it('leaves reads uncancelled when no signal is supplied', async () => { + const spy = stubFetch() + await specApi.list() + expect(lastInit(spy).signal).toBeUndefined() + }) + + it('does not make writes cancellable', async () => { + const spy = stubFetch() + + await specApi.message('login', 'looks good') + expect(lastInit(spy).signal).toBeUndefined() + + await specApi.execute('login') + expect(lastInit(spy).signal).toBeUndefined() + + await specApi.stop('login') + expect(lastInit(spy).signal).toBeUndefined() + + await specApi.remove('login') + expect(lastInit(spy).signal).toBeUndefined() + + await specApi.saveSettings('/srv/specs', '') + expect(lastInit(spy).signal).toBeUndefined() + }) + + it('passes the query signal down from a mounted queryFn', async () => { + // Proves the wiring, not just the api surface: a queryFn that ignores its + // context argument type-checks fine and silently leaves the fetch + // uncancellable, so the assertion has to come from a real mount. + const getSettings = vi + .spyOn(specApi, 'getSettings') + .mockResolvedValue({ base_path: '/srv/specs' }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} setErr={() => {}} /> + , + ) + + await waitFor(() => expect(getSettings).toHaveBeenCalled()) + expect(getSettings.mock.calls[0][0]).toBeInstanceOf(AbortSignal) + }) +}) diff --git a/website/src/test/SpecBuilderPage.test.tsx b/website/src/test/SpecBuilderPage.test.tsx index 3e337b52105..87c21e7c900 100644 --- a/website/src/test/SpecBuilderPage.test.tsx +++ b/website/src/test/SpecBuilderPage.test.tsx @@ -111,7 +111,8 @@ describe('SpecBuilder accessibility contract', () => { const SPECS = [{ name: ' function stubSpecs() { vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { - const body = String(url).includes('/specs/') + const requestUrl = String(url) + const body = requestUrl.includes('/specs/') ? { name: 'my-spec', phase: 'requirements', status: 'idle', running: false, working_dir: '/tmp/p', files: {} } : { specs: SPECS } return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)) }) diff --git a/website/src/test/SpecBuilderPageCov80.test.tsx b/website/src/test/SpecBuilderPageCov80.test.tsx index e9b3c65538d..b3541db4177 100644 --- a/website/src/test/SpecBuilderPageCov80.test.tsx +++ b/website/src/test/SpecBuilderPageCov80.test.tsx @@ -11,7 +11,13 @@ const list = vi.fn() vi.mock('../apps/spec-builder/api', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, specApi: { ...actual.specApi, list: () => list() } } + return { + ...actual, + specApi: { + ...actual.specApi, + list: (signal?: AbortSignal) => list(signal), + }, + } }) interface WorkspaceStub { @@ -102,6 +108,21 @@ describe('SpecBuilderPage selection', () => { expect(screen.getByTestId('sel')).toHaveTextContent('zz-one') }) + it('loads archived specs so a restored selection remains recoverable', async () => { + localStorage.setItem(LS.lastOpen, 'zz-archived') + list.mockResolvedValue({ + specs: [ + { name: 'zz-one', phase: 'requirements' }, + { name: 'zz-archived', phase: 'tasks', archived: true }, + ], + }) + renderPage() + await workspace() + await waitFor(() => expect(screen.getByTestId('names')).toHaveTextContent('zz-one,zz-archived')) + expect(screen.getByTestId('sel')).toHaveTextContent('zz-archived') + expect(list).toHaveBeenCalledWith(expect.any(AbortSignal)) + }) + it('forgets the selection when it is cleared', async () => { localStorage.setItem(LS.lastOpen, 'zz-one') renderPage() @@ -160,11 +181,7 @@ describe('SpecBuilderPage view swaps', () => { expect(await workspace()).toBeInTheDocument() }) - // NOTE: creating a spec should also SELECT it, but the stale-selection guard - // races the list invalidation and drops `sel` before the refetch lands (see - // the bug reported alongside this suite). That is deliberately NOT asserted - // here in either direction — only the list refresh, which is correct today. - it('refreshes the specs list after one is created', async () => { + it('refreshes the specs list and selects the spec after one is created', async () => { renderPage() await workspace() await waitFor(() => expect(screen.getByTestId('names')).toHaveTextContent('zz-one')) @@ -173,6 +190,7 @@ describe('SpecBuilderPage view swaps', () => { fireEvent.click(screen.getByText('zz-finish-new')) expect(screen.queryByTestId('new-spec-view')).not.toBeInTheDocument() await waitFor(() => expect(screen.getByTestId('names')).toHaveTextContent('zz-one,zz-two')) + expect(screen.getByTestId('sel')).toHaveTextContent('zz-two') }) it('opens and closes the settings modal from the workspace', async () => { diff --git a/website/src/test/SpecBuilderRailHorizontal.test.tsx b/website/src/test/SpecBuilderRailHorizontal.test.tsx index 64d3371dca1..e30d481f1ab 100644 --- a/website/src/test/SpecBuilderRailHorizontal.test.tsx +++ b/website/src/test/SpecBuilderRailHorizontal.test.tsx @@ -6,7 +6,7 @@ // does not violate max-two-buttons-per-row), and Settings therefore stays in the // expanded rail rather than riding along. import { describe, it, expect, vi } from 'vitest' -import { render, cleanup, fireEvent } from '@testing-library/react' +import { render, cleanup, fireEvent, screen } from '@testing-library/react' import SpecRail from '../apps/spec-builder/components/SpecRail' import type { SpecSummary } from '../apps/spec-builder/api' @@ -88,3 +88,24 @@ describe('Spec Builder collapsed rail, horizontal orientation', () => { cleanup() }) }) + +describe('Spec Builder expanded rail', () => { + it('keeps archived specs selectable and shows their title', () => { + const setSel = vi.fn() + renderBar({ + collapsed: false, + horizontal: false, + width: 260, + setSel, + specs: [ + spec(), + spec({ name: 'archived-dark-mode', title: 'Archived dark mode', archived: true }), + ], + }) + + expect(screen.getByText('Archived')).toBeInTheDocument() + fireEvent.click(screen.getByText('Archived dark mode')) + expect(setSel).toHaveBeenCalledWith('archived-dark-mode') + cleanup() + }) +}) diff --git a/website/src/test/SpecBuilderSpecDetailCoverage.test.tsx b/website/src/test/SpecBuilderSpecDetailCoverage.test.tsx index 111f2312fa8..13bcbe01884 100644 --- a/website/src/test/SpecBuilderSpecDetailCoverage.test.tsx +++ b/website/src/test/SpecBuilderSpecDetailCoverage.test.tsx @@ -7,8 +7,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import React from 'react' import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +let mobile = false + +vi.mock('../hooks/useIsMobile', () => ({ useIsMobile: () => mobile })) + // The embedded chat, the document renderer and the state panel are all covered // by their own tests; stubbing them keeps this file's assertions about // SpecDetail's own behaviour. The DocView stub exposes the selection-to-comment @@ -77,6 +82,8 @@ const BASE = { spec_dir: '/proj/checkout/.kiro/specs/checkout', slot_key: 'spec-builder-checkout-99', files: { 'requirements.md': '# r' }, + docs: { 'requirements.md': { hash: 'a'.repeat(64) } }, + duplicate_supported: true, } const okRes = (text: string) => ({ ok: true, status: 200, text: () => Promise.resolve(text) }) @@ -99,10 +106,14 @@ function installFetch( })) } -function renderDetail(name = 'checkout', setErr: (m: string) => void = () => {}) { +function renderDetail( + name = 'checkout', + setErr: (m: string) => void = () => {}, + onDuplicated?: (name: string) => void, +) { return render( - + , ) } @@ -122,6 +133,7 @@ beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) calls = [] localStorage.clear() + mobile = false // The pulsing dots and the poll both schedule work; without fake timers a // callback can fire after teardown and throw as an unhandled error. vi.useFakeTimers({ shouldAdvanceTime: true }) @@ -164,6 +176,105 @@ describe('SpecDetail header', () => { expect(screen.queryByRole('button', { name: /Approve/ })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /Start building/i })).not.toBeInTheDocument() }) + + it('puts duplicate and archive in one overflow menu on mobile', async () => { + mobile = true + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + installFetch(BASE) + renderDetail() + + const menu = await screen.findByRole('button', { name: /more actions/i }) + expect(screen.queryByRole('button', { name: /duplicate this spec/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /archive this spec/i })).not.toBeInTheDocument() + await user.click(menu) + expect(await screen.findByRole('menuitem', { name: /duplicate this spec/i })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /archive this spec/i })).toBeInTheDocument() + }) + + it('does not offer duplicate when the backend cannot publish it safely', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + installFetch({ ...BASE, duplicate_supported: false }) + renderDetail() + + await user.click(await screen.findByRole('button', { name: /more actions/i })) + + expect(screen.queryByRole('menuitem', { name: /duplicate this spec/i })).not.toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /archive this spec/i })).toBeInTheDocument() + }) + + it('moves the duplicate form below the fixed header at every pane width', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + installFetch(BASE) + renderDetail() + + await user.click(await screen.findByRole('button', { name: /more actions/i })) + await user.click(await screen.findByRole('menuitem', { name: /duplicate this spec/i })) + + const form = await screen.findByTestId('duplicate-form') + const input = screen.getByRole('textbox', { name: /name for the copy/i }) + expect(form).toHaveClass('flex-col') + expect(form).toContainElement(input) + expect(input.closest('header')).toBeNull() + }) + + it('moves the title editor below the fixed header at every pane width', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + installFetch(BASE) + renderDetail() + + await user.click(await screen.findByRole('button', { name: 'checkout' })) + + const form = await screen.findByTestId('title-form') + const input = screen.getByRole('textbox', { name: /spec label/i }) + expect(form).toHaveClass('flex-col') + expect(form).toContainElement(input) + expect(input.closest('header')).toBeNull() + }) + + it('keeps the mobile title static and puts rename in the overflow menu', async () => { + mobile = true + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + installFetch(BASE) + renderDetail() + + await screen.findByText('checkout') + expect(screen.queryByRole('button', { name: 'checkout' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /more actions/i })) + expect(await screen.findByRole('menuitem', { name: /rename/i })).toBeInTheDocument() + }) + + it('archives from the overflow menu', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + installFetch(BASE) + renderDetail() + + await user.click(await screen.findByRole('button', { name: /more actions/i })) + await user.click(await screen.findByRole('menuitem', { name: /archive this spec/i })) + + await waitFor(() => expect(calls.some((c) => c.url.includes('/archive'))).toBe(true)) + const request = calls.find((c) => c.url.includes('/archive')) + expect(JSON.parse(request?.body || '{}').archived).toBe(true) + }) + + it('names a duplicate inline and selects the completed copy', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const onDuplicated = vi.fn() + installFetch(BASE, (url) => (url.includes('/duplicate') + ? Promise.resolve(okRes('{"name":"checkout-v2"}')) + : undefined)) + renderDetail('checkout', () => {}, onDuplicated) + + await user.click(await screen.findByRole('button', { name: /more actions/i })) + await user.click(await screen.findByRole('menuitem', { name: /duplicate this spec/i })) + const input = await screen.findByRole('textbox', { name: /name for the copy/i }) + fireEvent.change(input, { target: { value: 'checkout-v2' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(calls.some((c) => c.url.includes('/duplicate'))).toBe(true)) + const request = calls.find((c) => c.url.includes('/duplicate')) + expect(JSON.parse(request?.body || '{}').new_name).toBe('checkout-v2') + await waitFor(() => expect(onDuplicated).toHaveBeenCalledWith('checkout-v2')) + }) }) describe('SpecDetail docs split', () => { @@ -269,6 +380,16 @@ describe('SpecDetail review overlay', () => { }) describe('SpecDetail phase actions', () => { + it('refuses to advance when the reviewed document has no trustworthy hash', async () => { + installFetch({ ...BASE, docs: {} }) + renderDetail() + + const approve = await screen.findByRole('button', { name: /Approve → Design/ }) + expect(approve).toBeDisabled() + fireEvent.click(approve) + expect(calls.filter((c) => c.url.includes('/message'))).toHaveLength(0) + }) + it('sends the phase-approval instruction and locks the button while in flight', async () => { let release: (() => void) | undefined installFetch(BASE, (url) => (url.includes('/message') @@ -314,7 +435,12 @@ describe('SpecDetail phase actions', () => { // Once design.md lands the backend reports the new phase, and the control // becomes the next approval rather than staying stuck on "drafting". - detail = { ...BASE, phase: 'design', files: { 'requirements.md': '# r', 'design.md': '# d' } } + detail = { + ...BASE, + phase: 'design', + files: { 'requirements.md': '# r', 'design.md': '# d' }, + docs: { ...BASE.docs, 'design.md': { hash: 'b'.repeat(64) } }, + } await waitFor( () => expect(screen.getByRole('button', { name: /Approve → Tasks/ })).toBeEnabled(), { timeout: 4000 }, @@ -362,12 +488,33 @@ describe('SpecDetail phase actions', () => { }) it('offers the tasks approval on the design phase', async () => { - installFetch({ ...BASE, phase: 'design', files: { 'requirements.md': '# r', 'design.md': '# d' } }) + installFetch({ + ...BASE, + phase: 'design', + files: { 'requirements.md': '# r', 'design.md': '# d' }, + docs: { ...BASE.docs, 'design.md': { hash: 'b'.repeat(64) } }, + }) renderDetail() - fireEvent.click(await screen.findByRole('button', { name: /Approve → Tasks/ })) + const approve = await screen.findByRole('button', { name: /Approve → Tasks/ }) + expect(screen.getByTestId('doc-view')).toHaveAttribute('data-tab', 'requirements') + expect(approve).toBeDisabled() + fireEvent.click(approve) + expect(calls.filter((c) => c.url.includes('/approve'))).toHaveLength(0) + expect(calls.filter((c) => c.url.includes('/message'))).toHaveLength(0) + + await selectTab('Design') + expect(screen.getByTestId('doc-view')).toHaveAttribute('data-tab', 'design') + expect(approve).toBeEnabled() + fireEvent.click(approve) await waitFor(() => expect(calls.filter((c) => c.url.includes('/message'))).toHaveLength(1)) - expect(JSON.parse(calls[0].body).text).toContain('Design approved') + const approval = calls.find((c) => c.url.includes('/approve')) + expect(JSON.parse(approval?.body || '{}')).toMatchObject({ + phase: 'design', + hash: 'b'.repeat(64), + }) + const message = calls.find((c) => c.url.includes('/message')) + expect(JSON.parse(message?.body || '{}').text).toContain('Design approved') }) it('offers no approval for a phase the table does not know', async () => { diff --git a/website/src/test/SpecBuilderTaskList.test.tsx b/website/src/test/SpecBuilderTaskList.test.tsx new file mode 100644 index 00000000000..aee093fa331 --- /dev/null +++ b/website/src/test/SpecBuilderTaskList.test.tsx @@ -0,0 +1,110 @@ +// The task list: tasks.md as addressable work rather than static markdown. +// +// The app could previously only hand the WHOLE list to an autonudge loop — no way +// to run one task, no way to see which task a run was on, and no progress anywhere. +// The two behaviours worth pinning are that a run carries the task's HASH (so a +// list that moved under the user is refused server-side rather than dispatching +// whatever ended up at that index), and that the controls go quiet while a build +// owns the list. +import { describe, it, expect, vi } from 'vitest' +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import TaskList from '../apps/spec-builder/components/TaskList' +import DocView from '../apps/spec-builder/components/DocView' +import type { SpecDetail, SpecTask } from '../apps/spec-builder/api' + +const tasks: SpecTask[] = [ + { index: 0, text: 'wire the endpoint', done: true, hash: 'a'.repeat(64) }, + { index: 1, text: 'add the tests', done: false, hash: 'b'.repeat(64) }, + { index: 2, text: 'update the docs', done: false, hash: 'c'.repeat(64) }, +] + +describe('TaskList', () => { + it('shows derived progress and offers a run only for open tasks', () => { + const onRun = vi.fn() + render( + , + ) + + expect(screen.getByText(/1 of 3 tasks done/i)).toBeTruthy() + const bar = screen.getByRole('progressbar', { name: /task progress/i }) + expect(bar.getAttribute('aria-valuenow')).toBe('1') + expect(bar.getAttribute('aria-valuemax')).toBe('3') + // Two open tasks, so two run controls; the finished one shows a state instead. + expect(screen.getAllByRole('button', { name: /^run task:/i })).toHaveLength(2) + }) + + it('hands the run the task identity, not just its position', () => { + const onRun = vi.fn() + render() + + act(() => { fireEvent.click(screen.getByRole('button', { name: /run task: add the tests/i })) }) + + expect(onRun).toHaveBeenCalledTimes(1) + // The hash travels with it: the agent rewrites tasks.md between polls, so the + // index alone could name a different task by the time the request lands. + expect(onRun.mock.calls[0][0]).toMatchObject({ index: 1, hash: 'b'.repeat(64) }) + }) + + it('disables every run while a build owns the whole list', () => { + // The backend refuses a single-task run during a whole-list build, because both + // write the same files and check off the same boxes. The control explains that + // instead of failing on click. + render() + for (const btn of screen.getAllByRole('button', { name: /^run task:/i })) { + expect(btn).toBeDisabled() + } + }) + + it('disables the other runs while one is being dispatched', () => { + render() + for (const btn of screen.getAllByRole('button', { name: /^run task:/i })) { + expect(btn).toBeDisabled() + } + }) + + it('says the list is still coming rather than showing an empty grid', () => { + render() + expect(screen.getByText(/no tasks yet/i)).toBeTruthy() + }) +}) + +describe('DocView tasks tab', () => { + const detail = { + name: 'thing', + spec_dir: '/w/.kiro/specs/thing', + phase: 'tasks', + running: false, + files: { + 'requirements.md': null, + 'design.md': null, + 'tasks.md': '# Delivery notes\nKeep this context visible.\n\n- [ ] add the tests\n', + }, + docs: { 'tasks.md': { hash: 'd'.repeat(64) } }, + tasks: [{ index: 0, text: 'add the tests', done: false, hash: 'b'.repeat(64) }], + task_progress: { done: 0, total: 1 }, + state: null, + context: {}, + } as unknown as SpecDetail + + it('renders the checklist as work when a run handler is wired', () => { + render() + expect(screen.getByRole('button', { name: /run task: add the tests/i })).toBeTruthy() + }) + + it('keeps non-checklist task document content reachable', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'tasks.md' })) + + expect(screen.getByText('Delivery notes')).toBeTruthy() + expect(screen.getByText('Keep this context visible.')).toBeTruthy() + }) + +}) diff --git a/website/src/test/SpecBuilderWriteApi.test.tsx b/website/src/test/SpecBuilderWriteApi.test.tsx new file mode 100644 index 00000000000..0560d733ebe --- /dev/null +++ b/website/src/test/SpecBuilderWriteApi.test.tsx @@ -0,0 +1,78 @@ +// The write endpoints' wire contract. Each new mutation must carry the identity +// the CLIENT rendered (spec_dir + the per-creation slot_key) so the backend can +// refuse a stale tab, and a failure must carry the backend's machine-readable +// `code` — matching on translated prose to recognise a conflict would work in +// exactly one locale. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { specApi, SpecApiError } from '../apps/spec-builder/api' + +const API = '/api/apps/spec-builder' +const ID = { spec_dir: '/w/.kiro/specs/thing', slot_key: 'spec-builder-thing-deadbeef' } + +function mockFetch(status = 200, payload: unknown = { ok: true }) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(payload), + json: async () => payload, + } as unknown as Response) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +const bodyOf = (m: ReturnType) => JSON.parse(m.mock.calls[0][1].body as string) + +describe('spec write API', () => { + beforeEach(() => { vi.restoreAllMocks() }) + afterEach(() => { vi.unstubAllGlobals() }) + + it('sends the approved phase together with the hash reviewed', async () => { + const f = mockFetch() + await specApi.approve('thing', 'requirements', 'c'.repeat(64), ID) + expect(f.mock.calls[0][0]).toBe(API + '/specs/thing/approve') + expect(bodyOf(f)).toEqual({ phase: 'requirements', hash: 'c'.repeat(64), ...ID }) + }) + + it('sends both the task index and its text hash', async () => { + const f = mockFetch() + await specApi.runTask('thing', 2, 'd'.repeat(64), ID) + expect(f.mock.calls[0][0]).toBe(API + '/specs/thing/task') + expect(bodyOf(f)).toEqual({ index: 2, hash: 'd'.repeat(64), ...ID }) + }) + + it('sends the label, the archive flag and the copy name', async () => { + const t = mockFetch() + await specApi.setTitle('thing', 'Checkout rewrite', ID) + expect(bodyOf(t)).toEqual({ title: 'Checkout rewrite', ...ID }) + vi.unstubAllGlobals() + + const a = mockFetch() + await specApi.setArchived('thing', true, ID) + expect(bodyOf(a)).toEqual({ archived: true, ...ID }) + vi.unstubAllGlobals() + + const d = mockFetch(201, { name: 'thing-copy' }) + await specApi.duplicate('thing', 'thing-copy', ID) + expect(bodyOf(d)).toEqual({ new_name: 'thing-copy', ...ID }) + }) + + it('omits an identity field the client does not have rather than claiming ""', async () => { + // An older tab predates these fields, and the backend treats "" as a CLAIM. + const f = mockFetch() + await specApi.setTitle('thing', 'x') + expect(bodyOf(f)).toEqual({ title: 'x' }) + }) + + it('still throws a typed error when the body carries no code', async () => { + mockFetch(500, { error: 'boom' }) + const err = await specApi.setArchived('thing', true, ID).catch((e) => e) + expect(err).toBeInstanceOf(SpecApiError) + expect(err.code).toBe('') + }) + + it('fetches active and archived specs in one request', async () => { + const plain = mockFetch(200, { specs: [{ name: 'old', archived: true }] }) + await specApi.list() + expect(plain.mock.calls[0][0]).toBe(API + '/specs') + }) +}) diff --git a/website/src/test/opsMissionControl.test.ts b/website/src/test/opsMissionControl.test.ts index 689e51917a0..9a44e57dbdb 100644 --- a/website/src/test/opsMissionControl.test.ts +++ b/website/src/test/opsMissionControl.test.ts @@ -410,7 +410,6 @@ describe('a parked signal is visibly parked, not silently gone', () => { it('uses a Lucide icon and no emoji', () => { expect(panel).toContain('BellOff') - // eslint-disable-next-line no-control-regex expect(panel).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u) }) }) @@ -848,7 +847,6 @@ describe('the Board renders the artifact a colleague gets handed', () => { it('uses a Lucide icon for the section, never an emoji', () => { expect(page).toMatch(/FileText className="lucide-inline"/) - // eslint-disable-next-line no-control-regex expect(page).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u) }) @@ -941,7 +939,6 @@ describe('Settings surfaces the notification channels and their on/off', () => { it('uses Lucide icons for the card and every channel, never an emoji', () => { expect(panel).toMatch(/BellRing className="lucide-inline"/) expect(panel).toMatch(/UserCheck className="lucide-inline"/) - // eslint-disable-next-line no-control-regex expect(panel).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u) })