Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6630bf9d9b | ||
|
|
29c898fe62 |
@@ -108,13 +108,11 @@ export class MissionsController {
|
|||||||
) {
|
) {
|
||||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
if (!mission) throw new NotFoundException('Mission not found');
|
||||||
// dto.status is deliberately not forwarded: mission_tasks.status is
|
|
||||||
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
|
|
||||||
// the repo strips it as well.
|
|
||||||
return this.brain.missionTasks.create({
|
return this.brain.missionTasks.create({
|
||||||
missionId,
|
missionId,
|
||||||
taskId: dto.taskId,
|
taskId: dto.taskId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
status: dto.status,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
pr: dto.pr,
|
pr: dto.pr,
|
||||||
|
|||||||
@@ -77,12 +77,6 @@ export class CreateMissionTaskDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
|
||||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
|
||||||
* The field stays declared because the global ValidationPipe runs with
|
|
||||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
|
||||||
*/
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(taskStatuses)
|
@IsIn(taskStatuses)
|
||||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
@@ -108,12 +102,6 @@ export class UpdateMissionTaskDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
|
||||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
|
||||||
* The field stays declared because the global ValidationPipe runs with
|
|
||||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
|
||||||
*/
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(taskStatuses)
|
@IsIn(taskStatuses)
|
||||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
|||||||
+2
-19
@@ -197,26 +197,10 @@ conflict must amend one of them explicitly, never fork a third document
|
|||||||
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
|
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
|
||||||
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
|
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
|
||||||
|
|
||||||
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
|
### 12. Decision registry
|
||||||
|
|
||||||
The stack ships a tiered deployment target, additive to the architecture
|
|
||||||
gate (D8): (1) Standalone tier — docker compose is the canonical
|
|
||||||
single-host deployment: postgres, valkey, openbao, gateway, appservice
|
|
||||||
and the served webUI in one composition, with migrations, health checks,
|
|
||||||
and a documented install/upgrade path; the registry (CI-published
|
|
||||||
images) is the only deployment source. (2) Enterprise tier — Kubernetes
|
|
||||||
manifests for the same service set, phase-gated on the standalone tier
|
|
||||||
holding its acceptance bar. The v1 acceptance bar for the standalone
|
|
||||||
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
|
|
||||||
open a PR to this repo; CI validates it; the running deployment adopts
|
|
||||||
the merged change (pull + restart). Federation (D3 clause) remains
|
|
||||||
deferred and unforeclosed. Implementation plan:
|
|
||||||
docs/plans/2026-08-30_containerization.md.
|
|
||||||
|
|
||||||
## 12. Decision registry
|
|
||||||
|
|
||||||
| ID | Decision (short form) |
|
| ID | Decision (short form) |
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
| --- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||||
@@ -231,7 +215,6 @@ docs/plans/2026-08-30_containerization.md.
|
|||||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||||
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
|
|
||||||
|
|
||||||
The full decision texts are recorded in the operator decision log (USC estate
|
The full decision texts are recorded in the operator decision log (USC estate
|
||||||
brain, webui-audit lane, `GRILL.md`).
|
brain, webui-audit lane, `GRILL.md`).
|
||||||
|
|||||||
@@ -73,22 +73,12 @@ SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
|
|||||||
`mission_tasks.status` as a write source, plus a writer inventory, before any
|
`mission_tasks.status` as a write source, plus a writer inventory, before any
|
||||||
expand DDL.
|
expand DDL.
|
||||||
|
|
||||||
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
|
- **Sole status write path:** `packages/brain/src/mission-tasks.ts`
|
||||||
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
||||||
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
||||||
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
||||||
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
||||||
immaterial to `status` writes, listed for inventory completeness.
|
immaterial to `status` writes, listed for inventory completeness.
|
||||||
- **Storage-layer surfaces that touch the column without authoring it**
|
|
||||||
(added 2026-08-29 after independent review of the phase-1 patch):
|
|
||||||
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
|
|
||||||
between storage tiers and must preserve the stored `status` verbatim — row
|
|
||||||
transport, exempt from the write prohibition (stripping there would corrupt
|
|
||||||
data inside the N-1 window). The generic table-keyed storage adapters
|
|
||||||
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
|
|
||||||
their table maps but have no caller that targets it: measured at this head,
|
|
||||||
every runtime adapter caller passes a fixed collection constant
|
|
||||||
(preferences/insights). Neither surface authors a new `status` value.
|
|
||||||
- **Read-only consumers of `mission_tasks`:** federation verb services
|
- **Read-only consumers of `mission_tasks`:** federation verb services
|
||||||
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
|
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
|
||||||
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
|
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: active
|
|
||||||
audience: developer
|
|
||||||
---
|
|
||||||
|
|
||||||
# Agent Enrollment Command Family — v1 Design (M4-4-0)
|
|
||||||
|
|
||||||
Status: design note (implementation-facing; amends no contract).
|
|
||||||
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
|
|
||||||
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
|
|
||||||
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
|
|
||||||
live `agents` table — a binding introduced at rev 4 and standing
|
|
||||||
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
|
|
||||||
the contract wins.
|
|
||||||
|
|
||||||
## 1. What the contracts bind (and what they leave open)
|
|
||||||
|
|
||||||
There is no standalone enrollment contract. The rank-4 family is defined
|
|
||||||
by composition:
|
|
||||||
|
|
||||||
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
|
|
||||||
reference/API-key intake (values never echoed), name/persona,
|
|
||||||
assignment scope (contract 3 §3.5)."
|
|
||||||
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
|
|
||||||
DTOs validated at the Gateway boundary (expected-version only where
|
|
||||||
an owning contract defines one); §4.2 closed per-family error enum
|
|
||||||
(validation, authentication, authorization, not-found, conflict,
|
|
||||||
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
|
|
||||||
envelope contributes correlation: every request accepts/generates a
|
|
||||||
correlation id, carried into the audit events **and returned in the
|
|
||||||
result**, with no second audit stream; §4.4 fail-closed — an
|
|
||||||
operation that cannot evaluate its authorization or reach its owning
|
|
||||||
tool refuses, never degrading to a fallback read or direct data
|
|
||||||
access; §4.5 CLI parity — the family MUST be invocable through the
|
|
||||||
official CLI against the same Gateway commands with the same
|
|
||||||
request/result/error contracts (a Gateway command without CLI
|
|
||||||
exposure is a tracked conformance gap).
|
|
||||||
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
|
|
||||||
envelope is contract 3 §4.3, ratified as a drafting addition to
|
|
||||||
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
|
|
||||||
and replay rules bind as written there; §3.1 rule 5 below designs to
|
|
||||||
them.
|
|
||||||
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
|
|
||||||
harness, API-key login, agent name and persona — D11), uses ONLY this
|
|
||||||
family, and is skippable. Wizard witness §6.10: a run that skips the
|
|
||||||
step produces zero enrollment-family mutations.
|
|
||||||
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
|
|
||||||
contract 7's agent-grantee FK references the live `agents` table
|
|
||||||
(`agents.id`, uuid); an enrollment surface with its own table would
|
|
||||||
force a contract-7 amendment.
|
|
||||||
|
|
||||||
**Assignment scope (open point, pinned here):** the rank-4 row cites
|
|
||||||
contract 3 §3.5, which defines no assignment semantics; the PRD's full
|
|
||||||
enrollment vision (Part I, Standalone flow) includes "account
|
|
||||||
assignment", but the D11 v1 slice is exactly "one harness, API key,
|
|
||||||
name/persona". v1 therefore scopes assignment to the two bindings the
|
|
||||||
minimal slice already implies — the enrolling user becomes the agent's
|
|
||||||
owner (`agents.owner_id`), and the credential reference names which of
|
|
||||||
that user's stored provider credentials the agent uses. Richer
|
|
||||||
assignment (multi-account, comms auto-enroll, workspace placement) is
|
|
||||||
deferred with the rest of the PRD's full flow (D11); when a contract
|
|
||||||
defines it, this family extends by ordinary amendment of the design.
|
|
||||||
The deferral rests on contract 3 §3.5's explicit delegation of
|
|
||||||
enrollment specifics to this family — not on reading the D11 list as
|
|
||||||
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
|
|
||||||
by the live table's NOT NULL columns, though D11 does not name them).
|
|
||||||
|
|
||||||
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
|
|
||||||
|
|
||||||
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
|
|
||||||
model, status enum, project_id (legacy `projects`, ON DELETE SET
|
|
||||||
NULL), owner_id → users, system_prompt, allowed_tools, skills,
|
|
||||||
is_system, config jsonb, timestamps. No harness column (provider and
|
|
||||||
model describe the LLM backend, not the harness), no audit coupling.
|
|
||||||
- Sole write path: `packages/brain/src/agents.ts` repository (the only
|
|
||||||
module issuing `insert(agents)`), with three write consumers: the
|
|
||||||
legacy `/api/agents` CRUD controller
|
|
||||||
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
|
|
||||||
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
|
|
||||||
→ `brain.agents.create`), and workspace bootstrap
|
|
||||||
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
|
|
||||||
three keep serving existing consumers; none is touched by M4-4.
|
|
||||||
- Sealed credential store exists: `ProviderCredentialsService`
|
|
||||||
(apps/gateway/src/agent/) — one row per (userId, provider), values
|
|
||||||
sealed at rest, decrypt server-side only, summaries never carry
|
|
||||||
values.
|
|
||||||
- Harness registry exists (`apps/gateway/src/harness/`), the validation
|
|
||||||
source for the harness field.
|
|
||||||
- Implementation pattern: the merged hierarchy module (M4-1) —
|
|
||||||
transaction-scoped command context, in-tx authorization, discriminated
|
|
||||||
result unions, same-transaction semantic audit event + transactional
|
|
||||||
outbox, no-oracle not_found folding.
|
|
||||||
|
|
||||||
**F1 — contract-5 mapping note (disposition, not an amendment):**
|
|
||||||
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
|
|
||||||
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
|
|
||||||
specific endpoints, and `/api/agents` is not among them). The operative
|
|
||||||
constraints are §3.3's amendment-only rule for new mapping rows and §5's
|
|
||||||
closure rule: this design adds no new consumer to `/api/agents` and
|
|
||||||
builds the rank-4 family as the P1 path for enrollment. Adding the
|
|
||||||
missing P0 row is a contract amendment for a future S2 pass; nothing in
|
|
||||||
M4-4 depends on it.
|
|
||||||
|
|
||||||
## 3. Command family surface (v1)
|
|
||||||
|
|
||||||
One command, one query. Module: `apps/gateway/src/enrollment/`
|
|
||||||
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
|
|
||||||
|
|
||||||
### 3.1 `agent.enroll` (mutation)
|
|
||||||
|
|
||||||
Request DTO (shared types package, class-validator at the boundary):
|
|
||||||
|
|
||||||
| Field | Type | Rule |
|
|
||||||
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
|
|
||||||
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
|
|
||||||
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
|
|
||||||
| `name` | string | non-empty, trimmed, ≤ 200 chars |
|
|
||||||
| `persona` | string \| null | optional; stored as the agent's system prompt |
|
|
||||||
| `model` | string | non-empty (provider-qualified model id) |
|
|
||||||
| `provider` | string | non-empty; names the credential's provider |
|
|
||||||
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
|
|
||||||
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. **Never echoed.** The credential value appears in no result DTO, no
|
|
||||||
audit event, no outbox payload, and no log line. The result carries
|
|
||||||
only `{ provider, credentialMode }`.
|
|
||||||
2. **Intake = the existing sealed store, inside the transaction.**
|
|
||||||
`intake` writes through the sealed-store path
|
|
||||||
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
|
|
||||||
per (userId, provider)) **in the same transaction** as the agent
|
|
||||||
insert — a failure after the credential write rolls everything back,
|
|
||||||
leaving no orphan credential. Enrollment persists no second copy and
|
|
||||||
no plaintext.
|
|
||||||
3. **Reference must resolve.** `reference` with no stored credential for
|
|
||||||
(actor, provider) refuses with `precondition_failed` (nothing is
|
|
||||||
created).
|
|
||||||
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
|
|
||||||
is AuthGuard-authenticated user; no hierarchy grant is required
|
|
||||||
because v1 enrollment binds no hierarchy node (§1 assignment-scope
|
|
||||||
pin). `is_system` is never settable through this command.
|
|
||||||
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
|
|
||||||
records, in a uniqueness-constrained fence table in the same
|
|
||||||
transaction as the mutation and its audit event: the key, the
|
|
||||||
operation identifier (`agent.enroll`), the acting principal, the
|
|
||||||
authorization scope, a digest of the canonicalized request payload
|
|
||||||
(the digest input EXCLUDES the credential value — it covers
|
|
||||||
provider + credentialMode, never plaintext), the declared replay
|
|
||||||
mode (always `actor-bound` for this family — the `shared` refusal
|
|
||||||
in the table above means no shared fence row can exist here; the
|
|
||||||
column is kept for envelope-shape fidelity and mode-mismatch
|
|
||||||
collision checks), and a reference to the committed outcome (the
|
|
||||||
agent id). The recorded **authorization scope** for this family is
|
|
||||||
pinned to the acting principal's platform-user scope (v1
|
|
||||||
authorization is grant-free per rule 4, so the scope is the
|
|
||||||
authenticated-user identity domain — recorded so the §4.3
|
|
||||||
scope-equality check has a defined value). Fence uniqueness is the
|
|
||||||
pair (operation identifier, key). **Replay:** a submission whose
|
|
||||||
(operation, key) is recorded is first authorized exactly as a fresh
|
|
||||||
submission; then replay-mode, scope, and digest equality are
|
|
||||||
checked (a mismatch on any — including scope — is a collision);
|
|
||||||
then **target-result authorization** — the submitter must hold, at
|
|
||||||
replay time, read authority on the referenced agent row under
|
|
||||||
§3.2's rule (owner or admin) — plus recorded-actor equality
|
|
||||||
(`actor-bound`). A passing replay executes nothing, returns the
|
|
||||||
recorded outcome, and appends a replay access event (non-mutation
|
|
||||||
audit class: accessing principal, current correlation id,
|
|
||||||
fence-row reference). Any equality or authorization failure refuses
|
|
||||||
with the single bounded `conflict` shape — constant, identifying no
|
|
||||||
record — preserving the no-existence-oracle rule. **Concurrency
|
|
||||||
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
|
|
||||||
with the same (operation, key) serialize on the fence's unique
|
|
||||||
constraint — exactly one executes; the loser waits for the winner's
|
|
||||||
transaction, and is then handled as a replay if it committed
|
|
||||||
(through the full replay path above) or executes afresh if it
|
|
||||||
aborted. A unique-violation race never surfaces as an unhandled
|
|
||||||
internal fault.
|
|
||||||
6. **Audit + outbox, same transaction.** Insert into `agents` +
|
|
||||||
sealed credential write (intake mode) + fence row + semantic audit
|
|
||||||
event (`agent.enrolled`: actor, agent id, harness, provider, name,
|
|
||||||
credentialMode — no credential material) + outbox row commit
|
|
||||||
atomically, hierarchy-pattern style. Audit rows reference the agent
|
|
||||||
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
|
|
||||||
deliberate FK-free linkage so audit history survives agent deletion
|
|
||||||
through the legacy CRUD DELETE path.
|
|
||||||
|
|
||||||
Result union: `enrolled { agent, correlationId }` | refusal from the
|
|
||||||
§3.3 enum (refusals also carry the correlation id, per contract 5
|
|
||||||
§4.3's end-to-end traceability). `agent` in the result is the persisted
|
|
||||||
row minus nothing sensitive (the table stores no credential material).
|
|
||||||
|
|
||||||
### 3.2 `agent.enrollment.get` (query)
|
|
||||||
|
|
||||||
By agent id; actor must be the owner (or admin). Unauthorized and
|
|
||||||
missing fold to the same `not_found` wire shape (contract 2
|
|
||||||
no-existence-oracle rule, applied family-wide for uniformity).
|
|
||||||
|
|
||||||
The query carries the same non-state envelope as the mutation
|
|
||||||
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
|
|
||||||
query responses carry it): typed request DTO with an optional
|
|
||||||
`correlationId` (generated when absent) and a typed result —
|
|
||||||
`found { agent, correlationId }` | `not_found` (the folded shape,
|
|
||||||
also carrying the correlation id). Queries take no idempotency key
|
|
||||||
(the fence binds mutations).
|
|
||||||
|
|
||||||
### 3.3 Error enum (closed, §4.2)
|
|
||||||
|
|
||||||
`validation_failed` 400 · `authentication_failed` 401 ·
|
|
||||||
`authorization_refused` 403 (owner-only paths; folded to `not_found`
|
|
||||||
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
|
|
||||||
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
|
|
||||||
422 (unresolvable credential reference; well-formed harness not in the
|
|
||||||
registry — syntactic invalidity is `validation_failed` per the §3.1
|
|
||||||
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
|
|
||||||
owning tool is unreachable; unauthorized-fallback behavior is
|
|
||||||
prohibited).
|
|
||||||
|
|
||||||
## 4. Schema delta (migration 0021, additive-only)
|
|
||||||
|
|
||||||
Extend `agents` — no new agent table, preserving custody-schema §5.2's
|
|
||||||
FK binding without amendment:
|
|
||||||
|
|
||||||
- `harness` text NULL — registered harness name; NULL for pre-existing
|
|
||||||
rows (legacy rows predate the concept).
|
|
||||||
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
|
|
||||||
legacy (non-enrolled) row. No backfill: enrollment is a fact this
|
|
||||||
command creates, not one to invent for existing rows.
|
|
||||||
|
|
||||||
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
|
|
||||||
separate store): `agent_audit_events` (append-only: id, event_type,
|
|
||||||
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
|
|
||||||
correlation id, causation id, payload jsonb, created_at; per-agent
|
|
||||||
ordering index), `agent_outbox` (hierarchy-outbox shape), and
|
|
||||||
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
|
|
||||||
key, acting principal, authorization scope, canonicalized-payload
|
|
||||||
digest, replay mode, committed-outcome reference (agent id), created_at;
|
|
||||||
UNIQUE (operation identifier, key)). Persona reuses the existing
|
|
||||||
`system_prompt` column; no version column (no ratified expected-version
|
|
||||||
rule names `agents` — §4.1 binds only where the owning contract defines
|
|
||||||
one).
|
|
||||||
|
|
||||||
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
|
|
||||||
same-tx atomicity (agent row + credential write + fence row + audit +
|
|
||||||
outbox all-or-nothing under injected failure at multiple points,
|
|
||||||
including after the credential write), fence uniqueness on
|
|
||||||
(operation, key).
|
|
||||||
|
|
||||||
Sequencing: additive DDL via the same migration path as 0018–0020
|
|
||||||
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
|
|
||||||
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
|
|
||||||
pending operator ruling on migration sequencing changes mechanics
|
|
||||||
lane-wide, re-check before generating 0021.
|
|
||||||
|
|
||||||
## 5. Witnesses the implementation slice must ship
|
|
||||||
|
|
||||||
1. Never-echo: enroll via `intake`, assert the value string is absent
|
|
||||||
from the HTTP result, the audit row, the outbox payload, and captured
|
|
||||||
logs.
|
|
||||||
2. Sealed-store single-copy: after intake, the credential exists only in
|
|
||||||
`provider_credentials` (sealed), and `agents` has no credential
|
|
||||||
column at all.
|
|
||||||
3. Reference-resolution refusal (`precondition_failed`, no row created).
|
|
||||||
4. Harness refusals, both codes: syntactically invalid →
|
|
||||||
`validation_failed`; well-formed registry miss →
|
|
||||||
`precondition_failed` (against the live registry).
|
|
||||||
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
|
|
||||||
recorded outcome and executes nothing (no new agent/audit/outbox
|
|
||||||
mutation rows; a replay access event is appended); payload-digest
|
|
||||||
mismatch, replay-mode mismatch, scope mismatch, and different-actor
|
|
||||||
actor-bound replay each refuse with the single bounded `conflict`
|
|
||||||
shape; a replay is re-authorized fresh (a submitter whose
|
|
||||||
authorization was revoked since the original is refused, not
|
|
||||||
replayed); a `shared` declaration on `agent.enroll` is refused
|
|
||||||
`validation_failed` with nothing executed and no fence row
|
|
||||||
recorded (seed-only rule); two concurrent same-(operation, key)
|
|
||||||
submissions produce exactly one mutation, the loser resolving
|
|
||||||
through the replay path (no unhandled unique-violation fault).
|
|
||||||
6. Same-tx atomicity fault injection (agent / credential write / fence
|
|
||||||
/ audit / outbox), including a failure injected after the intake
|
|
||||||
credential write commits its statement — everything rolls back, no
|
|
||||||
orphan credential.
|
|
||||||
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
|
|
||||||
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
|
|
||||||
`agent_idempotency_fence` attributable to the family.
|
|
||||||
8. `is_system` injection attempt is rejected by DTO validation.
|
|
||||||
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
|
|
||||||
on `agent.enroll` appears in its audit event(s) and in the result;
|
|
||||||
the same holds for `agent.enrollment.get`'s result; the §6.3 static
|
|
||||||
companions (no `any`-typed boundary pass-through; single audit
|
|
||||||
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
|
|
||||||
`agent.enrollment.get` of an existing agent and a get of a
|
|
||||||
nonexistent id return indistinguishable results.
|
|
||||||
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
|
|
||||||
`agent.enroll` and `agent.enrollment.get` against the Gateway
|
|
||||||
succeeds with the same typed results the web client receives. The
|
|
||||||
implementation slice therefore SHIPS CLI exposure for both
|
|
||||||
operations (contract 5 §4.5 — a Gateway command without CLI
|
|
||||||
exposure is a tracked conformance gap; this design refuses to open
|
|
||||||
one).
|
|
||||||
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
|
|
||||||
grant state unreachable (fault injection), the operation returns
|
|
||||||
the internal-fault or authorization-refusal class and performs no
|
|
||||||
fallback read/write.
|
|
||||||
|
|
||||||
## 6. Out of scope
|
|
||||||
|
|
||||||
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
|
|
||||||
(no contract requires it in v1 — the legacy write surfaces named in §2
|
|
||||||
keep serving existing consumers); OAuth login, multi-account, comms
|
|
||||||
auto-enroll, model recommendation (PRD full flow, deferred by D11);
|
|
||||||
contract amendments (F1 recorded above for a future S2 pass). CLI
|
|
||||||
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Plan — Stack Containerization (tiered deployment)
|
|
||||||
|
|
||||||
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
|
|
||||||
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
|
|
||||||
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
|
|
||||||
|
|
||||||
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
|
|
||||||
|
|
||||||
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
|
|
||||||
valkey, otel-collector, jaeger. No application services.
|
|
||||||
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
|
|
||||||
storage tier (own postgres/valkey; port-conflicts the base stack by
|
|
||||||
design). Not an app deployment.
|
|
||||||
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
|
|
||||||
multi-stage production builds (node:22-alpine) EXIST; the gateway image
|
|
||||||
includes the web SPA bundle (#1444).
|
|
||||||
- CI (`publish.yml`) builds and publishes these images (next-channel
|
|
||||||
prereleases + main stable), and runs `verify:release` fail-closed.
|
|
||||||
- Gap: no stack-level composition wires gateway+appservice+data plane
|
|
||||||
into one deployable unit; no blessed install/upgrade path; no
|
|
||||||
in-container agent-runtime story for the dogfood loop.
|
|
||||||
|
|
||||||
## Target (PRD D15 amendment)
|
|
||||||
|
|
||||||
Tiered deployment, additive to the existing architecture:
|
|
||||||
|
|
||||||
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
|
|
||||||
postgres, valkey, openbao, gateway, appservice (and the webUI the
|
|
||||||
gateway serves) to healthy; migrations apply; the webUI hosts agent
|
|
||||||
chat; an in-stack agent can read this repo and open a PR; CI
|
|
||||||
validates; the deployment adopts merged images (pull + restart).
|
|
||||||
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
|
|
||||||
same service set, phase-gated on the standalone bar holding.
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
### Phase A — blessed standalone compose
|
|
||||||
|
|
||||||
- A1 Compose service definitions for gateway + appservice joining the
|
|
||||||
existing infra compose (profiles: `dev` keeps today's behavior;
|
|
||||||
`stack` adds the app tier), with health checks and dependency order.
|
|
||||||
- A2 Migrations on boot (or an explicit migrate step) with idempotency
|
|
||||||
and version pinning; init-db.sql folded into pg-init.
|
|
||||||
- A3 Openbao in the compose set (secret plumbing for the app tier).
|
|
||||||
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
|
|
||||||
standalone mode; mode recorded per the mode-conversion contract.
|
|
||||||
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
|
|
||||||
webUI served; agent chat reachable; failures catalogued and fixed.
|
|
||||||
- Acceptance: the five-point NORTH-STAR bar measured live.
|
|
||||||
|
|
||||||
### Phase B — component completion
|
|
||||||
|
|
||||||
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
|
|
||||||
dogfood agents inherit SEAT-GRADE identity — credential-slot
|
|
||||||
isolation, wrapper-first enforcement, no privileged coordination
|
|
||||||
identity, evidence by references that resolve outside the container
|
|
||||||
lifetime.
|
|
||||||
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
|
|
||||||
bits (brain/tool access paths in-container), repo credentials for the
|
|
||||||
dogfood agent, watch/comms surfaces inside the deployment.
|
|
||||||
|
|
||||||
### Phase C — CI/CD parity
|
|
||||||
|
|
||||||
- Publish pipeline is the only image source (already true); add the
|
|
||||||
deployment-side pull/upgrade path (compose pull + migrate + restart =
|
|
||||||
next iteration); document the promotion flow next -> registry ->
|
|
||||||
deployment.
|
|
||||||
|
|
||||||
### Phase D — coordinator integration (GATED)
|
|
||||||
|
|
||||||
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
|
|
||||||
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
|
|
||||||
(schema, freshness, incarnation, clean-replacement resume; D57-D60
|
|
||||||
lineage) — carries an independent target-bound PASS. Wiring restarts
|
|
||||||
against the core alone re-creates the stale-incarnation failure class
|
|
||||||
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
|
|
||||||
P1 authorization).
|
|
||||||
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
|
|
||||||
health/recovery) executed by the SHIPPED coord client over the one
|
|
||||||
typed coordination contract (request id, actor identity, epoch,
|
|
||||||
revision, lease, correlation; typed stale rejection; worker role
|
|
||||||
boundary). No second coordination interface gets designed here —
|
|
||||||
containerization consumes the coordination contract, never defines it.
|
|
||||||
|
|
||||||
### Phase E — enterprise tier
|
|
||||||
|
|
||||||
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
|
|
||||||
|
|
||||||
### Absorbed M4 remainder
|
|
||||||
|
|
||||||
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
|
|
||||||
- M4-5: lands inside Phase B/C where natural.
|
|
||||||
- M4-6 (composes M4-1+M4-4): last, as designed.
|
|
||||||
|
|
||||||
## Non-goals (v1)
|
|
||||||
|
|
||||||
- No Kubernetes in v1; no multi-host federation; no replacement of the
|
|
||||||
fleet's brain-based seats (the stack is an additional operator
|
|
||||||
surface); no on-host image builds for deployment (registry only).
|
|
||||||
@@ -13,10 +13,6 @@ status: active
|
|||||||
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
|
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
|
||||||
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
|
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
|
||||||
|
|
||||||
## Feature design plans
|
|
||||||
|
|
||||||
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
|
|
||||||
|
|
||||||
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
|
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { createMissionTasksRepo } from './mission-tasks.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
|
|
||||||
* the sole path that authors mission_tasks.status from caller input (storage
|
|
||||||
* tier migration is row transport and preserves stored values; the generic
|
|
||||||
* storage adapters have no mission_tasks caller), and it must never forward a
|
|
||||||
* caller-supplied status to the database on create or update. Callers keep
|
|
||||||
* working (the field is accepted and ignored), so these tests assert on what
|
|
||||||
* reaches the Drizzle chain, not on rejection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function makeInsertDb(returned: unknown[]) {
|
|
||||||
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
|
|
||||||
return { db: { insert: vi.fn(() => ({ values })) }, values };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeUpdateDb(returned: unknown[]) {
|
|
||||||
const set = vi.fn((_v: unknown) => ({
|
|
||||||
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
|
|
||||||
}));
|
|
||||||
return { db: { update: vi.fn(() => ({ set })) }, set };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createMissionTasksRepo — status write prohibition', () => {
|
|
||||||
it('create strips a caller-supplied status before insert', async () => {
|
|
||||||
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
|
|
||||||
const repo = createMissionTasksRepo(db as never);
|
|
||||||
|
|
||||||
const result = await repo.create({
|
|
||||||
missionId: 'm1',
|
|
||||||
userId: 'u1',
|
|
||||||
status: 'done',
|
|
||||||
description: 'd',
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(values).toHaveBeenCalledTimes(1);
|
|
||||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
|
||||||
expect('status' in inserted).toBe(false);
|
|
||||||
expect(inserted.missionId).toBe('m1');
|
|
||||||
expect(inserted.description).toBe('d');
|
|
||||||
expect(result.id).toBe('mt1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('create without status still inserts (DB default applies)', async () => {
|
|
||||||
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
|
|
||||||
const repo = createMissionTasksRepo(db as never);
|
|
||||||
|
|
||||||
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
|
|
||||||
|
|
||||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
|
||||||
expect('status' in inserted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('update strips a caller-supplied status but keeps the other fields', async () => {
|
|
||||||
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
|
|
||||||
const repo = createMissionTasksRepo(db as never);
|
|
||||||
|
|
||||||
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
|
|
||||||
|
|
||||||
expect(set).toHaveBeenCalledTimes(1);
|
|
||||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
|
||||||
expect('status' in updated).toBe(false);
|
|
||||||
expect(updated.notes).toBe('n');
|
|
||||||
expect(updated.updatedAt).toBeInstanceOf(Date);
|
|
||||||
expect(result?.id).toBe('mt1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('update with only status degenerates to a timestamp-only update', async () => {
|
|
||||||
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
|
|
||||||
const repo = createMissionTasksRepo(db as never);
|
|
||||||
|
|
||||||
await repo.update('mt1', { status: 'blocked' } as never);
|
|
||||||
|
|
||||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
|
||||||
expect(Object.keys(updated)).toEqual(['updatedAt']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,24 +3,6 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
|
|||||||
export type MissionTask = typeof missionTasks.$inferSelect;
|
export type MissionTask = typeof missionTasks.$inferSelect;
|
||||||
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
||||||
|
|
||||||
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
|
|
||||||
// write source through the N-1 window. This repo is the sole path that authors
|
|
||||||
// status from caller input, so the field is stripped here — accepted and
|
|
||||||
// ignored rather than rejected, because the legacy surface is frozen with
|
|
||||||
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
|
|
||||||
// surfaces touch the column and are deliberately NOT stripped:
|
|
||||||
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
|
|
||||||
// must preserve the stored value verbatim, and the generic table-keyed storage
|
|
||||||
// adapters register mission_tasks but have no caller that targets it (runtime
|
|
||||||
// callers use fixed collection constants). Neither authors a new status. The
|
|
||||||
// column keeps its DB default, stays declared and readable, and is retired
|
|
||||||
// only after no readers remain.
|
|
||||||
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
|
|
||||||
const rest = { ...data };
|
|
||||||
delete rest.status;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMissionTasksRepo(db: Db) {
|
export function createMissionTasksRepo(db: Db) {
|
||||||
return {
|
return {
|
||||||
async findByMission(missionId: string): Promise<MissionTask[]> {
|
async findByMission(missionId: string): Promise<MissionTask[]> {
|
||||||
@@ -48,14 +30,14 @@ export function createMissionTasksRepo(db: Db) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async create(data: NewMissionTask): Promise<MissionTask> {
|
async create(data: NewMissionTask): Promise<MissionTask> {
|
||||||
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
|
const rows = await db.insert(missionTasks).values(data).returning();
|
||||||
return rows[0]!;
|
return rows[0]!;
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.update(missionTasks)
|
.update(missionTasks)
|
||||||
.set({ ...stripStatus(data), updatedAt: new Date() })
|
.set({ ...data, updatedAt: new Date() })
|
||||||
.where(eq(missionTasks.id, id))
|
.where(eq(missionTasks.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
CREATE TYPE "public"."agent_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
|
||||||
CREATE TABLE "agent_audit_events" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "agent_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
||||||
"event_type" text NOT NULL,
|
|
||||||
"actor_id" text NOT NULL,
|
|
||||||
"agent_id" uuid NOT NULL,
|
|
||||||
"correlation_id" text NOT NULL,
|
|
||||||
"causation_id" uuid,
|
|
||||||
"payload" jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "agent_audit_events_type_check" CHECK (event_type IN ('agent.enrolled', 'agent.enrollment.replayed'))
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "agent_idempotency_fence" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"operation" text NOT NULL,
|
|
||||||
"idempotency_key" text NOT NULL,
|
|
||||||
"actor_id" text NOT NULL,
|
|
||||||
"authorization_scope" text NOT NULL,
|
|
||||||
"payload_digest" text NOT NULL,
|
|
||||||
"replay_mode" text DEFAULT 'actor-bound' NOT NULL,
|
|
||||||
"outcome_agent_id" uuid NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "agent_idempotency_fence_replay_mode_check" CHECK (replay_mode IN ('actor-bound', 'shared'))
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "agent_outbox" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"event_id" uuid NOT NULL,
|
|
||||||
"correlation_id" text NOT NULL,
|
|
||||||
"status" "agent_outbox_status" DEFAULT 'pending' NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"delivered_at" timestamp with time zone
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "agents" ADD COLUMN "harness" text;--> statement-breakpoint
|
|
||||||
ALTER TABLE "agents" ADD COLUMN "enrolled_at" timestamp with time zone;--> statement-breakpoint
|
|
||||||
ALTER TABLE "agent_audit_events" ADD CONSTRAINT "agent_audit_events_causation_id_agent_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "agent_outbox" ADD CONSTRAINT "agent_outbox_event_id_agent_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "agent_audit_events_seq_idx" ON "agent_audit_events" USING btree ("seq");--> statement-breakpoint
|
|
||||||
CREATE INDEX "agent_audit_events_agent_seq_idx" ON "agent_audit_events" USING btree ("agent_id","seq");--> statement-breakpoint
|
|
||||||
CREATE INDEX "agent_audit_events_correlation_idx" ON "agent_audit_events" USING btree ("correlation_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "agent_idempotency_fence_operation_key_idx" ON "agent_idempotency_fence" USING btree ("operation","idempotency_key");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "agent_outbox_event_idx" ON "agent_outbox" USING btree ("event_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "agent_outbox_status_created_idx" ON "agent_outbox" USING btree ("status","created_at");
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -148,13 +148,6 @@
|
|||||||
"when": 1787963521142,
|
"when": 1787963521142,
|
||||||
"tag": "0020_special_betty_brant",
|
"tag": "0020_special_betty_brant",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 21,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1788053011351,
|
|
||||||
"tag": "0021_agent_enrollment",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
|
|
||||||
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
|
|
||||||
*
|
|
||||||
* Witnesses the guarantees migration 0021's tables themselves carry: the
|
|
||||||
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
|
|
||||||
* linkage (no foreign key from the events or fence tables into `agents` —
|
|
||||||
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
|
|
||||||
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
|
|
||||||
* (operation, key) and replay-mode CHECK, and the nullable enrollment
|
|
||||||
* columns on `agents` (legacy rows insert without them). The command-level
|
|
||||||
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
|
|
||||||
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
|
|
||||||
*
|
|
||||||
* Two legs run the same witness body:
|
|
||||||
* - PGlite (WASM Postgres): always runs.
|
|
||||||
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
|
|
||||||
* CI migrates ci-postgres before `pnpm test`.
|
|
||||||
*/
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { createDb } from './client.js';
|
|
||||||
import { createPgliteDb } from './client-pglite.js';
|
|
||||||
import { runPgliteMigrations } from './migrate.js';
|
|
||||||
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
|
|
||||||
|
|
||||||
type AnyDb = {
|
|
||||||
db: {
|
|
||||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
|
||||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
|
||||||
};
|
|
||||||
close: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
|
||||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
|
||||||
let err: unknown;
|
|
||||||
try {
|
|
||||||
await p;
|
|
||||||
} catch (e) {
|
|
||||||
err = e;
|
|
||||||
}
|
|
||||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
|
||||||
const messages: string[] = [];
|
|
||||||
let cur: unknown = err;
|
|
||||||
while (cur instanceof Error) {
|
|
||||||
messages.push(cur.message);
|
|
||||||
cur = (cur as { cause?: unknown }).cause;
|
|
||||||
}
|
|
||||||
expect(messages.join(' | '), label).toMatch(re);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
|
||||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
|
||||||
const T = `agent-e-${randomUUID().slice(0, 8)}`;
|
|
||||||
|
|
||||||
type EventInsert = typeof agentAuditEvents.$inferInsert;
|
|
||||||
|
|
||||||
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
|
||||||
return {
|
|
||||||
eventType: 'agent.enrolled',
|
|
||||||
actorId: `${T}-actor`,
|
|
||||||
agentId: randomUUID(),
|
|
||||||
correlationId: `${T}-corr-${randomUUID()}`,
|
|
||||||
payload: {
|
|
||||||
harness: 'claude-code',
|
|
||||||
provider: 'anthropic',
|
|
||||||
name: 'x',
|
|
||||||
credentialMode: 'reference',
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
|
|
||||||
|
|
||||||
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
|
|
||||||
return {
|
|
||||||
operation: 'agent.enroll',
|
|
||||||
idempotencyKey: `${T}-${randomUUID()}`,
|
|
||||||
actorId: `${T}-actor`,
|
|
||||||
authorizationScope: 'platform-user',
|
|
||||||
payloadDigest: `${T}-digest`,
|
|
||||||
outcomeAgentId: randomUUID(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function witnessSuite(getHandle: () => AnyDb): void {
|
|
||||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const d = db();
|
|
||||||
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
|
|
||||||
// Caused events first: the causation self-FK is RESTRICT.
|
|
||||||
await d.execute(
|
|
||||||
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
|
||||||
);
|
|
||||||
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
|
|
||||||
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
|
|
||||||
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
|
|
||||||
|
|
||||||
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
|
|
||||||
const legacyId = randomUUID();
|
|
||||||
await db()
|
|
||||||
.insert(agents)
|
|
||||||
.values({
|
|
||||||
id: legacyId,
|
|
||||||
name: `${T}-legacy`,
|
|
||||||
provider: 'anthropic',
|
|
||||||
model: 'claude-fable-5',
|
|
||||||
});
|
|
||||||
const legacy = rows(
|
|
||||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
|
|
||||||
)[0]!;
|
|
||||||
expect(legacy['harness']).toBeNull();
|
|
||||||
expect(legacy['enrolled_at']).toBeNull();
|
|
||||||
|
|
||||||
const enrolledId = randomUUID();
|
|
||||||
await db()
|
|
||||||
.insert(agents)
|
|
||||||
.values({
|
|
||||||
id: enrolledId,
|
|
||||||
name: `${T}-enrolled`,
|
|
||||||
provider: 'anthropic',
|
|
||||||
model: 'claude-fable-5',
|
|
||||||
harness: 'claude-code',
|
|
||||||
enrolledAt: new Date(),
|
|
||||||
});
|
|
||||||
const enrolled = rows(
|
|
||||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
|
|
||||||
)[0]!;
|
|
||||||
expect(enrolled['harness']).toBe('claude-code');
|
|
||||||
expect(enrolled['enrolled_at']).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
|
|
||||||
|
|
||||||
it('accepts both declared event types and refuses an undeclared one', async () => {
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ eventType: 'agent.enrolled' }));
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
|
|
||||||
await expectViolation(
|
|
||||||
db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ eventType: 'agent.deleted' })),
|
|
||||||
/type_check|violates check/i,
|
|
||||||
'undeclared event type must be refused',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('assigns strictly increasing seq in insert order for one agent', async () => {
|
|
||||||
const agentId = randomUUID();
|
|
||||||
const c1 = `${T}-seq-1-${randomUUID()}`;
|
|
||||||
const c2 = `${T}-seq-2-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ agentId, correlationId: c1 }));
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
|
|
||||||
const res = rows(
|
|
||||||
await db().execute(
|
|
||||||
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
|
|
||||||
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has no foreign key into agents, and events survive agent deletion', async () => {
|
|
||||||
const fks = rows(
|
|
||||||
await db().execute(sql`
|
|
||||||
SELECT ccu.table_name AS referenced_table
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.constraint_column_usage ccu
|
|
||||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
|
||||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
// The causation self-FK is the ONLY foreign key on the events table.
|
|
||||||
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
|
|
||||||
|
|
||||||
const agentId = randomUUID();
|
|
||||||
await db()
|
|
||||||
.insert(agents)
|
|
||||||
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
|
|
||||||
const corr = `${T}-survive-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ agentId, correlationId: corr }));
|
|
||||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
|
||||||
const after = rows(
|
|
||||||
await db().execute(
|
|
||||||
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(after).toHaveLength(1);
|
|
||||||
expect(after[0]!['agent_id']).toBe(agentId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
|
||||||
await expectViolation(
|
|
||||||
db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ causationId: randomUUID() })),
|
|
||||||
/foreign key/i,
|
|
||||||
'causation must reference an existing event',
|
|
||||||
);
|
|
||||||
const causeCorr = `${T}-cause-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ correlationId: causeCorr }));
|
|
||||||
const cause = rows(
|
|
||||||
await db().execute(
|
|
||||||
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
|
|
||||||
),
|
|
||||||
)[0]!;
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(
|
|
||||||
eventRow({
|
|
||||||
eventType: 'agent.enrollment.replayed',
|
|
||||||
causationId: cause['id'] as string,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expectViolation(
|
|
||||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
|
|
||||||
/foreign key/i,
|
|
||||||
'a cause with dependent events must not be deletable',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── agent_outbox shape ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
|
|
||||||
await expectViolation(
|
|
||||||
db()
|
|
||||||
.insert(agentOutbox)
|
|
||||||
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
|
|
||||||
/foreign key/i,
|
|
||||||
'outbox must reference an existing event',
|
|
||||||
);
|
|
||||||
const corr = `${T}-ob-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ correlationId: corr }));
|
|
||||||
const event = rows(
|
|
||||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
|
||||||
)[0]!;
|
|
||||||
const eventId = event['id'] as string;
|
|
||||||
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
|
|
||||||
await expectViolation(
|
|
||||||
db()
|
|
||||||
.insert(agentOutbox)
|
|
||||||
.values({ eventId, correlationId: `${T}-ob2` }),
|
|
||||||
/duplicate key|unique/i,
|
|
||||||
'one outbox record per event',
|
|
||||||
);
|
|
||||||
await expectViolation(
|
|
||||||
db().execute(
|
|
||||||
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
|
|
||||||
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
|
|
||||||
),
|
|
||||||
/invalid input value for enum|22P02/i,
|
|
||||||
'status outside pending/processing/delivered must be refused',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
|
||||||
const corr = `${T}-obr-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentAuditEvents)
|
|
||||||
.values(eventRow({ correlationId: corr }));
|
|
||||||
const event = rows(
|
|
||||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
|
||||||
)[0]!;
|
|
||||||
await db()
|
|
||||||
.insert(agentOutbox)
|
|
||||||
.values({ eventId: event['id'] as string, correlationId: corr });
|
|
||||||
await expectViolation(
|
|
||||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
|
|
||||||
/foreign key/i,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
|
|
||||||
|
|
||||||
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
|
|
||||||
const key = `${T}-fence-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentIdempotencyFence)
|
|
||||||
.values(fenceRow({ idempotencyKey: key }));
|
|
||||||
await expectViolation(
|
|
||||||
db()
|
|
||||||
.insert(agentIdempotencyFence)
|
|
||||||
.values(fenceRow({ idempotencyKey: key })),
|
|
||||||
/duplicate key|unique/i,
|
|
||||||
'fence uniqueness is (operation, key)',
|
|
||||||
);
|
|
||||||
// Same key, different operation identifier: a distinct fence.
|
|
||||||
await db()
|
|
||||||
.insert(agentIdempotencyFence)
|
|
||||||
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
|
|
||||||
const key = `${T}-mode-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentIdempotencyFence)
|
|
||||||
.values(fenceRow({ idempotencyKey: key }));
|
|
||||||
const row = rows(
|
|
||||||
await db().execute(
|
|
||||||
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
|
||||||
),
|
|
||||||
)[0]!;
|
|
||||||
expect(row['replay_mode']).toBe('actor-bound');
|
|
||||||
await expectViolation(
|
|
||||||
db()
|
|
||||||
.insert(agentIdempotencyFence)
|
|
||||||
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
|
|
||||||
/replay_mode_check|violates check/i,
|
|
||||||
'a mode outside actor-bound/shared must be refused',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
|
|
||||||
const fks = rows(
|
|
||||||
await db().execute(sql`
|
|
||||||
SELECT ccu.table_name AS referenced_table
|
|
||||||
FROM information_schema.table_constraints tc
|
|
||||||
JOIN information_schema.constraint_column_usage ccu
|
|
||||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
|
||||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
expect(fks).toHaveLength(0);
|
|
||||||
|
|
||||||
const agentId = randomUUID();
|
|
||||||
await db()
|
|
||||||
.insert(agents)
|
|
||||||
.values({
|
|
||||||
id: agentId,
|
|
||||||
name: `${T}-fdoomed`,
|
|
||||||
provider: 'anthropic',
|
|
||||||
model: 'claude-fable-5',
|
|
||||||
});
|
|
||||||
const key = `${T}-fsurvive-${randomUUID()}`;
|
|
||||||
await db()
|
|
||||||
.insert(agentIdempotencyFence)
|
|
||||||
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
|
|
||||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
|
||||||
const after = rows(
|
|
||||||
await db().execute(
|
|
||||||
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(after).toHaveLength(1);
|
|
||||||
expect(after[0]!['outcome_agent_id']).toBe(agentId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
|
||||||
|
|
||||||
describe('agent enrollment schema witnesses — PGlite', () => {
|
|
||||||
let dir: string;
|
|
||||||
let handle: ReturnType<typeof createPgliteDb>;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
|
|
||||||
handle = createPgliteDb(dir);
|
|
||||||
await runPgliteMigrations(handle);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await handle.close();
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
witnessSuite(() => handle as unknown as AnyDb);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
|
|
||||||
|
|
||||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
|
||||||
|
|
||||||
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
|
|
||||||
let handle: ReturnType<typeof createDb>;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
handle = createDb(process.env['DATABASE_URL']!);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await handle.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
witnessSuite(() => handle as unknown as AnyDb);
|
|
||||||
});
|
|
||||||
@@ -302,11 +302,6 @@ export const agents = pgTable(
|
|||||||
skills: jsonb('skills').$type<string[]>(),
|
skills: jsonb('skills').$type<string[]>(),
|
||||||
isSystem: boolean('is_system').notNull().default(false),
|
isSystem: boolean('is_system').notNull().default(false),
|
||||||
config: jsonb('config'),
|
config: jsonb('config'),
|
||||||
// Enrollment (M4-4, docs/plans/2026-08-29-agent-enrollment-command-design.md §4).
|
|
||||||
// NULL on both marks a legacy (non-enrolled) row; no backfill — enrollment
|
|
||||||
// is a fact the rank-4 command creates, not one to invent for existing rows.
|
|
||||||
harness: text('harness'),
|
|
||||||
enrolledAt: timestamp('enrolled_at', { withTimezone: true }),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
@@ -1284,109 +1279,3 @@ export const hierarchyOutbox = pgTable(
|
|||||||
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Agent enrollment (M4-4) — rank-4 command family audit/outbox/fence stores.
|
|
||||||
// Design: docs/plans/2026-08-29-agent-enrollment-command-design.md §4.
|
|
||||||
// Pattern reuse from the hierarchy audit/outbox pair, separate store. Audit
|
|
||||||
// rows reference the agent by snapshot id, deliberately with NO FK, so audit
|
|
||||||
// history survives agent deletion through the legacy CRUD DELETE path.
|
|
||||||
// Idempotency for this family lives in agent_idempotency_fence (contract 3
|
|
||||||
// §4.3 envelope, ratified into contract 5 §4 via contract 3 §7 item 4) — the
|
|
||||||
// audit and outbox tables carry no idempotency key of their own.
|
|
||||||
|
|
||||||
export const AGENT_AUDIT_EVENT_TYPES = [
|
|
||||||
// Semantic mutation event of agent.enroll.
|
|
||||||
'agent.enrolled',
|
|
||||||
// Non-mutation access class: a passing idempotent replay appends this and
|
|
||||||
// nothing else (accessing principal, current correlation id, fence-row
|
|
||||||
// reference in the payload).
|
|
||||||
'agent.enrollment.replayed',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const agentAuditEvents = pgTable(
|
|
||||||
'agent_audit_events',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
// Global append order; per-agent ordering is a filter on agent_id ordered
|
|
||||||
// by seq.
|
|
||||||
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
|
|
||||||
eventType: text('event_type').notNull(),
|
|
||||||
// No FK: audit events outlive every principal and every target.
|
|
||||||
actorId: text('actor_id').notNull(),
|
|
||||||
agentId: uuid('agent_id').notNull(),
|
|
||||||
correlationId: text('correlation_id').notNull(),
|
|
||||||
causationId: uuid('causation_id').references((): AnyPgColumn => agentAuditEvents.id, {
|
|
||||||
onDelete: 'restrict',
|
|
||||||
}),
|
|
||||||
// Immutable snapshot at event time; never carries credential material
|
|
||||||
// (§3.1 rule 1: actor, agent id, harness, provider, name, credentialMode).
|
|
||||||
payload: jsonb('payload').notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex('agent_audit_events_seq_idx').on(t.seq),
|
|
||||||
index('agent_audit_events_agent_seq_idx').on(t.agentId, t.seq),
|
|
||||||
index('agent_audit_events_correlation_idx').on(t.correlationId),
|
|
||||||
check(
|
|
||||||
'agent_audit_events_type_check',
|
|
||||||
sql`event_type IN ('agent.enrolled', 'agent.enrollment.replayed')`,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const agentOutboxStatusEnum = pgEnum('agent_outbox_status', [
|
|
||||||
'pending',
|
|
||||||
'processing',
|
|
||||||
'delivered',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const agentOutbox = pgTable(
|
|
||||||
'agent_outbox',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
// FK into the append-only events table: never dangles, RESTRICT is safe.
|
|
||||||
eventId: uuid('event_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => agentAuditEvents.id, { onDelete: 'restrict' }),
|
|
||||||
correlationId: text('correlation_id').notNull(),
|
|
||||||
status: agentOutboxStatusEnum('status').notNull().default('pending'),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex('agent_outbox_event_idx').on(t.eventId),
|
|
||||||
index('agent_outbox_status_created_idx').on(t.status, t.createdAt),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Contract 3 §4.3 fence shape. Uniqueness is (operation, key); the recorded
|
|
||||||
// replay mode is always 'actor-bound' for this family (`shared` is seed-only
|
|
||||||
// and refused at validation — design §3.1), but the column keeps the ratified
|
|
||||||
// envelope shape and serves the mode-mismatch collision check. The payload
|
|
||||||
// digest input EXCLUDES the credential value (design §3.1 rule 5).
|
|
||||||
export const agentIdempotencyFence = pgTable(
|
|
||||||
'agent_idempotency_fence',
|
|
||||||
{
|
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
|
||||||
operation: text('operation').notNull(),
|
|
||||||
idempotencyKey: text('idempotency_key').notNull(),
|
|
||||||
// No FK: fence rows outlive principals, mirroring the audit tables.
|
|
||||||
actorId: text('actor_id').notNull(),
|
|
||||||
authorizationScope: text('authorization_scope').notNull(),
|
|
||||||
payloadDigest: text('payload_digest').notNull(),
|
|
||||||
replayMode: text('replay_mode').notNull().default('actor-bound'),
|
|
||||||
// Committed-outcome reference (the enrolled agent's id). Snapshot value,
|
|
||||||
// no FK: the fence must keep answering replays after a legacy DELETE.
|
|
||||||
outcomeAgentId: uuid('outcome_agent_id').notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex('agent_idempotency_fence_operation_key_idx').on(t.operation, t.idempotencyKey),
|
|
||||||
check(
|
|
||||||
'agent_idempotency_fence_replay_mode_check',
|
|
||||||
sql`replay_mode IN ('actor-bound', 'shared')`,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ usage_error() {
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue|--number)
|
-i|--issue)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE="$2"
|
ISSUE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
BODY_FILE=""
|
|
||||||
|
|
||||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
@@ -25,7 +24,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue|--number)
|
-i|--issue)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -37,18 +36,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
|
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number Issue number (required; canonical)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -i, --issue Alias for --number"
|
|
||||||
echo " -b, --body Comment to add before closing (optional; canonical)"
|
echo " -b, --body Comment to add before closing (optional; canonical)"
|
||||||
echo " -c, --comment Alias for --body"
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
@@ -62,19 +54,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
usage_error "issue number is required (-i/--issue)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
BODY_FILE=""
|
|
||||||
LOGIN_OVERRIDE=""
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
@@ -46,7 +45,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue|--number)
|
-i|--issue)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -59,12 +58,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-l|--login)
|
-l|--login)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LOGIN_OVERRIDE="$2"
|
LOGIN_OVERRIDE="$2"
|
||||||
@@ -74,8 +67,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number Issue number (required; canonical)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -i, --issue Alias for --number"
|
|
||||||
echo " -b, --body Comment text (required; canonical)"
|
echo " -b, --body Comment text (required; canonical)"
|
||||||
echo " -c, --comment Alias for --body"
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||||
@@ -90,19 +82,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
usage_error "issue number is required (-i/--issue)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Default values
|
# Default values
|
||||||
TITLE=""
|
TITLE=""
|
||||||
BODY=""
|
BODY=""
|
||||||
BODY_FILE=""
|
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
INTERACTIVE=false
|
INTERACTIVE=false
|
||||||
@@ -101,12 +100,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BODY="$2"
|
BODY="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-l|--labels)
|
-l|--labels)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABELS="$2"
|
LABELS="$2"
|
||||||
@@ -131,19 +124,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ "$INTERACTIVE" == true ]]; then
|
if [[ "$INTERACTIVE" == true ]]; then
|
||||||
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||||
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
TITLE=""
|
TITLE=""
|
||||||
BODY=""
|
BODY=""
|
||||||
BODY_FILE=""
|
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue|--number)
|
-i|--issue)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -41,12 +40,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BODY="$2"
|
BODY="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-l|--labels)
|
-l|--labels)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABELS="$2"
|
LABELS="$2"
|
||||||
@@ -61,8 +54,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>]"
|
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number Issue number (required; canonical)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -i, --issue Alias for --number"
|
|
||||||
echo " -t, --title New title"
|
echo " -t, --title New title"
|
||||||
echo " -b, --body New body/description"
|
echo " -b, --body New body/description"
|
||||||
echo " -l, --labels Labels (comma-separated, replaces existing)"
|
echo " -l, --labels Labels (comma-separated, replaces existing)"
|
||||||
@@ -78,19 +70,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
usage_error "issue number is required (-i/--issue)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
STATE="$2"
|
STATE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--label|--labels)
|
-l|--label)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABEL="$2"
|
LABEL="$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
BODY_FILE=""
|
|
||||||
|
|
||||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
@@ -24,7 +23,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue|--number)
|
-i|--issue)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -36,18 +35,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
|
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number Issue number (required; canonical)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -i, --issue Alias for --number"
|
|
||||||
echo " -b, --body Comment to add when reopening (optional; canonical)"
|
echo " -b, --body Comment to add when reopening (optional; canonical)"
|
||||||
echo " -c, --comment Alias for --body"
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
@@ -61,19 +53,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
usage_error "issue number is required (-i/--issue)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ if comments:
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue|--number)
|
-i|--issue)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -90,8 +90,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo "Usage: issue-view.sh -i <issue_number>"
|
echo "Usage: issue-view.sh -i <issue_number>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number Issue number (required; canonical)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -i, --issue Alias for --number"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Comments are always included (tea --comments / Gitea API /comments)."
|
echo "Comments are always included (tea --comments / Gitea API /comments)."
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
BODY_FILE=""
|
|
||||||
|
|
||||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
@@ -36,12 +35,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
|
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -60,19 +53,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
usage_error "PR number is required (-n/--number)"
|
usage_error "PR number is required (-n/--number)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Default values
|
# Default values
|
||||||
TITLE=""
|
TITLE=""
|
||||||
BODY=""
|
BODY=""
|
||||||
BODY_FILE=""
|
|
||||||
BASE_BRANCH=""
|
BASE_BRANCH=""
|
||||||
HEAD_BRANCH=""
|
HEAD_BRANCH=""
|
||||||
LABELS=""
|
LABELS=""
|
||||||
@@ -159,12 +158,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BODY="$2"
|
BODY="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-B|--base)
|
-B|--base)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
BASE_BRANCH="$2"
|
BASE_BRANCH="$2"
|
||||||
@@ -204,19 +197,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# If no title but issue provided, generate title
|
# If no title but issue provided, generate title
|
||||||
if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then
|
if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then
|
||||||
TITLE="Fixes #$ISSUE"
|
TITLE="Fixes #$ISSUE"
|
||||||
|
|||||||
@@ -13,33 +13,21 @@ OUTPUT_FILE=""
|
|||||||
REPO_OVERRIDE=""
|
REPO_OVERRIDE=""
|
||||||
HOST_OVERRIDE=""
|
HOST_OVERRIDE=""
|
||||||
|
|
||||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
|
||||||
# distinct from provider, credential, and verification failures (exit 1).
|
|
||||||
usage_error() {
|
|
||||||
echo "Error: $*" >&2
|
|
||||||
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>] (see --help)" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-n|--number)
|
-n|--number)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-o|--output)
|
-o|--output)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
OUTPUT_FILE="$2"
|
OUTPUT_FILE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-r|--repo)
|
-r|--repo)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--host)
|
--host)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
HOST_OVERRIDE="$2"
|
HOST_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -52,18 +40,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
|
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
|
||||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
echo ""
|
|
||||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
usage_error "unknown option: $1"
|
echo "Unknown option: $1"
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
usage_error "PR number is required (-n/--number)"
|
echo "Error: PR number is required (-n)" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
TITLE=""
|
TITLE=""
|
||||||
BODY=""
|
BODY=""
|
||||||
BODY_FILE=""
|
|
||||||
BASE_BRANCH=""
|
BASE_BRANCH=""
|
||||||
DRAFT_MODE=""
|
DRAFT_MODE=""
|
||||||
LOGIN_OVERRIDE=""
|
LOGIN_OVERRIDE=""
|
||||||
@@ -66,7 +65,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
|
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
|
||||||
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
|
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
|
||||||
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
|
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
|
||||||
--body-file) [[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"; BODY_FILE="$2"; shift 2 ;;
|
|
||||||
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
|
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||||
--draft)
|
--draft)
|
||||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||||
@@ -82,19 +80,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
|
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
|
||||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
|
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
|
||||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||||
|
|||||||
@@ -34,41 +34,29 @@ Examples:
|
|||||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-2}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
|
||||||
# distinct from provider, credential, and verification failures (exit 1).
|
|
||||||
usage_error() {
|
|
||||||
echo "Error: $*" >&2
|
|
||||||
usage >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-s|--state)
|
-s|--state)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
STATE="$2"
|
STATE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--label|--labels)
|
-l|--label)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
LABEL="$2"
|
LABEL="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-a|--author)
|
-a|--author)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
AUTHOR="$2"
|
AUTHOR="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-n|--limit)
|
-n|--limit)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
LIMIT="$2"
|
LIMIT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-r|--repo)
|
-r|--repo)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -76,7 +64,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
usage_error "unknown option: $1"
|
echo "Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -12,23 +12,13 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
OUTPUT_FILE=""
|
OUTPUT_FILE=""
|
||||||
|
|
||||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
|
||||||
# distinct from provider, credential, and verification failures (exit 1).
|
|
||||||
usage_error() {
|
|
||||||
echo "Error: $*" >&2
|
|
||||||
echo "Usage: pr-metadata.sh -n <pr_number> [-o <output_file>] (see --help)" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-n|--number)
|
-n|--number)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-o|--output)
|
-o|--output)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
OUTPUT_FILE="$2"
|
OUTPUT_FILE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -39,18 +29,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
echo ""
|
|
||||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
usage_error "unknown option: $1"
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
usage_error "PR number is required (-n/--number)"
|
echo "Error: PR number is required (-n)" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
write_metadata() {
|
write_metadata() {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
ACTION=""
|
ACTION=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
BODY_FILE=""
|
|
||||||
LOGIN_OVERRIDE=""
|
LOGIN_OVERRIDE=""
|
||||||
REPO_OVERRIDE=""
|
REPO_OVERRIDE=""
|
||||||
HOST_OVERRIDE=""
|
HOST_OVERRIDE=""
|
||||||
@@ -72,12 +71,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--body-file)
|
|
||||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
|
||||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
|
||||||
BODY_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-l|--login)
|
-l|--login)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LOGIN_OVERRIDE="$2"
|
LOGIN_OVERRIDE="$2"
|
||||||
@@ -115,19 +108,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
|
||||||
# exclusive with an explicit --body/--comment value.
|
|
||||||
if [[ -n "$BODY_FILE" ]]; then
|
|
||||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
|
||||||
if [[ "$BODY_FILE" == "-" ]]; then
|
|
||||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
|
||||||
else
|
|
||||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
|
||||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
usage_error "PR number is required (-n/--number)"
|
usage_error "PR number is required (-n/--number)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -11,23 +11,13 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
REPO_OVERRIDE=""
|
REPO_OVERRIDE=""
|
||||||
|
|
||||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
|
||||||
# distinct from provider, credential, and verification failures (exit 1).
|
|
||||||
usage_error() {
|
|
||||||
echo "Error: $*" >&2
|
|
||||||
echo "Usage: pr-view.sh -n <pr_number> [-r owner/repo] (see --help)" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-n|--number)
|
-n|--number)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-r|--repo)
|
-r|--repo)
|
||||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
|
||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -38,18 +28,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -r, --repo Repository slug (default: infer from git origin)"
|
echo " -r, --repo Repository slug (default: infer from git origin)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
echo ""
|
|
||||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
usage_error "unknown option: $1"
|
echo "Unknown option: $1"
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
usage_error "PR number is required (-n/--number)"
|
echo "Error: PR number is required (-n)"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
|||||||
@@ -161,30 +161,6 @@ rc=0
|
|||||||
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
||||||
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
||||||
|
|
||||||
# R3 body-file arms (2026-08-29): --body-file <path> and '-' (stdin).
|
|
||||||
BF_FILE="$WORK_DIR/body.md"
|
|
||||||
printf 'line one\nline two\n' > "$BF_FILE"
|
|
||||||
|
|
||||||
# File loads the body: parse acceptance then credential-class failure
|
|
||||||
# (sandboxed runner: rc nonzero and NOT 2).
|
|
||||||
rc=0
|
|
||||||
run_wrapper_sandboxed -i 5 --body-file "$BF_FILE" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -ne 0 ]] || fail "body-file arm unexpectedly succeeded in the sandbox"
|
|
||||||
[[ "$rc" -ne 2 ]] || fail "body-file arm misclassified credential failure as a usage error"
|
|
||||||
|
|
||||||
# Stdin form loads the body the same way.
|
|
||||||
rc=0
|
|
||||||
printf 'from stdin' | run_wrapper_sandboxed -i 5 --body-file - >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -ne 0 && "$rc" -ne 2 ]] || fail "body-file stdin arm rc=$rc (want nonzero, not 2)"
|
|
||||||
|
|
||||||
# Mutually exclusive with --body: rc 2.
|
|
||||||
expect_rc 2 "body-file + body exclusive" -i 5 --body-file "$BF_FILE" -b explicit
|
|
||||||
expect_stderr "mutually exclusive" "exclusivity message on stderr"
|
|
||||||
|
|
||||||
# Missing file: rc 2 naming the path.
|
|
||||||
expect_rc 2 "missing body file" -i 5 --body-file "$WORK_DIR/nope.md"
|
|
||||||
expect_stderr "not readable" "missing-file message on stderr"
|
|
||||||
|
|
||||||
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
||||||
# invocation is the only permitted entry in the probe log).
|
# invocation is the only permitted entry in the probe log).
|
||||||
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Usage-error contract for pr-diff.sh (R4, 2026-08-28).
|
|
||||||
#
|
|
||||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
|
||||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
|
||||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
|
||||||
#
|
|
||||||
# Arms:
|
|
||||||
# 1. --help and -h exit 0 and print usage.
|
|
||||||
# 2. Unknown option exits 2 with the message on stderr.
|
|
||||||
# 3. Missing required -i exits 2 (stderr).
|
|
||||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
|
||||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
|
||||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
|
||||||
# ever read and no provider is contacted.
|
|
||||||
# 6. No arm performs any provider request (PATH shims record every
|
|
||||||
# invocation; the probe log must stay empty).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-diff-usage}"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
|
||||||
OUT_FILE="$WORK_DIR/out.log"
|
|
||||||
ERR_FILE="$WORK_DIR/err.log"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
|
||||||
: > "$PROBE_LOG"
|
|
||||||
|
|
||||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
|
||||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
|
||||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
|
||||||
for tool in gh tea curl; do
|
|
||||||
cat > "$BIN_DIR/$tool" <<STUB
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
echo "$tool \$*" >> "$PROBE_LOG"
|
|
||||||
exit 99
|
|
||||||
STUB
|
|
||||||
chmod +x "$BIN_DIR/$tool"
|
|
||||||
done
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-diff.sh" "$@" )
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
|
||||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
|
||||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
|
||||||
run_wrapper_sandboxed() {
|
|
||||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
|
||||||
(
|
|
||||||
cd "$WORK_DIR"
|
|
||||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
|
||||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
|
||||||
"$SCRIPT_DIR/pr-diff.sh" "$@"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $*" >&2
|
|
||||||
echo "--- stderr ---" >&2
|
|
||||||
cat "$ERR_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
|
||||||
local want="$1" desc="$2" rc=0
|
|
||||||
shift 2
|
|
||||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
|
||||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Help exits 0 and prints usage.
|
|
||||||
expect_rc 0 "--help exits 0" --help
|
|
||||||
grep -q "Usage: pr-diff.sh" "$OUT_FILE" || fail "--help did not print usage"
|
|
||||||
expect_rc 0 "-h exits 0" -h
|
|
||||||
|
|
||||||
# 2. Unknown option: rc 2, stderr.
|
|
||||||
expect_rc 2 "unknown option exits 2" --bogus
|
|
||||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
|
||||||
|
|
||||||
# 3. Missing required PR number: rc 2, stderr.
|
|
||||||
expect_rc 2 "missing -n exits 2"
|
|
||||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
|
||||||
for flag in -n -o -r --number --output --repo --host; do
|
|
||||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
|
||||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
|
||||||
# -b --help previously consumed --help as the body and performed the write).
|
|
||||||
expect_rc 2 "option-like value rejected" -n --help
|
|
||||||
expect_rc 2 "short flag value rejected" -n -h
|
|
||||||
expect_stderr "requires a value" "short flag value message on stderr"
|
|
||||||
expect_stderr "requires a value" "option-like value message on stderr"
|
|
||||||
|
|
||||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
|
||||||
if [[ -s "$PROBE_LOG" ]]; then
|
|
||||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
|
||||||
cat "$PROBE_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
|
||||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
|
||||||
for flag in -n; do
|
|
||||||
rc=0
|
|
||||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
|
||||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
|
||||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
|
||||||
# comment parses, then falls back to the API. Hermeticity for this
|
|
||||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
|
||||||
# the arm above proves only parse acceptance and non-usage classification.
|
|
||||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
|
||||||
|
|
||||||
echo "pr-diff.sh usage-contract regression passed (R1/R4)"
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Usage-error contract for pr-list.sh (R4, 2026-08-28).
|
|
||||||
#
|
|
||||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
|
||||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
|
||||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
|
||||||
#
|
|
||||||
# Arms:
|
|
||||||
# 1. --help and -h exit 0 and print usage.
|
|
||||||
# 2. Unknown option exits 2 with the message on stderr.
|
|
||||||
# 3. Missing required -i exits 2 (stderr).
|
|
||||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
|
||||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
|
||||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
|
||||||
# ever read and no provider is contacted.
|
|
||||||
# 6. No arm performs any provider request (PATH shims record every
|
|
||||||
# invocation; the probe log must stay empty).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-list-usage}"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
|
||||||
OUT_FILE="$WORK_DIR/out.log"
|
|
||||||
ERR_FILE="$WORK_DIR/err.log"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
|
||||||
: > "$PROBE_LOG"
|
|
||||||
|
|
||||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
|
||||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
|
||||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
|
||||||
for tool in gh tea curl; do
|
|
||||||
cat > "$BIN_DIR/$tool" <<STUB
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
echo "$tool \$*" >> "$PROBE_LOG"
|
|
||||||
exit 99
|
|
||||||
STUB
|
|
||||||
chmod +x "$BIN_DIR/$tool"
|
|
||||||
done
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-list.sh" "$@" )
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
|
||||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
|
||||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
|
||||||
run_wrapper_sandboxed() {
|
|
||||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
|
||||||
(
|
|
||||||
cd "$WORK_DIR"
|
|
||||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
|
||||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
|
||||||
"$SCRIPT_DIR/pr-list.sh" "$@"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $*" >&2
|
|
||||||
echo "--- stderr ---" >&2
|
|
||||||
cat "$ERR_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
|
||||||
local want="$1" desc="$2" rc=0
|
|
||||||
shift 2
|
|
||||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
|
||||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Help exits 0 and prints usage.
|
|
||||||
expect_rc 0 "--help exits 0" --help
|
|
||||||
grep -q "Usage: pr-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
|
||||||
expect_rc 0 "-h exits 0" -h
|
|
||||||
|
|
||||||
# 2. Unknown option: rc 2, stderr.
|
|
||||||
expect_rc 2 "unknown option exits 2" --bogus
|
|
||||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
|
||||||
|
|
||||||
# 3. Missing required PR number: rc 2, stderr.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
|
||||||
for flag in -s -l -a -n -r --state --label --author --limit --repo; do
|
|
||||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
|
||||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
|
||||||
# -b --help previously consumed --help as the body and performed the write).
|
|
||||||
expect_rc 2 "option-like value rejected" -s --help
|
|
||||||
expect_rc 2 "short flag value rejected" -s -h
|
|
||||||
expect_stderr "requires a value" "short flag value message on stderr"
|
|
||||||
expect_stderr "requires a value" "option-like value message on stderr"
|
|
||||||
|
|
||||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
|
||||||
if [[ -s "$PROBE_LOG" ]]; then
|
|
||||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
|
||||||
cat "$PROBE_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
|
||||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
|
||||||
for flag in -s; do
|
|
||||||
rc=0
|
|
||||||
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
|
||||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
|
||||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
|
||||||
# comment parses, then falls back to the API. Hermeticity for this
|
|
||||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
|
||||||
# the arm above proves only parse acceptance and non-usage classification.
|
|
||||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
|
||||||
|
|
||||||
echo "pr-list.sh usage-contract regression passed (R1/R4)"
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Usage-error contract for pr-metadata.sh (R4, 2026-08-28).
|
|
||||||
#
|
|
||||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
|
||||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
|
||||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
|
||||||
#
|
|
||||||
# Arms:
|
|
||||||
# 1. --help and -h exit 0 and print usage.
|
|
||||||
# 2. Unknown option exits 2 with the message on stderr.
|
|
||||||
# 3. Missing required -i exits 2 (stderr).
|
|
||||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
|
||||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
|
||||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
|
||||||
# ever read and no provider is contacted.
|
|
||||||
# 6. No arm performs any provider request (PATH shims record every
|
|
||||||
# invocation; the probe log must stay empty).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-usage}"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
|
||||||
OUT_FILE="$WORK_DIR/out.log"
|
|
||||||
ERR_FILE="$WORK_DIR/err.log"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
|
||||||
: > "$PROBE_LOG"
|
|
||||||
|
|
||||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
|
||||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
|
||||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
|
||||||
for tool in gh tea curl; do
|
|
||||||
cat > "$BIN_DIR/$tool" <<STUB
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
echo "$tool \$*" >> "$PROBE_LOG"
|
|
||||||
exit 99
|
|
||||||
STUB
|
|
||||||
chmod +x "$BIN_DIR/$tool"
|
|
||||||
done
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-metadata.sh" "$@" )
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
|
||||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
|
||||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
|
||||||
run_wrapper_sandboxed() {
|
|
||||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
|
||||||
(
|
|
||||||
cd "$WORK_DIR"
|
|
||||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
|
||||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
|
||||||
"$SCRIPT_DIR/pr-metadata.sh" "$@"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $*" >&2
|
|
||||||
echo "--- stderr ---" >&2
|
|
||||||
cat "$ERR_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
|
||||||
local want="$1" desc="$2" rc=0
|
|
||||||
shift 2
|
|
||||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
|
||||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Help exits 0 and prints usage.
|
|
||||||
expect_rc 0 "--help exits 0" --help
|
|
||||||
grep -q "Usage: pr-metadata.sh" "$OUT_FILE" || fail "--help did not print usage"
|
|
||||||
expect_rc 0 "-h exits 0" -h
|
|
||||||
|
|
||||||
# 2. Unknown option: rc 2, stderr.
|
|
||||||
expect_rc 2 "unknown option exits 2" --bogus
|
|
||||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
|
||||||
|
|
||||||
# 3. Missing required PR number: rc 2, stderr.
|
|
||||||
expect_rc 2 "missing -n exits 2"
|
|
||||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
|
||||||
for flag in -n -o --number --output; do
|
|
||||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
|
||||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
|
||||||
# -b --help previously consumed --help as the body and performed the write).
|
|
||||||
expect_rc 2 "option-like value rejected" -n --help
|
|
||||||
expect_rc 2 "short flag value rejected" -n -h
|
|
||||||
expect_stderr "requires a value" "short flag value message on stderr"
|
|
||||||
expect_stderr "requires a value" "option-like value message on stderr"
|
|
||||||
|
|
||||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
|
||||||
if [[ -s "$PROBE_LOG" ]]; then
|
|
||||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
|
||||||
cat "$PROBE_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
|
||||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
|
||||||
for flag in -n; do
|
|
||||||
rc=0
|
|
||||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
|
||||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
|
||||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
|
||||||
# comment parses, then falls back to the API. Hermeticity for this
|
|
||||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
|
||||||
# the arm above proves only parse acceptance and non-usage classification.
|
|
||||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
|
||||||
|
|
||||||
echo "pr-metadata.sh usage-contract regression passed (R1/R4)"
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Usage-error contract for pr-view.sh (R4, 2026-08-28).
|
|
||||||
#
|
|
||||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
|
||||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
|
||||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
|
||||||
#
|
|
||||||
# Arms:
|
|
||||||
# 1. --help and -h exit 0 and print usage.
|
|
||||||
# 2. Unknown option exits 2 with the message on stderr.
|
|
||||||
# 3. Missing required -i exits 2 (stderr).
|
|
||||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
|
||||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
|
||||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
|
||||||
# ever read and no provider is contacted.
|
|
||||||
# 6. No arm performs any provider request (PATH shims record every
|
|
||||||
# invocation; the probe log must stay empty).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-view-usage}"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
|
||||||
OUT_FILE="$WORK_DIR/out.log"
|
|
||||||
ERR_FILE="$WORK_DIR/err.log"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
|
||||||
: > "$PROBE_LOG"
|
|
||||||
|
|
||||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
|
||||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
|
||||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
|
||||||
for tool in gh tea curl; do
|
|
||||||
cat > "$BIN_DIR/$tool" <<STUB
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
echo "$tool \$*" >> "$PROBE_LOG"
|
|
||||||
exit 99
|
|
||||||
STUB
|
|
||||||
chmod +x "$BIN_DIR/$tool"
|
|
||||||
done
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-view.sh" "$@" )
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
|
||||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
|
||||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
|
||||||
run_wrapper_sandboxed() {
|
|
||||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
|
||||||
(
|
|
||||||
cd "$WORK_DIR"
|
|
||||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
|
||||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
|
||||||
"$SCRIPT_DIR/pr-view.sh" "$@"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $*" >&2
|
|
||||||
echo "--- stderr ---" >&2
|
|
||||||
cat "$ERR_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
|
||||||
local want="$1" desc="$2" rc=0
|
|
||||||
shift 2
|
|
||||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
|
||||||
}
|
|
||||||
|
|
||||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
|
||||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Help exits 0 and prints usage.
|
|
||||||
expect_rc 0 "--help exits 0" --help
|
|
||||||
grep -q "Usage: pr-view.sh" "$OUT_FILE" || fail "--help did not print usage"
|
|
||||||
expect_rc 0 "-h exits 0" -h
|
|
||||||
|
|
||||||
# 2. Unknown option: rc 2, stderr.
|
|
||||||
expect_rc 2 "unknown option exits 2" --bogus
|
|
||||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
|
||||||
|
|
||||||
# 3. Missing required PR number: rc 2, stderr.
|
|
||||||
expect_rc 2 "missing -n exits 2"
|
|
||||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
|
||||||
for flag in -n -r --number --repo; do
|
|
||||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
|
||||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
|
||||||
# -b --help previously consumed --help as the body and performed the write).
|
|
||||||
expect_rc 2 "option-like value rejected" -n --help
|
|
||||||
expect_rc 2 "short flag value rejected" -n -h
|
|
||||||
expect_stderr "requires a value" "short flag value message on stderr"
|
|
||||||
expect_stderr "requires a value" "option-like value message on stderr"
|
|
||||||
|
|
||||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
|
||||||
if [[ -s "$PROBE_LOG" ]]; then
|
|
||||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
|
||||||
cat "$PROBE_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
|
||||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
|
||||||
for flag in -n; do
|
|
||||||
rc=0
|
|
||||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
|
||||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
|
||||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
|
||||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
|
||||||
# comment parses, then falls back to the API. Hermeticity for this
|
|
||||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
|
||||||
# the arm above proves only parse acceptance and non-usage classification.
|
|
||||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
|
||||||
|
|
||||||
echo "pr-view.sh usage-contract regression passed (R1/R4)"
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -15,17 +15,6 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
||||||
|
|
||||||
describe('arg translation', () => {
|
describe('arg translation', () => {
|
||||||
// B2 (2026-08-29): when --socket is omitted, the CLI forwards NO -L and
|
|
||||||
// agent-send.sh's own resolution governs (explicit -L > MOSAIC_TMUX_SOCKET
|
|
||||||
// > unique hit > ambiguity refusal). Forwarding a guessed default here
|
|
||||||
// would defeat that resolution and reintroduce the stale-twin defect.
|
|
||||||
it('omits -L entirely when --socket is not given (tool-owned resolution)', () => {
|
|
||||||
const args = tmuxSendArgs('orch-01', 'hello', {});
|
|
||||||
expect(args).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
|
||||||
expect(args).not.toContain('-L');
|
|
||||||
expect(args.join(' ')).not.toContain('-L');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
||||||
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ export function registerCommsCommand(program: Command): void {
|
|||||||
.description('send <target> [message...] — same-host tmux unless --site is given')
|
.description('send <target> [message...] — same-host tmux unless --site is given')
|
||||||
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
||||||
.option('--file <path>', 'message body from file (same-host path only)')
|
.option('--file <path>', 'message body from file (same-host path only)')
|
||||||
.option(
|
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
|
||||||
'--socket <name>',
|
|
||||||
'tmux socket for the same-host send (e.g. mosaic-fleet). OMIT it to let agent-send.sh resolve the socket (explicit -L > MOSAIC_TMUX_SOCKET > unique hit; ambiguity refuses with its exit 4).',
|
|
||||||
)
|
|
||||||
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
||||||
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
||||||
.argument('<target>', 'destination seat (session name)')
|
.argument('<target>', 'destination seat (session name)')
|
||||||
|
|||||||
Reference in New Issue
Block a user