Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ab5161db | ||
|
|
ab2c15c0bf | ||
|
|
1ad328af63 | ||
|
|
41c11388e4 | ||
|
|
143ba0f57a | ||
|
|
ee815a72b1 | ||
|
|
94d626dff9 | ||
|
|
ee6c842918 | ||
|
|
ba3b854d50 | ||
|
|
c7a7fd07cc | ||
|
|
5399c6b7e7 | ||
|
|
e09b8783b4 | ||
|
|
abb0c93601 | ||
|
|
e67cced273 | ||
|
|
635cb1f666 |
@@ -108,11 +108,13 @@ 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,6 +77,12 @@ 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';
|
||||||
@@ -102,6 +108,12 @@ 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';
|
||||||
|
|||||||
+34
-17
@@ -197,24 +197,41 @@ 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).
|
||||||
|
|
||||||
### 12. Decision registry
|
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
|
||||||
|
|
||||||
| ID | Decision (short form) |
|
The stack ships a tiered deployment target, additive to the architecture
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------ |
|
gate (D8): (1) Standalone tier — docker compose is the canonical
|
||||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
single-host deployment: postgres, valkey, openbao, gateway, appservice
|
||||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
and the served webUI in one composition, with migrations, health checks,
|
||||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
and a documented install/upgrade path; the registry (CI-published
|
||||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
images) is the only deployment source. (2) Enterprise tier — Kubernetes
|
||||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
manifests for the same service set, phase-gated on the standalone tier
|
||||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
holding its acceptance bar. The v1 acceptance bar for the standalone
|
||||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
|
||||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
open a PR to this repo; CI validates it; the running deployment adopts
|
||||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
the merged change (pull + restart). Federation (D3 clause) remains
|
||||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
deferred and unforeclosed. Implementation plan:
|
||||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
docs/plans/2026-08-30_containerization.md.
|
||||||
| 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 |
|
## 12. Decision registry
|
||||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
|
||||||
|
| ID | Decision (short form) |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||||
|
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||||
|
| 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 |
|
||||||
|
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||||
|
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||||
|
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||||
|
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||||
|
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||||
|
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||||
|
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||||
|
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||||
|
| 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 |
|
||||||
|
| 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`).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||||
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
|
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
|
||||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||||
|
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
|
||||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
kind: verification
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
|
# P0 Field-Map Currency Verification — 2026-08-29
|
||||||
|
|
||||||
|
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
|
||||||
|
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
|
||||||
|
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
|
||||||
|
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
|
||||||
|
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
|
||||||
|
integration trunk `next`. This document re-verifies the map's currency at the
|
||||||
|
lane-opening head and records the measured pre-expand writer inventory. It amends
|
||||||
|
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
|
||||||
|
wins.
|
||||||
|
|
||||||
|
## 1. Currency verification (measured)
|
||||||
|
|
||||||
|
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
|
||||||
|
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
|
||||||
|
2026-08-29).
|
||||||
|
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
|
||||||
|
**300 insertions, 0 deletions** — no existing declaration changed.
|
||||||
|
- The additions: the new declarations `logicalAgentConnectorLeases`,
|
||||||
|
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
|
||||||
|
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
|
||||||
|
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
|
||||||
|
column on the unmapped BetterAuth `accounts` table (shipped as
|
||||||
|
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
|
||||||
|
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
|
||||||
|
source.
|
||||||
|
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
|
||||||
|
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
|
||||||
|
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
|
||||||
|
therefore current at `origin/main` itself, and at the trunk head beyond it.
|
||||||
|
|
||||||
|
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
|
||||||
|
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
|
||||||
|
`agents`, fleet `backlog` — is byte-identical to the declaration the map
|
||||||
|
inspected. The field map is current as written. No row changes.
|
||||||
|
|
||||||
|
## 2. Refinements available since the pin (context, not map changes)
|
||||||
|
|
||||||
|
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
|
||||||
|
layer; its "bootstrap workspace" backfill step now has a shipped target:
|
||||||
|
`workspaces` (uuid PK, chained under platform projects per
|
||||||
|
`docs/requirements/hierarchy-schema.md`; hierarchy core in
|
||||||
|
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
|
||||||
|
`0019_volatile_killraven.sql`, visibility in
|
||||||
|
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
|
||||||
|
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
|
||||||
|
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
|
||||||
|
task lane's `task_events`/`task_outbox` mirror the pattern but are
|
||||||
|
workspace-scoped with the composite `(workspace_id, id)` key required by
|
||||||
|
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
|
||||||
|
shared store for task events.
|
||||||
|
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
|
||||||
|
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
|
||||||
|
the trunk head pinned above, so no reinterpretation of the acceptance text
|
||||||
|
is needed.
|
||||||
|
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
|
||||||
|
ownership to the mission seat `coder2`. Seat identity is operational fleet
|
||||||
|
state, not resolvable from this repository, and is outside this document's
|
||||||
|
scope. The invariant §6 protects binds regardless of seat and is restated
|
||||||
|
here as binding on the M4-3 schema lane: exactly one lane generates
|
||||||
|
migrations at a time; expand is additive; no drop/rename/narrow; constraints
|
||||||
|
validate before NOT NULL.
|
||||||
|
|
||||||
|
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
|
||||||
|
|
||||||
|
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
|
||||||
|
expand DDL.
|
||||||
|
|
||||||
|
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
|
||||||
|
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
||||||
|
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
||||||
|
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
||||||
|
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
||||||
|
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
|
||||||
|
(`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_update_task` write the separately mapped `tasks` table, a legitimate
|
||||||
|
N-1 writer through the compatibility window.
|
||||||
|
- The ratified contract 5 decision
|
||||||
|
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
|
||||||
|
the legacy endpoints — including MCP `brain_*` task mutations — for new
|
||||||
|
consumers, while existing consumers keep working until each surface's owning
|
||||||
|
contract retires it. It does not stop existing writes.
|
||||||
|
|
||||||
|
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
|
||||||
|
on `mission_tasks` create/update) MUST land before the expand DDL of migration
|
||||||
|
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
|
||||||
|
status is ignored; the column stays declared and readable through the whole
|
||||||
|
N-1 window; retirement only after no readers).
|
||||||
|
|
||||||
|
## 4. Lane opening
|
||||||
|
|
||||||
|
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
|
||||||
|
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
|
||||||
|
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
|
||||||
|
map (§5.4), and required migration tests (§5.5) bind as written. External
|
||||||
|
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
|
||||||
|
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
|
||||||
|
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 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,6 +13,10 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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,6 +3,24 @@ 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[]> {
|
||||||
@@ -30,14 +48,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(data).returning();
|
const rows = await db.insert(missionTasks).values(stripStatus(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({ ...data, updatedAt: new Date() })
|
.set({ ...stripStatus(data), updatedAt: new Date() })
|
||||||
.where(eq(missionTasks.id, id))
|
.where(eq(missionTasks.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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,6 +148,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +302,11 @@ 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(),
|
||||||
},
|
},
|
||||||
@@ -1279,3 +1284,109 @@ 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)
|
-i|--issue|--number)
|
||||||
[[ $# -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,6 +12,7 @@ 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 +25,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue|--number)
|
||||||
[[ $# -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,11 +37,18 @@ 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 " -i, --issue Issue number (required)"
|
echo " -n, --number Issue number (required; canonical)"
|
||||||
|
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"
|
||||||
@@ -54,6 +62,19 @@ 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,6 +31,7 @@ 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,
|
||||||
@@ -45,7 +46,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue|--number)
|
||||||
[[ $# -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
|
||||||
@@ -58,6 +59,12 @@ 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"
|
||||||
@@ -67,7 +74,8 @@ 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 " -i, --issue Issue number (required)"
|
echo " -n, --number Issue number (required; canonical)"
|
||||||
|
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"
|
||||||
@@ -82,6 +90,19 @@ 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,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Default values
|
# Default values
|
||||||
TITLE=""
|
TITLE=""
|
||||||
BODY=""
|
BODY=""
|
||||||
|
BODY_FILE=""
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
INTERACTIVE=false
|
INTERACTIVE=false
|
||||||
@@ -100,6 +101,12 @@ 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"
|
||||||
@@ -124,6 +131,19 @@ 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,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
TITLE=""
|
TITLE=""
|
||||||
BODY=""
|
BODY=""
|
||||||
|
BODY_FILE=""
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue|--number)
|
||||||
[[ $# -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
|
||||||
@@ -40,6 +41,12 @@ 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"
|
||||||
@@ -54,7 +61,8 @@ 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 " -i, --issue Issue number (required)"
|
echo " -n, --number Issue number (required; canonical)"
|
||||||
|
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)"
|
||||||
@@ -70,6 +78,19 @@ 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)
|
-l|--label|--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)"
|
||||||
LABEL="$2"
|
LABEL="$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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
|
||||||
@@ -23,7 +24,7 @@ usage_error() {
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue|--number)
|
||||||
[[ $# -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
|
||||||
@@ -35,11 +36,18 @@ 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 " -i, --issue Issue number (required)"
|
echo " -n, --number Issue number (required; canonical)"
|
||||||
|
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"
|
||||||
@@ -53,6 +61,19 @@ 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)
|
-i|--issue|--number)
|
||||||
[[ $# -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,7 +90,8 @@ 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 " -i, --issue Issue number (required)"
|
echo " -n, --number Issue number (required; canonical)"
|
||||||
|
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,6 +11,7 @@ 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
|
||||||
@@ -35,6 +36,12 @@ 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 ""
|
||||||
@@ -53,6 +60,19 @@ 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,6 +10,7 @@ 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=""
|
||||||
@@ -158,6 +159,12 @@ 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"
|
||||||
@@ -197,6 +204,19 @@ 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,21 +13,33 @@ 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
|
||||||
;;
|
;;
|
||||||
@@ -40,18 +52,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
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
echo "Error: PR number is required (-n)" >&2
|
usage_error "PR number is required (-n/--number)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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=""
|
||||||
@@ -65,6 +66,7 @@ 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; }
|
||||||
@@ -80,6 +82,19 @@ 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,29 +34,41 @@ 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:-1}"
|
exit "${1:-2}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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)
|
-l|--label|--labels)
|
||||||
|
[[ $# -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
|
||||||
;;
|
;;
|
||||||
@@ -64,8 +76,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
usage_error "unknown option: $1"
|
||||||
usage
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -12,13 +12,23 @@ 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
|
||||||
;;
|
;;
|
||||||
@@ -29,18 +39,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
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
echo "Error: PR number is required (-n)" >&2
|
usage_error "PR number is required (-n/--number)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
write_metadata() {
|
write_metadata() {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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=""
|
||||||
@@ -71,6 +72,12 @@ 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"
|
||||||
@@ -108,6 +115,19 @@ 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,13 +11,23 @@ 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
|
||||||
;;
|
;;
|
||||||
@@ -28,18 +38,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
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
echo "Error: PR number is required (-n)"
|
usage_error "PR number is required (-n/--number)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
|||||||
@@ -161,6 +161,30 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/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)"
|
||||||
@@ -35,6 +35,9 @@
|
|||||||
#
|
#
|
||||||
# OPTIONS
|
# OPTIONS
|
||||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||||
|
#
|
||||||
|
# Exit 4: local target session exists on multiple socket servers and no
|
||||||
|
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||||
# -n DST_HOST hostname to show in the preamble for the target.
|
# -n DST_HOST hostname to show in the preamble for the target.
|
||||||
@@ -63,8 +66,10 @@
|
|||||||
# group 1 = src label group 2 = dst host:session
|
# group 1 = src label group 2 = dst host:session
|
||||||
# group 3 = class (absent => actionable) group 4 = message body
|
# group 3 = class (absent => actionable) group 4 = message body
|
||||||
#
|
#
|
||||||
# EXIT CODES (passed through from send-message.sh)
|
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||||
|
# 4 agent-send refusal: local target session exists on multiple socket
|
||||||
|
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
@@ -154,6 +159,61 @@ FULL="${PREAMBLE} ${MSG}"
|
|||||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||||
|
|
||||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||||
|
|
||||||
|
# Exact session matching for the sender target (codex PR #1466): without
|
||||||
|
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||||
|
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||||
|
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||||
|
# socket discovery so the discovery probes use the same target semantics
|
||||||
|
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||||
|
DST_TARGET="$DST_SESSION"
|
||||||
|
case "$DST_SESSION" in
|
||||||
|
=*) ;;
|
||||||
|
*:*)
|
||||||
|
# Compound target (session:win.pane): pin the SESSION component exact
|
||||||
|
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||||
|
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||||
|
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||||
|
;;
|
||||||
|
*) DST_TARGET="=$DST_SESSION" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||||
|
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||||
|
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||||
|
# no explicit or env choice exists and the session name lives on multiple
|
||||||
|
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||||
|
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||||
|
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||||
|
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||||
|
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||||
|
# leak into remote sends, where -L would target a socket on the remote
|
||||||
|
# host (codex PR #1466).
|
||||||
|
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||||
|
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||||
|
fi
|
||||||
|
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||||
|
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||||
|
hits=""
|
||||||
|
for sf in "$socket_dir"/*; do
|
||||||
|
[ -S "$sf" ] || continue
|
||||||
|
sname="${sf##*/}"
|
||||||
|
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||||
|
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||||
|
# would count as a false hit for target X (codex PR #1466).
|
||||||
|
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||||
|
done
|
||||||
|
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||||
|
if [ "$hit_count" -gt 1 ]; then
|
||||||
|
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||||
|
printf ' %s\n' $hits >&2
|
||||||
|
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||||
|
exit 4
|
||||||
|
elif [ "$hit_count" -eq 1 ]; then
|
||||||
|
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
socket_args=()
|
socket_args=()
|
||||||
if [ -n "$SOCKET_NAME" ]; then
|
if [ -n "$SOCKET_NAME" ]; then
|
||||||
socket_args=(-L "$SOCKET_NAME")
|
socket_args=(-L "$SOCKET_NAME")
|
||||||
@@ -161,9 +221,9 @@ fi
|
|||||||
|
|
||||||
if [ -z "$SSH_TARGET" ]; then
|
if [ -z "$SSH_TARGET" ]; then
|
||||||
# Local pane: call the canonical sender directly.
|
# Local pane: call the canonical sender directly.
|
||||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
|
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||||
else
|
else
|
||||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ SOCKET="mosaic-test-$RANDOM-$$"
|
|||||||
TARGET="target-$RANDOM"
|
TARGET="target-$RANDOM"
|
||||||
DEFAULT_TARGET="default-target-$RANDOM"
|
DEFAULT_TARGET="default-target-$RANDOM"
|
||||||
TMPDIR=$(mktemp -d)
|
TMPDIR=$(mktemp -d)
|
||||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
|
ART_OUT=$(mktemp)
|
||||||
|
AMB_OUT=$(mktemp)
|
||||||
|
AMB_ERR=$(mktemp)
|
||||||
|
A2_OUT=$(mktemp)
|
||||||
|
A2_ERR=$(mktemp)
|
||||||
|
UNIQ_OUT=$(mktemp)
|
||||||
|
UNIQ_ERR=$(mktemp)
|
||||||
|
TWIN="twin-$RANDOM-$$"
|
||||||
|
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true; rm -rf "$TMPDIR" $ART_OUT $AMB_OUT $AMB_ERR $A2_OUT $A2_ERR $UNIQ_OUT $UNIQ_ERR' EXIT
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
@@ -79,4 +87,83 @@ for i in $(seq 1 "$CONC_N"); do
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||||
|
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||||
|
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||||
|
|
||||||
|
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||||
|
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
|
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
|
amb_rc=0
|
||||||
|
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||||
|
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||||
|
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||||
|
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||||
|
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||||
|
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||||
|
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||||
|
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
|
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
|
a2_rc=0
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||||
|
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||||
|
sleep 0.2
|
||||||
|
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||||
|
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||||
|
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||||
|
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||||
|
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||||
|
fi
|
||||||
|
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||||
|
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||||
|
# and deliver there.
|
||||||
|
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||||
|
# #1466): a session named TWIN-old must not count as a hit for target
|
||||||
|
# TWIN (tmux target syntax prefix-matches without '=').
|
||||||
|
PSEUDO="${TWIN}-old"
|
||||||
|
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
|
A3_ERR=$(mktemp)
|
||||||
|
a3_rc=0
|
||||||
|
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||||
|
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||||
|
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||||
|
# (rc 1) instead of delivering into the prefix-named session.
|
||||||
|
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||||
|
a3_pane="$(tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null)" || a3_pane=""
|
||||||
|
if printf '%s' "$a3_pane" | grep -F "prefix trap" >/dev/null; then
|
||||||
|
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||||
|
fi
|
||||||
|
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||||
|
rm -f "$A3_ERR"
|
||||||
|
|
||||||
|
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||||
|
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||||
|
PSEUDO2="${TWIN}-old"
|
||||||
|
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||||
|
A4_ERR=$(mktemp)
|
||||||
|
a4_rc=0
|
||||||
|
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||||
|
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||||
|
a4_pane="$(tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null)" || a4_pane=""
|
||||||
|
if printf '%s' "$a4_pane" | grep -F "compound trap" >/dev/null; then
|
||||||
|
fail "compound delivery landed in the prefix-named session"
|
||||||
|
fi
|
||||||
|
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||||
|
rm -f "$A4_ERR"
|
||||||
|
|
||||||
|
uniq_rc=0
|
||||||
|
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||||
|
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||||
|
sleep 0.2
|
||||||
|
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||||
|
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||||
|
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||||
|
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||||
|
fail "auto-resolution cross-delivered to the default socket pane"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "ok - named tmux socket send tools"
|
echo "ok - named tmux socket send tools"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,6 +15,17 @@ 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,7 +62,10 @@ 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('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
|
.option(
|
||||||
|
'--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)')
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ describe('registerFleetCommand', () => {
|
|||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
|
'logins',
|
||||||
'migrate-v1',
|
'migrate-v1',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { homedir, hostname, userInfo } from 'node:os';
|
|||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import * as readline from 'node:readline';
|
import * as readline from 'node:readline';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
@@ -1606,6 +1606,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
|
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
|
||||||
.option('--roster <path>', 'Fleet roster path');
|
.option('--roster <path>', 'Fleet roster path');
|
||||||
|
|
||||||
|
// fleet logins (P4 gap closure, 2026-08-29): project per-seat git tokens
|
||||||
|
// from the brain into usable credentials. The implementation is the
|
||||||
|
// framework fleet tool; args pass through verbatim (--apply --seat X).
|
||||||
|
cmd
|
||||||
|
.command('logins')
|
||||||
|
.description('Per-seat git credentials: seat-logins.sh pass-through (--apply --seat <name>)')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((...rest: unknown[]) => {
|
||||||
|
const cmdObj = rest[rest.length - 1] as { args: string[] };
|
||||||
|
const script = `${frameworkRoot}/tools/fleet/seat-logins.sh`;
|
||||||
|
const result = spawnSync('bash', [script, ...cmdObj.args], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
process.exit(result.status ?? 125);
|
||||||
|
});
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('init')
|
.command('init')
|
||||||
.description('Initialize a local fleet roster')
|
.description('Initialize a local fleet roster')
|
||||||
|
|||||||
Reference in New Issue
Block a user