feat(core): add feedback tools contracts, validation, storage, and dedupe
* feat(core): add feedback report tool contracts and storage * fix(core): stabilize feedback dedupe and clear lint blocker * fix(core): harden feedback report parsing and resilience * docs: add feedback routing architecture and PR explainer * fix(core): remove dedupe collisions and share atomic writes * docs: add final PR403 bugbot resolution status * docs: formalize feedback pipeline and fork-aware execution design * docs: remove PR-specific review artifacts * docs: add durable feedback pipeline explainer * docs: add consent gates and journal semantics to feedback design * docs: add pr403 confidence checklist with openclaw dogfood evidence * docs: add work openclaw validation prompt helper page * docs: record work openclaw validation evidence * docs: remove pr-specific review artifacts from repo * fix(core): ignore confidence in feedback dedupe key
This commit is contained in:
parent
240d6423fb
commit
8e8cab771f
|
|
@ -0,0 +1,214 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Feedback Pipeline Architecture Explainer</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f2efe7;
|
||||
--panel: #fffdf8;
|
||||
--ink: #1f1a16;
|
||||
--muted: #6f6357;
|
||||
--brand: #165a72;
|
||||
--line: #dfd3c2;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
background:
|
||||
radial-gradient(circle at 12% 10%, #ebdfcb 0%, transparent 35%),
|
||||
radial-gradient(circle at 88% 85%, #e5d4ba 0%, transparent 30%), var(--bg);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem 3rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0.4rem 0 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
background: #f3ebe0;
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.28rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0.7rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #faf4ec;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<h1>Feedback Pipeline Explainer</h1>
|
||||
<p class="muted">
|
||||
Durable architecture reference for the report -> issue -> agent-session -> PR pipeline,
|
||||
including fork-aware execution and governance controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="grid two">
|
||||
<article class="card">
|
||||
<h2>Formal Pipeline</h2>
|
||||
<ol>
|
||||
<li>Capture and validate report payload.</li>
|
||||
<li>Resolve issue via dedupe markers (create or update/comment).</li>
|
||||
<li>Plan follow-up: issue-only, issue+PR, or issue+fork.</li>
|
||||
<li>Execute direct SCM operations or spawn agent session for code changes.</li>
|
||||
<li>Link issue/fork/PR and update operation journal.</li>
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Execution Boundaries</h2>
|
||||
<ul>
|
||||
<li>Orchestrator is the only component allowed to perform SCM side effects.</li>
|
||||
<li>Optional subagent skill can provide recommendation signals only.</li>
|
||||
<li>Policy fallback remains deterministic when subagent output is invalid.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid three">
|
||||
<article class="card">
|
||||
<h3>Trigger Conditions</h3>
|
||||
<ul>
|
||||
<li>Valid report in <code>mode=scm</code>.</li>
|
||||
<li>Confidence thresholds met.</li>
|
||||
<li>Governance allows attempted mutation path.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Target Selection</h3>
|
||||
<ul>
|
||||
<li>Prefer upstream when writable and policy allows.</li>
|
||||
<li>Use fork path when upstream writes are blocked.</li>
|
||||
<li>Downgrade to issue-only when neither path is permitted.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Idempotency</h3>
|
||||
<ul>
|
||||
<li><code>dedupeKey</code> for issue identity.</li>
|
||||
<li><code>operationKey</code> per mutation stage.</li>
|
||||
<li>Find-or-create semantics before every mutation.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid two">
|
||||
<article class="card">
|
||||
<h2>Consent Gates (Default Policy)</h2>
|
||||
<ul>
|
||||
<li>Outside AO dogfooding, human consent is required for fork creation.</li>
|
||||
<li>Outside AO dogfooding, human consent is required for PR creation.</li>
|
||||
<li>Outside AO dogfooding, human consent is required before upstream/fork target switch.</li>
|
||||
<li>Project-level bypass is allowed only when owner explicitly enables it.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Journal (Plain Language)</h2>
|
||||
<ul>
|
||||
<li>The journal is a progress log for every report.</li>
|
||||
<li>Each mutation updates one tracked record, not separate duplicates.</li>
|
||||
<li>Retries keep the same keys and increment attempt metadata.</li>
|
||||
<li>Consent outcomes are stored for auditability.</li>
|
||||
</ul>
|
||||
<pre><code>{
|
||||
"dedupeKey": "f4d7dbe5b0f8...",
|
||||
"stage": "create_pr",
|
||||
"status": "failed",
|
||||
"attempt": 2,
|
||||
"issueUrl": ".../issues/399",
|
||||
"prUrl": null
|
||||
}</code></pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid two">
|
||||
<article class="card">
|
||||
<h2>PR Requirements</h2>
|
||||
<ul>
|
||||
<li>Issue reference must exist in PR metadata/body.</li>
|
||||
<li>Stable lineage markers link issue, session, and PR.</li>
|
||||
<li>Existing PR for same dedupe context must be linked, not duplicated.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Governance Hooks</h2>
|
||||
<ul>
|
||||
<li>canCreateIssue / canCreateFork / canCreatePR / canSpawnSession checks.</li>
|
||||
<li>Per-fork-owner allow/deny and optional approval gates.</li>
|
||||
<li>Denied operations are journaled with explicit reason codes.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
# Feedback Routing and Follow-up Design (Formalized Pipeline v2)
|
||||
|
||||
## Status
|
||||
|
||||
This is a design formalization update after PR #403 discussion.
|
||||
|
||||
- PR #403 remains implementation-scoped to feedback contracts/validation/storage.
|
||||
- This document defines the next-step architecture for report -> issue -> agent-session -> PR.
|
||||
- This update is design-only (no new runtime behavior introduced by this document itself).
|
||||
|
||||
## Scope and Decisions
|
||||
|
||||
1. Feedback routing mode is exclusive: `local` OR `scm` (never both).
|
||||
2. Privacy guardrails are intentionally deferred to a dedicated follow-up PR.
|
||||
3. Side effects stay deterministic in orchestrator control code.
|
||||
4. Optional subagent/skill can recommend decisions, but cannot execute SCM mutations.
|
||||
|
||||
## Formal Pipeline
|
||||
|
||||
1. **Report capture**: validate feedback tool payload and compute dedupe key.
|
||||
2. **Issue resolution**: find existing issue by markers; create or comment/update.
|
||||
3. **Follow-up planning**: decide issue-only vs issue+PR vs issue+fork from policy + context.
|
||||
4. **Execution**:
|
||||
- direct SCM action path (issue/fork/PR metadata operations), or
|
||||
- agent-session path (spawn session to produce code changes).
|
||||
5. **Linking and journal update**: persist outcome state and references.
|
||||
|
||||
## 1) Trigger Conditions
|
||||
|
||||
The pipeline is triggered when all of the following are true:
|
||||
|
||||
1. A valid `bug_report` or `improvement_suggestion` is captured.
|
||||
2. Routing mode is `scm` for the active project.
|
||||
3. Confidence threshold is met for that report type.
|
||||
4. Governance policy allows target/fork mutation for this actor/project.
|
||||
|
||||
Decision triggers for follow-up action:
|
||||
|
||||
1. `self_blocking_now = false` -> issue-only.
|
||||
2. `self_blocking_now = true` + ready branch/commits -> issue + PR link/create path.
|
||||
3. `self_blocking_now = true` + no writable upstream path -> issue + fork path.
|
||||
4. `self_blocking_now = true` + no code yet -> spawn agent-session path.
|
||||
|
||||
## 2) Session Spawning Contract
|
||||
|
||||
When follow-up requires code (not just metadata operations), orchestrator spawns a worker session.
|
||||
|
||||
### Inputs
|
||||
|
||||
1. `reportId`, `dedupeKey`, `issueUrl` (resolved in prior stage).
|
||||
2. `targetRepo` and `targetBranchPolicy`.
|
||||
3. `followUpIntent` (`fix_now`, `draft_solution`, etc.).
|
||||
4. Optional `forkContext` (fork owner/repo/branch if fork path selected).
|
||||
|
||||
### Preconditions
|
||||
|
||||
1. Issue is already resolved/created and has stable URL.
|
||||
2. Spawn policy permits automatic coding session for this project.
|
||||
3. Repo target has been selected (upstream or fork).
|
||||
|
||||
### Required outputs
|
||||
|
||||
1. Session ID.
|
||||
2. Branch reference used by the session.
|
||||
3. Optional PR URL if created by orchestrator or agent flow.
|
||||
4. Terminal state recorded in publish journal (`done`, `failed`, `cancelled`).
|
||||
|
||||
## 3) Target Selection (Upstream vs Fork)
|
||||
|
||||
Target selection is deterministic and policy-driven:
|
||||
|
||||
1. If upstream write is allowed and policy is `upstream`, target upstream.
|
||||
2. If upstream write is blocked and policy allows fork, target fork.
|
||||
3. If fork exists and policy says reuse, reuse existing fork.
|
||||
4. If fork missing and policy allows creation, create fork and continue.
|
||||
5. If neither upstream nor fork is allowed, downgrade to issue-only and mark follow-up blocked.
|
||||
|
||||
Example policy knobs:
|
||||
|
||||
```yaml
|
||||
feedback:
|
||||
mode: scm
|
||||
scm:
|
||||
provider: github
|
||||
targetRepo: auto # auto | upstream | fork
|
||||
forkStrategy: upstream # upstream | fork | skip
|
||||
```
|
||||
|
||||
## Consent Gates (Default Policy)
|
||||
|
||||
For projects other than AO dogfooding, these are hard defaults:
|
||||
|
||||
1. Explicit human consent is required before creating a fork.
|
||||
2. Explicit human consent is required before creating a PR.
|
||||
3. Explicit human consent is required before switching execution target between upstream and fork.
|
||||
4. No silent infrastructure flip is allowed by default.
|
||||
|
||||
Override model:
|
||||
|
||||
1. Project-level override is optional and must be explicitly enabled by project owner.
|
||||
2. Overrides are scoped per operation (`createFork`, `createPR`, `switchTarget`) and must be auditable.
|
||||
3. Without explicit owner override, consent gate defaults remain enforced.
|
||||
|
||||
## 4) PR Creation/Linking Requirements
|
||||
|
||||
For any PR action, orchestrator enforces:
|
||||
|
||||
1. Issue URL exists and is referenced in PR body.
|
||||
2. Dedupe marker is present in issue/PR metadata for traceability.
|
||||
3. If PR already exists for dedupe key + branch, link existing PR rather than creating duplicate.
|
||||
4. If fork path is used, PR must include fork repo/branch references.
|
||||
5. Issue must be updated with final PR URL and state transitions.
|
||||
|
||||
Canonical markers in issue/PR body:
|
||||
|
||||
1. `<!-- ao:feedback-tool:<tool> -->`
|
||||
2. `<!-- ao:dedupe-key:<dedupeKey> -->`
|
||||
3. `<!-- ao:session:<sessionId> -->` (if session spawned)
|
||||
|
||||
## 5) Idempotency and Retry Semantics
|
||||
|
||||
Idempotency keys:
|
||||
|
||||
1. `dedupeKey` for issue-level identity.
|
||||
2. `operationKey` for each side effect (create issue, create fork, create PR, add comment).
|
||||
|
||||
Retry semantics:
|
||||
|
||||
1. Retry only retryable transport/server failures.
|
||||
2. Exponential backoff with bounded attempts.
|
||||
3. Non-retryable errors transition to terminal failure with actionable reason.
|
||||
|
||||
At-least-once safety:
|
||||
|
||||
1. Replays must first check existing issue/fork/PR using markers before creating anything.
|
||||
2. Side effects must be written as "find-or-create" operations.
|
||||
|
||||
Journal semantics:
|
||||
|
||||
1. Minimal journal record per report in `scm` mode:
|
||||
- `dedupeKey`, `stage`, `status`, `issueUrl`, `prUrl`, `targetRepo`, `lastError`.
|
||||
2. Journal drives recovery and prevents duplicate creation on restart.
|
||||
|
||||
Plain-language journal behavior:
|
||||
|
||||
1. Think of the journal as a progress log for each report.
|
||||
2. Before each mutation attempt, orchestrator writes what it is about to do.
|
||||
3. After attempt completion, orchestrator updates the same record with success/failure and links.
|
||||
4. On retry, orchestrator keeps the same identity keys and increments attempt metadata instead of creating a parallel track.
|
||||
5. Consent decisions (approved/denied) are written so operators can audit why a path was or was not taken.
|
||||
|
||||
Minimal journal schema example:
|
||||
|
||||
```json
|
||||
{
|
||||
"reportId": "fr_01HT2H2F3H4A5",
|
||||
"dedupeKey": "f4d7dbe5b0f8...",
|
||||
"mode": "scm",
|
||||
"stage": "create_pr",
|
||||
"status": "failed",
|
||||
"attempt": 2,
|
||||
"operationKey": "create_pr:f4d7dbe5b0f8:upstream",
|
||||
"targetRepo": "ComposioHQ/agent-orchestrator",
|
||||
"issueUrl": "https://github.com/ComposioHQ/agent-orchestrator/issues/399",
|
||||
"prUrl": null,
|
||||
"consent": {
|
||||
"createFork": "approved",
|
||||
"createPR": "approved",
|
||||
"switchTarget": "not-needed"
|
||||
},
|
||||
"lastError": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "PR creation blocked by repository policy"
|
||||
},
|
||||
"updatedAt": "2026-03-10T15:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 6) Governance Hooks Per Fork Owner Policy
|
||||
|
||||
Governance is evaluated before each mutating operation.
|
||||
|
||||
Policy hooks:
|
||||
|
||||
1. `canCreateIssue(project, actor, targetRepo)`
|
||||
2. `canCreateFork(project, actor, forkOwner)`
|
||||
3. `canCreatePR(project, actor, targetRepo, sourceRepo)`
|
||||
4. `canSpawnSession(project, actor, followUpIntent)`
|
||||
|
||||
Per-fork-owner controls:
|
||||
|
||||
1. Allowed fork owner list / deny list.
|
||||
2. Require human approval for fork creation under selected owners.
|
||||
3. Optional restriction to pre-registered fork remotes.
|
||||
|
||||
If governance denies an operation:
|
||||
|
||||
1. Do not attempt mutation.
|
||||
2. Downgrade path if possible (e.g., issue-only).
|
||||
3. Record explicit denial reason in journal.
|
||||
|
||||
## Responsibilities: Orchestrator vs Subagent Skill
|
||||
|
||||
### Orchestrator (required)
|
||||
|
||||
1. Owns all SCM side effects.
|
||||
2. Owns retries, idempotency checks, and journal updates.
|
||||
3. Enforces governance hooks and policy fallbacks.
|
||||
|
||||
### Optional ephemeral subagent skill
|
||||
|
||||
1. Produces recommendation payload only:
|
||||
- `self_blocking_now`
|
||||
- `recommended_action`
|
||||
- `reason`
|
||||
- `confidence`
|
||||
2. Must return strict JSON schema.
|
||||
3. If invalid/low confidence, orchestrator falls back to deterministic rules.
|
||||
|
||||
## Proposed Components
|
||||
|
||||
1. `FeedbackRouter`: local vs scm dispatch.
|
||||
2. `IssueResolver`: dedupe-aware issue create/update/comment.
|
||||
3. `FollowUpPlanner`: issue-only vs issue+PR vs issue+fork decision.
|
||||
4. `TargetResolver`: upstream/fork target determination.
|
||||
5. `FollowUpExecutor`: direct SCM or agent-session execution path.
|
||||
6. `FeedbackPublishJournal`: status, links, retries, recovery metadata.
|
||||
|
||||
## Config Proposal (Extended)
|
||||
|
||||
```yaml
|
||||
feedback:
|
||||
mode: scm # local | scm
|
||||
scm:
|
||||
provider: github # github | gitlab
|
||||
targetRepo: auto # auto | upstream | fork
|
||||
forkStrategy: upstream # upstream | fork | skip
|
||||
prReference: if_present # required | if_present | never
|
||||
minConfidence:
|
||||
bug_report: 0.6
|
||||
improvement_suggestion: 0.75
|
||||
followUp:
|
||||
enableAgentSession: true
|
||||
requireIssueBeforeSession: true
|
||||
consent:
|
||||
defaultPolicy: require_human_for_major_mutations # hard default outside AO dogfooding
|
||||
requireFor:
|
||||
createFork: true
|
||||
createPR: true
|
||||
switchTarget: true
|
||||
projectOverride:
|
||||
enabled: false # must be explicitly enabled by project owner
|
||||
governance:
|
||||
allowedForkOwners: ["<org-or-user>"]
|
||||
requireApprovalForForkCreation: true
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
1. Trigger matrix and planner decision table.
|
||||
2. Target resolver behavior for upstream/fork permutations.
|
||||
3. Marker generation and dedupe matching.
|
||||
4. Governance hook allow/deny behavior.
|
||||
5. Retry classifier and idempotent replay logic.
|
||||
|
||||
### Integration tests
|
||||
|
||||
1. Issue create/update on GitHub and GitLab.
|
||||
2. Fork ensure/reuse path under different policies.
|
||||
3. PR create/link and issue back-link updates.
|
||||
4. Agent-session contract handoff and journal transitions.
|
||||
|
||||
### End-to-end tests
|
||||
|
||||
1. report -> issue-only path.
|
||||
2. report -> issue -> session -> PR path.
|
||||
3. report -> issue -> fork -> session -> PR-from-fork path.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Ship with `mode: local` default and `scm` opt-in.
|
||||
2. Enable on a small set of projects first.
|
||||
3. Track duplicate suppression rate, retry outcomes, and failure classes.
|
||||
4. Expand once deterministic behavior and governance policy outcomes are stable.
|
||||
|
||||
## Summary
|
||||
|
||||
The formal pipeline is now explicit: report -> issue -> (optional) agent-session -> PR, with fork-aware execution and governance hooks. Orchestrator remains the only executor of side effects; agentic skill remains advisory.
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# AO22 Design Iteration Summary
|
||||
|
||||
## What was updated
|
||||
|
||||
1. `docs/design/feedback-routing-and-followup-design.md`
|
||||
- Formalized the full pipeline: report -> issue -> agent-session -> PR.
|
||||
- Added explicit sections for:
|
||||
- trigger conditions,
|
||||
- session spawning contract,
|
||||
- target selection (upstream vs fork),
|
||||
- PR creation/linking requirements,
|
||||
- idempotency/retry semantics,
|
||||
- governance hooks per fork-owner policy.
|
||||
|
||||
2. `docs/design/feedback-pipeline-explainer.html`
|
||||
- Added a durable architecture explainer page (non-PR-specific).
|
||||
- Mirrors the same six design contracts and the formal pipeline.
|
||||
|
||||
## Intent
|
||||
|
||||
This iteration is design formalization only. No runtime/code behavior was introduced by this request.
|
||||
|
||||
## Feedback incorporated
|
||||
|
||||
- Added a dedicated `Consent Gates (Default Policy)` section with hard defaults for non-AO dogfooding:
|
||||
- explicit approval required for `createFork`, `createPR`, and upstream/fork target switching.
|
||||
- Clarified that project-level override is optional and only active when explicitly enabled by the owner.
|
||||
- Expanded journal behavior in plain language to describe how records are written/updated/retried.
|
||||
- Added a minimal journal schema example for the feedback pipeline.
|
||||
|
||||
## Ready for review
|
||||
|
||||
The docs now provide a deterministic contract for implementing fork-aware report->issue->session->PR execution in follow-up PRs.
|
||||
|
|
@ -139,6 +139,51 @@ Loads and validates `agent-orchestrator.yaml`:
|
|||
2. Wire it up in the polling loop
|
||||
3. Add config schema in `src/config.ts` if new reaction type
|
||||
|
||||
### Feedback Tools (v1)
|
||||
|
||||
`@composio/ao-core` exports two structured feedback tool contracts:
|
||||
|
||||
- `bug_report`
|
||||
- `improvement_suggestion`
|
||||
|
||||
Both share the same required input fields:
|
||||
|
||||
- `title`
|
||||
- `body`
|
||||
- `evidence` (array of strings)
|
||||
- `session`
|
||||
- `source`
|
||||
- `confidence` (0..1)
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
import { FEEDBACK_TOOL_NAMES, FeedbackReportStore, getFeedbackReportsDir } from "@composio/ao-core";
|
||||
|
||||
const reportsDir = getFeedbackReportsDir(configPath, projectPath);
|
||||
const store = new FeedbackReportStore(reportsDir);
|
||||
|
||||
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
title: "SSO login loop",
|
||||
body: "Google SSO redirects back to /login repeatedly.",
|
||||
evidence: ["trace_id=abc123", "screenshot: login-loop.png"],
|
||||
session: "ao-22",
|
||||
source: "agent",
|
||||
confidence: 0.84,
|
||||
});
|
||||
```
|
||||
|
||||
Storage format:
|
||||
|
||||
- Reports are persisted under `~/.agent-orchestrator/{hash}-{projectId}/feedback-reports`
|
||||
- Each report is a typed key=value file (`report_<timestamp>_<id>.kv`) for easy inspection
|
||||
- A deterministic dedupe key (`sha256`, 16 hex chars) is generated from normalized tool+content
|
||||
|
||||
Migration notes:
|
||||
|
||||
- No migration needed for existing AO installs
|
||||
- The `feedback-reports` directory is created lazily on first persisted report
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
FEEDBACK_TOOL_CONTRACTS,
|
||||
FEEDBACK_TOOL_NAMES,
|
||||
FeedbackReportStore,
|
||||
generateFeedbackDedupeKey,
|
||||
validateFeedbackToolInput,
|
||||
} from "../feedback-tools.js";
|
||||
|
||||
const validPayload = {
|
||||
title: "Login failure for SSO users",
|
||||
body: "Users with Google SSO are looped back to login.",
|
||||
evidence: ["trace_id=abc123", "Video capture from session"],
|
||||
session: "ao-22",
|
||||
source: "agent",
|
||||
confidence: 0.82,
|
||||
};
|
||||
|
||||
describe("feedback tool contracts", () => {
|
||||
it("defines both v1 tool contracts", () => {
|
||||
expect(Object.keys(FEEDBACK_TOOL_CONTRACTS).sort()).toEqual([
|
||||
FEEDBACK_TOOL_NAMES.BUG_REPORT,
|
||||
FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION,
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates required fields for bug_report", () => {
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
title: "",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
body: "",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
evidence: [],
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
session: "",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
source: "",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects malformed confidence", () => {
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
|
||||
...validPayload,
|
||||
confidence: -0.1,
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
|
||||
...validPayload,
|
||||
confidence: 1.1,
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
|
||||
...validPayload,
|
||||
confidence: Number.NaN,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedback dedupe key", () => {
|
||||
it("is stable for whitespace/case differences and evidence ordering", () => {
|
||||
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
title: " Login failure FOR SSO users ",
|
||||
body: "Users with Google SSO are looped back to login.",
|
||||
evidence: ["Video capture from session", "trace_id=abc123"],
|
||||
source: "Agent",
|
||||
});
|
||||
|
||||
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
|
||||
|
||||
expect(keyA).toBe(keyB);
|
||||
});
|
||||
|
||||
it("is stable when case-only evidence changes alter default sort order", () => {
|
||||
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
evidence: ["BETA", "alpha"],
|
||||
});
|
||||
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
evidence: ["beta", "alpha"],
|
||||
});
|
||||
|
||||
expect(keyA).toBe(keyB);
|
||||
});
|
||||
|
||||
it("is stable when only confidence changes", () => {
|
||||
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
confidence: 0.21,
|
||||
});
|
||||
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
confidence: 0.99,
|
||||
});
|
||||
|
||||
expect(keyA).toBe(keyB);
|
||||
});
|
||||
|
||||
it("changes when normalized content differs", () => {
|
||||
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
|
||||
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
body: "Users with Google SSO see a 500 after callback.",
|
||||
});
|
||||
|
||||
expect(keyA).not.toBe(keyB);
|
||||
});
|
||||
|
||||
it("does not collide when one evidence item contains pipes", () => {
|
||||
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
evidence: ["a|b"],
|
||||
});
|
||||
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
|
||||
...validPayload,
|
||||
evidence: ["a", "b"],
|
||||
});
|
||||
|
||||
expect(keyA).not.toBe(keyB);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedback report store", () => {
|
||||
let reportsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
reportsDir = join(tmpdir(), `ao-feedback-${randomUUID()}`);
|
||||
mkdirSync(reportsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(reportsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists and reads structured feedback reports", () => {
|
||||
const store = new FeedbackReportStore(reportsDir);
|
||||
|
||||
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
|
||||
const records = store.list();
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toEqual(saved);
|
||||
expect(records[0]?.tool).toBe(FEEDBACK_TOOL_NAMES.BUG_REPORT);
|
||||
expect(records[0]?.dedupeKey).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it("does not allow invalid reports to be persisted", () => {
|
||||
const store = new FeedbackReportStore(reportsDir);
|
||||
|
||||
expect(() =>
|
||||
store.persist(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
|
||||
...validPayload,
|
||||
evidence: [],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("skips corrupt report files and returns valid ones", () => {
|
||||
const store = new FeedbackReportStore(reportsDir);
|
||||
|
||||
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
|
||||
writeFileSync(
|
||||
join(reportsDir, "report_2026-03-10T00-00-00-000Z_bad.kv"),
|
||||
"version=1\nid=report_2026-03-10T00-00-00-000Z_bad\ntool=bug_report\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const records = store.list();
|
||||
expect(records).toEqual([saved]);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
getProjectBaseDir,
|
||||
getSessionsDir,
|
||||
getWorktreesDir,
|
||||
getFeedbackReportsDir,
|
||||
getArchiveDir,
|
||||
getOriginFilePath,
|
||||
generateSessionName,
|
||||
|
|
@ -271,6 +272,12 @@ describe("Path Construction", () => {
|
|||
expect(worktreesDir).toMatch(/\.agent-orchestrator\/[a-f0-9]{12}-integrator\/worktrees$/);
|
||||
});
|
||||
|
||||
it("getFeedbackReportsDir returns {baseDir}/feedback-reports", () => {
|
||||
const reportsDir = getFeedbackReportsDir(configPath, "/repos/integrator");
|
||||
|
||||
expect(reportsDir).toMatch(/\.agent-orchestrator\/[a-f0-9]{12}-integrator\/feedback-reports$/);
|
||||
});
|
||||
|
||||
it("getArchiveDir returns {baseDir}/sessions/archive", () => {
|
||||
const archiveDir = getArchiveDir(configPath, "/repos/integrator");
|
||||
|
||||
|
|
@ -287,10 +294,12 @@ describe("Path Construction", () => {
|
|||
const baseDir = getProjectBaseDir(configPath, "/repos/integrator");
|
||||
const sessionsDir = getSessionsDir(configPath, "/repos/integrator");
|
||||
const worktreesDir = getWorktreesDir(configPath, "/repos/integrator");
|
||||
const reportsDir = getFeedbackReportsDir(configPath, "/repos/integrator");
|
||||
const archiveDir = getArchiveDir(configPath, "/repos/integrator");
|
||||
|
||||
expect(sessionsDir).toContain(baseDir);
|
||||
expect(worktreesDir).toContain(baseDir);
|
||||
expect(reportsDir).toContain(baseDir);
|
||||
expect(archiveDir).toContain(baseDir);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { renameSync, writeFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Atomically write a file by writing to a temp file then renaming.
|
||||
* rename() is atomic on POSIX, so concurrent writers never produce torn data.
|
||||
*/
|
||||
export function atomicWriteFileSync(filePath: string, content: string): void {
|
||||
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
||||
writeFileSync(tmpPath, content, "utf-8");
|
||||
renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { atomicWriteFileSync } from "./atomic-write.js";
|
||||
import { parseKeyValueContent } from "./key-value.js";
|
||||
|
||||
export const FEEDBACK_TOOL_NAMES = {
|
||||
BUG_REPORT: "bug_report",
|
||||
IMPROVEMENT_SUGGESTION: "improvement_suggestion",
|
||||
} as const;
|
||||
|
||||
export type FeedbackToolName = (typeof FEEDBACK_TOOL_NAMES)[keyof typeof FEEDBACK_TOOL_NAMES];
|
||||
|
||||
const normalizeText = (value: string): string => value.trim().replace(/\s+/g, " ");
|
||||
|
||||
const NonEmptyTextSchema = z.string().transform(normalizeText).pipe(z.string().min(1));
|
||||
|
||||
const FeedbackInputSchema = z
|
||||
.object({
|
||||
title: NonEmptyTextSchema,
|
||||
body: NonEmptyTextSchema,
|
||||
evidence: z.array(NonEmptyTextSchema).min(1),
|
||||
session: NonEmptyTextSchema,
|
||||
source: NonEmptyTextSchema,
|
||||
confidence: z.number().finite().min(0).max(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const BugReportSchema = FeedbackInputSchema;
|
||||
export const ImprovementSuggestionSchema = FeedbackInputSchema;
|
||||
|
||||
export type BugReportInput = z.infer<typeof BugReportSchema>;
|
||||
export type ImprovementSuggestionInput = z.infer<typeof ImprovementSuggestionSchema>;
|
||||
export type FeedbackToolInput = BugReportInput | ImprovementSuggestionInput;
|
||||
|
||||
export interface FeedbackToolContract {
|
||||
name: FeedbackToolName;
|
||||
description: string;
|
||||
schema: typeof FeedbackInputSchema;
|
||||
}
|
||||
|
||||
export const FEEDBACK_TOOL_CONTRACTS: Record<FeedbackToolName, FeedbackToolContract> = {
|
||||
bug_report: {
|
||||
name: FEEDBACK_TOOL_NAMES.BUG_REPORT,
|
||||
description: "Capture reproducible bugs found while working in AO sessions.",
|
||||
schema: BugReportSchema,
|
||||
},
|
||||
improvement_suggestion: {
|
||||
name: FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION,
|
||||
description: "Capture actionable improvement suggestions discovered in AO sessions.",
|
||||
schema: ImprovementSuggestionSchema,
|
||||
},
|
||||
};
|
||||
|
||||
export interface PersistedFeedbackReport extends FeedbackToolInput {
|
||||
id: string;
|
||||
tool: FeedbackToolName;
|
||||
createdAt: string;
|
||||
dedupeKey: string;
|
||||
}
|
||||
|
||||
const ReportIdSchema = z.string().regex(/^report_[A-Za-z0-9_-]+$/);
|
||||
const ISODateSchema = z.string().datetime({ offset: true });
|
||||
const DedupeKeySchema = z.string().regex(/^[a-f0-9]{16}$/);
|
||||
|
||||
export function validateFeedbackToolInput(
|
||||
tool: FeedbackToolName,
|
||||
input: FeedbackToolInput,
|
||||
): FeedbackToolInput {
|
||||
return FEEDBACK_TOOL_CONTRACTS[tool].schema.parse(input);
|
||||
}
|
||||
|
||||
export function generateFeedbackDedupeKey(
|
||||
tool: FeedbackToolName,
|
||||
input: FeedbackToolInput,
|
||||
): string {
|
||||
const canonicalEvidence = [...input.evidence]
|
||||
.map((item) => normalizeText(item).toLowerCase())
|
||||
.sort();
|
||||
const canonical = JSON.stringify({
|
||||
tool,
|
||||
title: normalizeText(input.title).toLowerCase(),
|
||||
body: normalizeText(input.body).toLowerCase(),
|
||||
session: normalizeText(input.session).toLowerCase(),
|
||||
source: normalizeText(input.source).toLowerCase(),
|
||||
evidence: canonicalEvidence,
|
||||
});
|
||||
|
||||
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function serializeReport(report: PersistedFeedbackReport): string {
|
||||
const lines: string[] = [
|
||||
"version=1",
|
||||
`id=${report.id}`,
|
||||
`tool=${report.tool}`,
|
||||
`createdAt=${report.createdAt}`,
|
||||
`dedupeKey=${report.dedupeKey}`,
|
||||
`title=${report.title}`,
|
||||
`body=${report.body}`,
|
||||
`session=${report.session}`,
|
||||
`source=${report.source}`,
|
||||
`confidence=${report.confidence}`,
|
||||
];
|
||||
|
||||
for (const [index, evidenceItem] of report.evidence.entries()) {
|
||||
lines.push(`evidence.${index}=${evidenceItem}`);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function parseReportFile(content: string): PersistedFeedbackReport {
|
||||
const raw = parseKeyValueContent(content);
|
||||
|
||||
const evidence = Object.entries(raw)
|
||||
.filter(([key]) => key.startsWith("evidence."))
|
||||
.map(([key, value]) => ({
|
||||
index: Number.parseInt(key.split(".")[1] ?? "0", 10),
|
||||
value,
|
||||
}))
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((item) => item.value);
|
||||
|
||||
const tool = raw["tool"];
|
||||
if (
|
||||
tool !== FEEDBACK_TOOL_NAMES.BUG_REPORT &&
|
||||
tool !== FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION
|
||||
) {
|
||||
throw new Error(`Invalid feedback report tool type: ${tool ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const parsedInput = FEEDBACK_TOOL_CONTRACTS[tool].schema.parse({
|
||||
title: raw["title"] ?? "",
|
||||
body: raw["body"] ?? "",
|
||||
evidence,
|
||||
session: raw["session"] ?? "",
|
||||
source: raw["source"] ?? "",
|
||||
confidence: Number(raw["confidence"]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: ReportIdSchema.parse(raw["id"] ?? ""),
|
||||
tool,
|
||||
createdAt: ISODateSchema.parse(raw["createdAt"] ?? ""),
|
||||
dedupeKey: DedupeKeySchema.parse(raw["dedupeKey"] ?? ""),
|
||||
...parsedInput,
|
||||
};
|
||||
}
|
||||
|
||||
function isReportFileName(fileName: string): boolean {
|
||||
return /^report_[A-Za-z0-9_-]+\.kv$/.test(fileName);
|
||||
}
|
||||
|
||||
export class FeedbackReportStore {
|
||||
constructor(private readonly reportsDir: string) {}
|
||||
|
||||
persist(tool: FeedbackToolName, input: FeedbackToolInput): PersistedFeedbackReport {
|
||||
const validated = validateFeedbackToolInput(tool, input);
|
||||
const createdAt = new Date().toISOString();
|
||||
const dedupeKey = generateFeedbackDedupeKey(tool, validated);
|
||||
const id = `report_${createdAt.replace(/[:.]/g, "-")}_${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const report: PersistedFeedbackReport = {
|
||||
id,
|
||||
tool,
|
||||
createdAt,
|
||||
dedupeKey,
|
||||
...validated,
|
||||
};
|
||||
|
||||
mkdirSync(this.reportsDir, { recursive: true });
|
||||
const filePath = join(this.reportsDir, `${id}.kv`);
|
||||
atomicWriteFileSync(filePath, serializeReport(report));
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
list(): PersistedFeedbackReport[] {
|
||||
if (!existsSync(this.reportsDir)) return [];
|
||||
|
||||
const reports: PersistedFeedbackReport[] = [];
|
||||
for (const name of readdirSync(this.reportsDir)) {
|
||||
if (!isReportFileName(name)) continue;
|
||||
const reportPath = join(this.reportsDir, name);
|
||||
try {
|
||||
if (!statSync(reportPath).isFile()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(reportPath, "utf-8");
|
||||
const parsed = parseReportFile(content);
|
||||
reports.push(parsed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return reports.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +99,25 @@ export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
|
|||
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
|
||||
// Feedback tools — contracts, validation, and report storage
|
||||
export {
|
||||
FEEDBACK_TOOL_NAMES,
|
||||
FEEDBACK_TOOL_CONTRACTS,
|
||||
BugReportSchema,
|
||||
ImprovementSuggestionSchema,
|
||||
validateFeedbackToolInput,
|
||||
generateFeedbackDedupeKey,
|
||||
FeedbackReportStore,
|
||||
} from "./feedback-tools.js";
|
||||
export type {
|
||||
FeedbackToolName,
|
||||
FeedbackToolContract,
|
||||
BugReportInput,
|
||||
ImprovementSuggestionInput,
|
||||
FeedbackToolInput,
|
||||
PersistedFeedbackReport,
|
||||
} from "./feedback-tools.js";
|
||||
|
||||
// Path utilities — hash-based directory structure
|
||||
export {
|
||||
generateConfigHash,
|
||||
|
|
@ -108,6 +127,7 @@ export {
|
|||
getProjectBaseDir,
|
||||
getSessionsDir,
|
||||
getWorktreesDir,
|
||||
getFeedbackReportsDir,
|
||||
getArchiveDir,
|
||||
getOriginFilePath,
|
||||
generateSessionName,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Parse a key=value file into a record.
|
||||
* Lines starting with # are comments. Empty lines are skipped.
|
||||
* Only the first "=" is used as the delimiter (values can contain "=").
|
||||
*/
|
||||
export function parseKeyValueContent(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1).trim();
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@
|
|||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
|
|
@ -34,25 +33,8 @@ import {
|
|||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import type { SessionId, SessionMetadata } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse a key=value metadata file into a record.
|
||||
* Lines starting with # are comments. Empty lines are skipped.
|
||||
* Only the first `=` is used as the delimiter (values can contain `=`).
|
||||
*/
|
||||
function parseMetadataFile(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1).trim();
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
import { atomicWriteFileSync } from "./atomic-write.js";
|
||||
import { parseKeyValueContent } from "./key-value.js";
|
||||
|
||||
/** Serialize a record back to key=value format. */
|
||||
function serializeMetadata(data: Record<string, string>): string {
|
||||
|
|
@ -64,16 +46,6 @@ function serializeMetadata(data: Record<string, string>): string {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write a file by writing to a temp file then renaming.
|
||||
* rename() is atomic on POSIX, so concurrent writers never produce torn data.
|
||||
*/
|
||||
function atomicWriteFileSync(filePath: string, content: string): void {
|
||||
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
||||
writeFileSync(tmpPath, content, "utf-8");
|
||||
renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
/** Validate sessionId to prevent path traversal. */
|
||||
const VALID_SESSION_ID = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
|
|
@ -97,7 +69,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
|
|||
if (!existsSync(path)) return null;
|
||||
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const raw = parseMetadataFile(content);
|
||||
const raw = parseKeyValueContent(content);
|
||||
|
||||
return {
|
||||
worktree: raw["worktree"] ?? "",
|
||||
|
|
@ -133,7 +105,7 @@ export function readMetadataRaw(
|
|||
): Record<string, string> | null {
|
||||
const path = metadataPath(dataDir, sessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
return parseMetadataFile(readFileSync(path, "utf-8"));
|
||||
return parseKeyValueContent(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -187,7 +159,7 @@ export function updateMetadata(
|
|||
let existing: Record<string, string> = {};
|
||||
|
||||
if (existsSync(path)) {
|
||||
existing = parseMetadataFile(readFileSync(path, "utf-8"));
|
||||
existing = parseKeyValueContent(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
// Merge updates — remove keys set to empty string
|
||||
|
|
@ -254,7 +226,7 @@ export function readArchivedMetadataRaw(
|
|||
|
||||
if (!latest) return null;
|
||||
try {
|
||||
return parseMetadataFile(readFileSync(join(archiveDir, latest), "utf-8"));
|
||||
return parseKeyValueContent(readFileSync(join(archiveDir, latest), "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -284,7 +256,7 @@ export function updateArchivedMetadata(
|
|||
const archivePath = join(archiveDir, latest);
|
||||
let existing: Record<string, string>;
|
||||
try {
|
||||
existing = parseMetadataFile(readFileSync(archivePath, "utf-8"));
|
||||
existing = parseKeyValueContent(readFileSync(archivePath, "utf-8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ export function getWorktreesDir(configPath: string, projectPath: string): string
|
|||
return join(getProjectBaseDir(configPath, projectPath), "worktrees");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the feedback reports directory for a project.
|
||||
* Format: ~/.agent-orchestrator/{hash}-{projectId}/feedback-reports
|
||||
*/
|
||||
export function getFeedbackReportsDir(configPath: string, projectPath: string): string {
|
||||
return join(getProjectBaseDir(configPath, projectPath), "feedback-reports");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the archive directory for a project.
|
||||
* Format: ~/.agent-orchestrator/{hash}-{projectId}/archive
|
||||
|
|
|
|||
Loading…
Reference in New Issue