agents-relay 1.0.74 → 1.0.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli.js +28 -0
- package/dist/dashboard.js +26 -1
- package/dist/feedback.js +234 -0
- package/dist/relayd.js +10 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,6 +135,8 @@ events, durable-thread reuse, resume, or retry behavior.
|
|
|
135
135
|
|
|
136
136
|
Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly; the CLI uses GitHubStore with the authenticated gh client in operational mode.
|
|
137
137
|
|
|
138
|
+
External product feedback uses the reusable `src/feedback.ts` contract. See [docs/feedback-intake.md](docs/feedback-intake.md) for the schema, attachment limits, privacy rules, materialization path, and provenance envelope for later Jobs/Tasks/PRs.
|
|
139
|
+
|
|
138
140
|
reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
|
|
139
141
|
|
|
140
142
|
`agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
|
package/dist/cli.js
CHANGED
|
@@ -673,6 +673,32 @@ async function runJobCommand(action, args) {
|
|
|
673
673
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
674
674
|
export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
|
|
675
675
|
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
|
|
676
|
+
export function dashboardTaskAction(store, reconciler, action) {
|
|
677
|
+
return async (jobId, taskId) => {
|
|
678
|
+
const job = await store.load(jobId);
|
|
679
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
680
|
+
if (!task)
|
|
681
|
+
throw new Error(`Task ${taskId} not found`);
|
|
682
|
+
if (action === 'retry') {
|
|
683
|
+
if (!['FAILED', 'BLOCKED'].includes(task.state))
|
|
684
|
+
throw new Error(`Task ${task.id} is not restartable (${task.state})`);
|
|
685
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
686
|
+
await transitionTaskEvent(task, 'retry');
|
|
687
|
+
task.error = null;
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
if (!['DRAFT', 'QUEUED', 'BLOCKED', 'FAILED'].includes(task.state))
|
|
691
|
+
throw new Error(`Task ${task.id} is not cancellable (${task.state})`);
|
|
692
|
+
await transitionTaskEvent(task, 'cancel');
|
|
693
|
+
task.error = 'Cancelled by user';
|
|
694
|
+
}
|
|
695
|
+
task.leaseOwner = null;
|
|
696
|
+
task.leaseExpiresAt = null;
|
|
697
|
+
task.executionId = null;
|
|
698
|
+
await store.saveTask(task);
|
|
699
|
+
return await reconciler.wake(job.id);
|
|
700
|
+
};
|
|
701
|
+
}
|
|
676
702
|
export function dashboardJobAction(store, reconciler, action) {
|
|
677
703
|
return async (jobId) => {
|
|
678
704
|
const job = await store.load(jobId);
|
|
@@ -724,6 +750,8 @@ export async function createService(args) {
|
|
|
724
750
|
start: dashboardJobAction(loaded.store, reconciler, 'start'),
|
|
725
751
|
reconcile: dashboardJobAction(loaded.store, reconciler, 'reconcile'),
|
|
726
752
|
cancel: dashboardJobAction(loaded.store, reconciler, 'cancel'),
|
|
753
|
+
taskRetry: dashboardTaskAction(loaded.store, reconciler, 'retry'),
|
|
754
|
+
taskCancel: dashboardTaskAction(loaded.store, reconciler, 'cancel'),
|
|
727
755
|
});
|
|
728
756
|
await once(dashboardServer, 'listening');
|
|
729
757
|
const stopWatcher = await reconciler.watch(loaded.job.id);
|
package/dist/dashboard.js
CHANGED
|
@@ -124,6 +124,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
124
124
|
.task-row { width:100%; display:flex; align-items:center; gap:.4rem; min-width:0; padding:.32rem .38rem; border:0; border-radius:.35rem; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
|
|
125
125
|
.task-row[aria-current="true"] { background:var(--accent2); }
|
|
126
126
|
.task-id { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font: .77rem ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
127
|
+
.task-actions { display:flex; justify-content:flex-end; gap:.25rem; margin-bottom:.5rem; }
|
|
127
128
|
.event-list { display:grid; gap:.45rem; }
|
|
128
129
|
.event-item { padding-bottom:.4rem; border-bottom:1px solid var(--border); }
|
|
129
130
|
.event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
|
|
@@ -274,7 +275,10 @@ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();
|
|
|
274
275
|
function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
|
|
275
276
|
function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
|
|
276
277
|
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
|
|
277
|
-
function
|
|
278
|
+
function taskActionEnabled(task,action){if(action==='retry')return ['FAILED','BLOCKED'].includes(task.state);if(action==='cancel')return ['DRAFT','QUEUED','BLOCKED','FAILED'].includes(task.state);return false;}
|
|
279
|
+
function taskActionButton(task,action,label,icon){const button=document.createElement('button');button.type='button';button.className='job-action';button.textContent=icon;button.title=label;button.setAttribute('aria-label',label);button.disabled=!taskActionEnabled(task,action);button.addEventListener('click',()=>void runTaskAction(task,action,button));return button;}
|
|
280
|
+
async function runTaskAction(task,action,button){const job=selected();if(!job)return;button.disabled=true;try{const response=await fetch('/api/task-action?id='+encodeURIComponent(job.id)+'&task='+encodeURIComponent(task.id)+'&action='+encodeURIComponent(action),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){button.title=(action==='retry'?'Restart':'Cancel')+': '+error.message;}finally{renderAll();}}
|
|
281
|
+
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const actions=element('div','task-actions');actions.append(taskActionButton(task,'retry','Restart task','↻'),taskActionButton(task,'cancel','Cancel task','×'));const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(actions,groups);}
|
|
278
282
|
function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
|
|
279
283
|
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
|
|
280
284
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
@@ -485,6 +489,27 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
485
489
|
}
|
|
486
490
|
return;
|
|
487
491
|
}
|
|
492
|
+
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/task-action') {
|
|
493
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
494
|
+
const requestedJobId = url.searchParams.get('id') ?? '';
|
|
495
|
+
const requestedTaskId = url.searchParams.get('task') ?? '';
|
|
496
|
+
const action = url.searchParams.get('action') ?? '';
|
|
497
|
+
const handler = action === 'retry' ? actions?.taskRetry : action === 'cancel' ? actions?.taskCancel : undefined;
|
|
498
|
+
if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !/^[A-Za-z0-9_-]+$/.test(requestedTaskId) || !handler) {
|
|
499
|
+
res.writeHead(404);
|
|
500
|
+
res.end('Task action unavailable');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
505
|
+
res.end(JSON.stringify(await handler(requestedJobId, requestedTaskId)));
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
res.writeHead(409);
|
|
509
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
488
513
|
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job') {
|
|
489
514
|
try {
|
|
490
515
|
const requestedJobId = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('id') ?? undefined;
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
export const FEEDBACK_MARKER = 'agents-relay:feedback:v1';
|
|
5
|
+
export const FEEDBACK_SCHEMA = 'agents-relay.feedback';
|
|
6
|
+
export const FEEDBACK_SCHEMA_VERSION = 1;
|
|
7
|
+
export const FEEDBACK_MAX_TEXT_LENGTH = 20_000;
|
|
8
|
+
export const FEEDBACK_MAX_ATTACHMENTS = 10;
|
|
9
|
+
export const FEEDBACK_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
export const FEEDBACK_IMAGE_TYPES = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
|
|
11
|
+
export const DEFAULT_FEEDBACK_LIMITS = {
|
|
12
|
+
maxTextLength: 20_000,
|
|
13
|
+
maxAttachments: 5,
|
|
14
|
+
maxAttachmentBytes: 10 * 1024 * 1024,
|
|
15
|
+
allowedMediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
|
16
|
+
};
|
|
17
|
+
function effectiveLimits(limits = {}) {
|
|
18
|
+
return {
|
|
19
|
+
maxTextLength: limits.maxTextLength ?? DEFAULT_FEEDBACK_LIMITS.maxTextLength,
|
|
20
|
+
maxAttachments: limits.maxAttachments ?? DEFAULT_FEEDBACK_LIMITS.maxAttachments,
|
|
21
|
+
maxAttachmentBytes: limits.maxAttachmentBytes ?? DEFAULT_FEEDBACK_LIMITS.maxAttachmentBytes,
|
|
22
|
+
allowedMediaTypes: limits.allowedMediaTypes ?? DEFAULT_FEEDBACK_LIMITS.allowedMediaTypes,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function fail(message) { throw new Error(`Invalid feedback: ${message}`); }
|
|
26
|
+
function safeId(id) {
|
|
27
|
+
if (!/^[a-f0-9-]{36}$/i.test(id))
|
|
28
|
+
fail('feedback id is invalid');
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
function cleanFilename(filename) {
|
|
32
|
+
const cleaned = basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120);
|
|
33
|
+
if (!cleaned || cleaned === '.' || cleaned === '..')
|
|
34
|
+
fail('attachment filename is invalid');
|
|
35
|
+
return cleaned;
|
|
36
|
+
}
|
|
37
|
+
function cleanOptional(value, field) {
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (value.length > 500)
|
|
41
|
+
fail(`${field} is too long`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function validateProvenance(input) {
|
|
45
|
+
if (!input.product.trim())
|
|
46
|
+
fail('provenance.product is required');
|
|
47
|
+
if (!input.submittedAt || Number.isNaN(Date.parse(input.submittedAt)))
|
|
48
|
+
fail('provenance.submittedAt must be an ISO timestamp');
|
|
49
|
+
if (input.contact && !input.contact.value.trim())
|
|
50
|
+
fail('contact value must not be empty');
|
|
51
|
+
// Only the explicitly documented fields are copied. Upload/browser/provider metadata is never persisted.
|
|
52
|
+
return {
|
|
53
|
+
source: input.source,
|
|
54
|
+
product: input.product.trim().slice(0, 200),
|
|
55
|
+
surface: cleanOptional(input.surface, 'surface'),
|
|
56
|
+
setupStep: cleanOptional(input.setupStep, 'setupStep'),
|
|
57
|
+
actorClass: input.actorClass,
|
|
58
|
+
version: cleanOptional(input.version, 'version'),
|
|
59
|
+
build: cleanOptional(input.build, 'build'),
|
|
60
|
+
submittedAt: new Date(input.submittedAt).toISOString(),
|
|
61
|
+
...(input.contact ? { contact: { channel: input.contact.channel, value: input.contact.value.trim().slice(0, 500) } } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function marker(label, value) {
|
|
65
|
+
return `<!-- ${label} ${JSON.stringify(value).replace(/>/g, '\\u003e')} -->`;
|
|
66
|
+
}
|
|
67
|
+
export function renderFeedbackMarker(record) {
|
|
68
|
+
const { userContent: _userContent, ...metadata } = record;
|
|
69
|
+
return marker(FEEDBACK_MARKER, metadata);
|
|
70
|
+
}
|
|
71
|
+
export function parseFeedbackMarker(value) {
|
|
72
|
+
const match = value.match(/<!--\s*agents-relay:feedback:v1\s+([\s\S]*?)\s*-->/);
|
|
73
|
+
if (!match)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(match[1]);
|
|
77
|
+
return parsed.schema === FEEDBACK_MARKER && parsed.schemaVersion === FEEDBACK_SCHEMA_VERSION ? parsed : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class FileFeedbackStore {
|
|
84
|
+
root;
|
|
85
|
+
limits;
|
|
86
|
+
constructor(root, limits = {}) {
|
|
87
|
+
this.root = resolve(root);
|
|
88
|
+
this.limits = effectiveLimits(limits);
|
|
89
|
+
}
|
|
90
|
+
async submit(submission) {
|
|
91
|
+
const existing = submission.idempotencyKey ? await this.findByIdempotencyKey(submission.idempotencyKey) : null;
|
|
92
|
+
if (existing)
|
|
93
|
+
return { record: existing, recordRef: this.recordPath(existing.id) };
|
|
94
|
+
const text = submission.text.trim();
|
|
95
|
+
if (!text)
|
|
96
|
+
fail('text is required');
|
|
97
|
+
if (text.length > this.limits.maxTextLength)
|
|
98
|
+
fail(`text exceeds ${this.limits.maxTextLength} characters`);
|
|
99
|
+
const inputs = submission.attachments ?? [];
|
|
100
|
+
if (inputs.length > this.limits.maxAttachments)
|
|
101
|
+
fail(`at most ${this.limits.maxAttachments} attachments are allowed`);
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
const id = randomUUID();
|
|
104
|
+
const attachmentDir = join(this.root, 'attachments', id);
|
|
105
|
+
const attachments = [];
|
|
106
|
+
await mkdir(attachmentDir, { recursive: true });
|
|
107
|
+
try {
|
|
108
|
+
for (const input of inputs) {
|
|
109
|
+
if (!this.limits.allowedMediaTypes.includes(input.mediaType.toLowerCase()))
|
|
110
|
+
fail(`unsupported attachment type: ${input.mediaType}`);
|
|
111
|
+
if (input.data.byteLength === 0 || input.data.byteLength > this.limits.maxAttachmentBytes)
|
|
112
|
+
fail(`attachment exceeds ${this.limits.maxAttachmentBytes} bytes`);
|
|
113
|
+
const attachmentId = randomUUID();
|
|
114
|
+
const filename = cleanFilename(input.filename);
|
|
115
|
+
const relative = join('attachments', id, `${attachmentId}-${filename}`);
|
|
116
|
+
await writeFile(join(this.root, relative), input.data, { flag: 'wx' });
|
|
117
|
+
attachments.push({ id: attachmentId, filename, mediaType: input.mediaType.toLowerCase(), sizeBytes: input.data.byteLength, sha256: createHash('sha256').update(input.data).digest('hex'), storageRef: relative, uploadedAt: now });
|
|
118
|
+
}
|
|
119
|
+
const record = {
|
|
120
|
+
schema: FEEDBACK_MARKER, schemaVersion: FEEDBACK_SCHEMA_VERSION, id,
|
|
121
|
+
...(submission.idempotencyKey ? { idempotencyKey: submission.idempotencyKey } : {}),
|
|
122
|
+
userContent: { text }, attachments, systemProvenance: validateProvenance(submission.provenance),
|
|
123
|
+
triage: { status: 'new', updatedAt: now }, downstream: [], createdAt: now,
|
|
124
|
+
};
|
|
125
|
+
await mkdir(join(this.root, 'records'), { recursive: true });
|
|
126
|
+
const recordRef = this.recordPath(id);
|
|
127
|
+
const temp = `${recordRef}.${randomUUID()}.tmp`;
|
|
128
|
+
await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
129
|
+
await rename(temp, recordRef);
|
|
130
|
+
return { record, recordRef };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
await this.removeAttachmentDirectory(attachmentDir);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async read(id) {
|
|
138
|
+
const parsed = JSON.parse(await readFile(this.recordPath(safeId(id)), 'utf8'));
|
|
139
|
+
if (parsed.schema !== FEEDBACK_MARKER || parsed.schemaVersion !== FEEDBACK_SCHEMA_VERSION)
|
|
140
|
+
fail('record schema is unsupported');
|
|
141
|
+
return parsed;
|
|
142
|
+
}
|
|
143
|
+
recordPath(id) { return join(this.root, 'records', `${id}.json`); }
|
|
144
|
+
async findByIdempotencyKey(key) {
|
|
145
|
+
const directory = join(this.root, 'records');
|
|
146
|
+
let entries;
|
|
147
|
+
try {
|
|
148
|
+
entries = await readdir(directory);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries.filter(item => item.endsWith('.json'))) {
|
|
154
|
+
const record = JSON.parse(await readFile(join(directory, entry), 'utf8'));
|
|
155
|
+
if (record.idempotencyKey === key)
|
|
156
|
+
return record;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
async removeAttachmentDirectory(path) {
|
|
161
|
+
try {
|
|
162
|
+
await rm(path, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
catch { /* best-effort cleanup; the record was not materialized */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export async function materializeFeedbackRecord(root, submission, limits) {
|
|
168
|
+
return new FileFeedbackStore(root, limits).submit(submission);
|
|
169
|
+
}
|
|
170
|
+
/** Redacts common bearer/API-key shapes before a product stores user text. */
|
|
171
|
+
export function redactSensitiveText(text) {
|
|
172
|
+
return text
|
|
173
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
|
174
|
+
.replace(/\b(?:sk|pk)_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED_KEY]')
|
|
175
|
+
.replace(/\b(?:api[_-]?key|token|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]');
|
|
176
|
+
}
|
|
177
|
+
export function createFeedbackRecord(submission, options = {}) {
|
|
178
|
+
if (typeof submission.text !== 'string' || submission.text.length === 0 || submission.text.length > FEEDBACK_MAX_TEXT_LENGTH)
|
|
179
|
+
fail(`text must contain 1-${FEEDBACK_MAX_TEXT_LENGTH} characters`);
|
|
180
|
+
const attachments = (submission.attachments ?? []).map((input, index) => {
|
|
181
|
+
if (!FEEDBACK_IMAGE_TYPES.includes(input.mimeType))
|
|
182
|
+
fail(`attachments[${index}] has an unsupported image format`);
|
|
183
|
+
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes <= 0 || input.sizeBytes > FEEDBACK_MAX_ATTACHMENT_BYTES)
|
|
184
|
+
fail(`attachments[${index}] exceeds the ${FEEDBACK_MAX_ATTACHMENT_BYTES} byte limit`);
|
|
185
|
+
if (!input.reference || input.reference.length > 2_000 || /[?#]/.test(input.reference) || /:\/\/[^/]*:[^@]+@/.test(input.reference))
|
|
186
|
+
fail('attachment reference must be a stable, credential-free reference');
|
|
187
|
+
return { ...input, id: `attachment-${index + 1}` };
|
|
188
|
+
});
|
|
189
|
+
if (attachments.length > FEEDBACK_MAX_ATTACHMENTS)
|
|
190
|
+
fail(`at most ${FEEDBACK_MAX_ATTACHMENTS} attachments are supported`);
|
|
191
|
+
const visibility = submission.visibility ?? 'private';
|
|
192
|
+
const sanitized = options.sanitize === true || submission.sanitized === true;
|
|
193
|
+
if (visibility === 'public' && !sanitized)
|
|
194
|
+
fail('public feedback must be explicitly sanitized');
|
|
195
|
+
const provenance = validateProvenance(submission.provenance);
|
|
196
|
+
return {
|
|
197
|
+
schema: FEEDBACK_SCHEMA, schemaVersion: FEEDBACK_SCHEMA_VERSION, id: options.id ?? `feedback-${randomUUID()}`,
|
|
198
|
+
userContent: { text: sanitized ? redactSensitiveText(submission.text) : submission.text }, attachments, provenance,
|
|
199
|
+
systemMetadata: { receivedAt: options.receivedAt ?? new Date().toISOString(), visibility, sanitized },
|
|
200
|
+
...(submission.contact ? { contact: submission.contact } : {}), triage: { status: 'new' }, downstream: { work: [] },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export function feedbackWorkProvenance(record) {
|
|
204
|
+
return { feedbackId: record.id, feedbackSchema: `${record.schema}/v${record.schemaVersion}`, feedbackReference: `feedback://${record.id}`, feedbackSubmittedAt: record.provenance.submittedAt };
|
|
205
|
+
}
|
|
206
|
+
export function feedbackFingerprint(record) {
|
|
207
|
+
return createHash('sha256').update(JSON.stringify({ text: record.userContent.text, attachments: record.attachments.map(item => item.sha256 ?? item.reference), provenance: record.provenance })).digest('hex');
|
|
208
|
+
}
|
|
209
|
+
export function renderFeedbackMarkdown(record) {
|
|
210
|
+
const markerBody = { schema: record.schema, schemaVersion: record.schemaVersion, id: record.id, provenance: record.provenance, attachmentIds: record.attachments.map(item => item.id), triage: record.triage, downstream: record.downstream };
|
|
211
|
+
const attachments = record.attachments.length === 0 ? '_None._' : record.attachments.map(item => `- ${item.id}: ${item.mimeType}, ${item.sizeBytes} bytes, ref \`${item.reference}\``).join('\n');
|
|
212
|
+
return `${marker(FEEDBACK_MARKER, markerBody)}\n# Feedback ${record.id}\n\n## User report\n\n${record.userContent.text}\n\n## Attachments\n\n${attachments}\n`;
|
|
213
|
+
}
|
|
214
|
+
export async function materializeFeedback(first, second, third) {
|
|
215
|
+
if (typeof first === 'string')
|
|
216
|
+
return materializeFeedbackRecord(first, second, third);
|
|
217
|
+
const path = second;
|
|
218
|
+
const format = third ?? 'json';
|
|
219
|
+
if (!path || path.includes('\0'))
|
|
220
|
+
throw new Error('Feedback materialization path is invalid');
|
|
221
|
+
await mkdir(resolve(path, '..'), { recursive: true });
|
|
222
|
+
const body = format === 'markdown' ? renderFeedbackMarkdown(first) : `${JSON.stringify(first, null, 2)}\n`;
|
|
223
|
+
await writeFile(path, body, { encoding: 'utf8', mode: 0o600 });
|
|
224
|
+
}
|
|
225
|
+
export async function readFeedbackAttachment(root, attachment) {
|
|
226
|
+
const path = resolve(root, attachment.storageRef);
|
|
227
|
+
const rootPath = resolve(root);
|
|
228
|
+
if (!path.startsWith(`${rootPath}/`))
|
|
229
|
+
fail('attachment reference escapes storage root');
|
|
230
|
+
const bytes = await readFile(path);
|
|
231
|
+
if (bytes.byteLength !== attachment.sizeBytes || createHash('sha256').update(bytes).digest('hex') !== attachment.sha256)
|
|
232
|
+
fail('attachment integrity check failed');
|
|
233
|
+
return bytes;
|
|
234
|
+
}
|
package/dist/relayd.js
CHANGED
|
@@ -11,7 +11,7 @@ import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
|
|
|
11
11
|
import { Reconciler } from './reconciler.js';
|
|
12
12
|
import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestGraphql } from './store.js';
|
|
13
13
|
import { githubAuthContext } from './github-auth.js';
|
|
14
|
-
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
14
|
+
import { dashboardJobAction, dashboardTaskAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
15
15
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
16
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
|
|
17
17
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
@@ -127,6 +127,13 @@ export async function runDaemon(argv) {
|
|
|
127
127
|
const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
|
|
128
128
|
return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
|
|
129
129
|
};
|
|
130
|
+
const workspaceTaskAction = (action) => async (jobId, taskId) => {
|
|
131
|
+
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
132
|
+
if (!overview)
|
|
133
|
+
throw new Error(`Managed job ${jobId} not found`);
|
|
134
|
+
const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
|
|
135
|
+
return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
|
|
136
|
+
};
|
|
130
137
|
const secretFile = value(argv, '--github-webhook-secret-file');
|
|
131
138
|
const secret = value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
|
|
132
139
|
const webhook = secret ? {
|
|
@@ -170,6 +177,8 @@ export async function runDaemon(argv) {
|
|
|
170
177
|
start: workspaceJobAction('start'),
|
|
171
178
|
reconcile: workspaceJobAction('reconcile'),
|
|
172
179
|
cancel: workspaceJobAction('cancel'),
|
|
180
|
+
taskRetry: workspaceTaskAction('retry'),
|
|
181
|
+
taskCancel: workspaceTaskAction('cancel'),
|
|
173
182
|
});
|
|
174
183
|
await once(server, 'listening');
|
|
175
184
|
const reconcileSafely = async () => {
|