nexus-agentd 0.2.5 → 0.3.0
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 +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
package/dist/process-tree.js
CHANGED
|
@@ -3,30 +3,39 @@ const DEFAULT_GRACE_MS = 2_000;
|
|
|
3
3
|
const FORCE_KILL_WAIT_MS = 1_000;
|
|
4
4
|
export async function terminateProcessTree(child, options = {}) {
|
|
5
5
|
const pid = child.pid;
|
|
6
|
-
if (!pid
|
|
6
|
+
if (!pid)
|
|
7
7
|
return;
|
|
8
8
|
const graceMs = Math.max(0, options.graceMs ?? DEFAULT_GRACE_MS);
|
|
9
9
|
if (process.platform === 'win32') {
|
|
10
|
+
// Once a Windows process has exited taskkill can no longer address its
|
|
11
|
+
// descendants by the parent's PID. Callers should tear down before
|
|
12
|
+
// exit when they need Windows tree semantics.
|
|
13
|
+
if (hasExited(child))
|
|
14
|
+
return;
|
|
10
15
|
await taskkill(pid, false);
|
|
11
16
|
if (await waitForExit(child, graceMs))
|
|
12
17
|
return;
|
|
13
18
|
await taskkill(pid, true);
|
|
14
|
-
await waitForExit(child, FORCE_KILL_WAIT_MS)
|
|
15
|
-
|
|
19
|
+
if (await waitForExit(child, FORCE_KILL_WAIT_MS))
|
|
20
|
+
return;
|
|
21
|
+
throw new Error(`Failed to terminate process ${pid}`);
|
|
16
22
|
}
|
|
17
23
|
if (options.ownsProcessGroup === true) {
|
|
18
24
|
signalUnixGroup(pid, 'SIGTERM');
|
|
19
25
|
if (await waitForUnixGroupExit(pid, graceMs))
|
|
20
26
|
return;
|
|
21
27
|
signalUnixGroup(pid, 'SIGKILL');
|
|
22
|
-
await waitForUnixGroupExit(pid, FORCE_KILL_WAIT_MS)
|
|
23
|
-
|
|
28
|
+
if (await waitForUnixGroupExit(pid, FORCE_KILL_WAIT_MS))
|
|
29
|
+
return;
|
|
30
|
+
throw new Error(`Failed to terminate process group ${pid}`);
|
|
24
31
|
}
|
|
25
32
|
signalUnix(child, 'SIGTERM');
|
|
26
33
|
if (await waitForExit(child, graceMs))
|
|
27
34
|
return;
|
|
28
35
|
signalUnix(child, 'SIGKILL');
|
|
29
|
-
await waitForExit(child, FORCE_KILL_WAIT_MS)
|
|
36
|
+
if (await waitForExit(child, FORCE_KILL_WAIT_MS))
|
|
37
|
+
return;
|
|
38
|
+
throw new Error(`Failed to terminate process ${pid}`);
|
|
30
39
|
}
|
|
31
40
|
function signalUnix(child, signal) {
|
|
32
41
|
try {
|
package/dist/run-store.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { ArtifactStore, type ArtifactReadResult, type ArtifactStoreMetrics, type ArtifactStoreOptions, type StoredArtifactView } from './artifact-store.js';
|
|
2
|
+
import type { AgentdArtifact, AgentdProtocol, AgentdRunDetail, AgentdRunView, AgentdSessionState } from './types.js';
|
|
3
|
+
export { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
4
|
+
export type { ArtifactReadResult, ArtifactStorageStatus, ArtifactStoreMetrics, ArtifactStoreIo, ArtifactStoreOptions, StoredArtifactView } from './artifact-store.js';
|
|
5
|
+
interface StoredRun extends Omit<AgentdRunDetail, 'taskPreview'> {
|
|
3
6
|
ownerKeyId: string;
|
|
4
7
|
}
|
|
5
8
|
export interface CreateRunInput {
|
|
@@ -12,6 +15,17 @@ export interface CreateRunInput {
|
|
|
12
15
|
ownerKeyId: string;
|
|
13
16
|
task: string;
|
|
14
17
|
inputAttachmentCount?: number;
|
|
18
|
+
retryOfRunId?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface RunStoreOptions extends ArtifactStoreOptions {
|
|
21
|
+
/** History count cap; the positional maxRuns argument takes precedence. */
|
|
22
|
+
maxRuns?: number;
|
|
23
|
+
/** Task text cap; the positional maxTaskChars argument takes precedence. */
|
|
24
|
+
maxTaskChars?: number;
|
|
25
|
+
/** Terminal run metadata retention duration. Active runs are protected. */
|
|
26
|
+
historyRetentionDays?: number;
|
|
27
|
+
/** JSON run-history byte budget, independent from artifact payload bytes. */
|
|
28
|
+
historyMaxBytes?: number;
|
|
15
29
|
}
|
|
16
30
|
export interface RunListQuery {
|
|
17
31
|
agentId?: string;
|
|
@@ -19,24 +33,60 @@ export interface RunListQuery {
|
|
|
19
33
|
state?: AgentdSessionState;
|
|
20
34
|
query?: string;
|
|
21
35
|
limit?: number;
|
|
36
|
+
offset?: number;
|
|
22
37
|
}
|
|
23
38
|
export declare class RunStore {
|
|
24
39
|
readonly filePath: string;
|
|
25
|
-
private readonly maxRuns;
|
|
26
|
-
private readonly maxTaskChars;
|
|
27
40
|
private readonly runs;
|
|
28
41
|
private initialized;
|
|
29
42
|
private dirty;
|
|
30
43
|
private persistTimer?;
|
|
31
44
|
private writeQueue;
|
|
32
|
-
|
|
45
|
+
readonly artifactStore: ArtifactStore;
|
|
46
|
+
private readonly maxRuns;
|
|
47
|
+
private readonly maxTaskChars;
|
|
48
|
+
private readonly historyRetentionDays?;
|
|
49
|
+
private readonly historyMaxBytes?;
|
|
50
|
+
private readonly now;
|
|
51
|
+
private lastWriteMs;
|
|
52
|
+
private writeFailures;
|
|
53
|
+
private historyBytes;
|
|
54
|
+
constructor(filePath: string, maxRuns?: number, maxTaskChars?: number | RunStoreOptions, options?: RunStoreOptions);
|
|
55
|
+
constructor(filePath: string, options?: RunStoreOptions);
|
|
33
56
|
init(): Promise<void>;
|
|
34
57
|
create(input: CreateRunInput): AgentdRunDetail;
|
|
35
|
-
update(id: string, patch: Partial<Pick<StoredRun, 'protocolSessionId' | 'state' | 'progress' | 'output' | 'error' | 'artifacts' | 'artifactCount' | 'updatedAt' | 'endedAt' | 'durationMs' | 'inputAttachmentCount' | 'completion'>>): AgentdRunDetail | undefined;
|
|
58
|
+
update(id: string, patch: Partial<Pick<StoredRun, 'protocolSessionId' | 'state' | 'progress' | 'output' | 'error' | 'artifacts' | 'artifactCount' | 'updatedAt' | 'endedAt' | 'durationMs' | 'inputAttachmentCount' | 'completion' | 'retryOfRunId'>>): AgentdRunDetail | undefined;
|
|
59
|
+
/** Queue payload snapshots without blocking the protocol sink. */
|
|
60
|
+
recordArtifacts(runId: string, artifacts: AgentdArtifact[]): StoredArtifactView[] | undefined;
|
|
61
|
+
artifactViews(runId: string): StoredArtifactView[] | undefined;
|
|
62
|
+
readArtifact(runId: string, artifactId: string): Promise<ArtifactReadResult | undefined>;
|
|
63
|
+
getOwnerKeyId(runId: string): string | undefined;
|
|
64
|
+
metrics(): {
|
|
65
|
+
historyBytes: number;
|
|
66
|
+
retainedRuns: number;
|
|
67
|
+
pendingWrites: number;
|
|
68
|
+
lastWriteMs: number;
|
|
69
|
+
writeFailures: number;
|
|
70
|
+
artifactStoreStats: ArtifactStoreMetrics;
|
|
71
|
+
};
|
|
72
|
+
stats(): {
|
|
73
|
+
historyBytes: number;
|
|
74
|
+
retainedRuns: number;
|
|
75
|
+
pendingWrites: number;
|
|
76
|
+
lastWriteMs: number;
|
|
77
|
+
writeFailures: number;
|
|
78
|
+
artifactStoreStats: ArtifactStoreMetrics;
|
|
79
|
+
};
|
|
80
|
+
setRetryOfRunId(runId: string, retryOfRunId?: string): AgentdRunDetail | undefined;
|
|
36
81
|
get(id: string): AgentdRunDetail | undefined;
|
|
37
82
|
list(query?: RunListQuery): {
|
|
38
83
|
runs: AgentdRunView[];
|
|
39
84
|
total: number;
|
|
85
|
+
stats: {
|
|
86
|
+
active: number;
|
|
87
|
+
completed: number;
|
|
88
|
+
failed: number;
|
|
89
|
+
};
|
|
40
90
|
};
|
|
41
91
|
flush(): Promise<void>;
|
|
42
92
|
private load;
|
|
@@ -44,6 +94,6 @@ export declare class RunStore {
|
|
|
44
94
|
private prune;
|
|
45
95
|
private persist;
|
|
46
96
|
private assertInitialized;
|
|
97
|
+
private applyArtifactViews;
|
|
47
98
|
}
|
|
48
99
|
export declare function runStorePathForConfig(configPath: string): string;
|
|
49
|
-
export {};
|
package/dist/run-store.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
5
|
+
export { ArtifactStore, artifactStorePathForRunStore } from './artifact-store.js';
|
|
4
6
|
const STATES = new Set([
|
|
5
7
|
'created',
|
|
6
8
|
'running',
|
|
@@ -14,17 +16,43 @@ const MAX_OUTPUT_CHARS = 256 * 1024;
|
|
|
14
16
|
const MAX_SUMMARY_CHARS = 600;
|
|
15
17
|
export class RunStore {
|
|
16
18
|
filePath;
|
|
17
|
-
maxRuns;
|
|
18
|
-
maxTaskChars;
|
|
19
19
|
runs = new Map();
|
|
20
20
|
initialized = false;
|
|
21
21
|
dirty = false;
|
|
22
22
|
persistTimer;
|
|
23
23
|
writeQueue = Promise.resolve();
|
|
24
|
-
|
|
24
|
+
artifactStore;
|
|
25
|
+
maxRuns;
|
|
26
|
+
maxTaskChars;
|
|
27
|
+
historyRetentionDays;
|
|
28
|
+
historyMaxBytes;
|
|
29
|
+
now;
|
|
30
|
+
lastWriteMs = 0;
|
|
31
|
+
writeFailures = 0;
|
|
32
|
+
historyBytes = 0;
|
|
33
|
+
constructor(filePath, maxRunsOrOptions = 1000, maxTaskCharsOrOptions = undefined, suppliedOptions = {}) {
|
|
25
34
|
this.filePath = filePath;
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
const positionalOptions = typeof maxRunsOrOptions === 'object' ? maxRunsOrOptions : { ...suppliedOptions };
|
|
36
|
+
if (typeof maxTaskCharsOrOptions === 'object') {
|
|
37
|
+
Object.assign(positionalOptions, maxTaskCharsOrOptions);
|
|
38
|
+
}
|
|
39
|
+
this.maxRuns = positiveInteger(typeof maxRunsOrOptions === 'number'
|
|
40
|
+
? maxRunsOrOptions
|
|
41
|
+
: positionalOptions.maxRuns, 1000);
|
|
42
|
+
this.maxTaskChars = positiveInteger(typeof maxTaskCharsOrOptions === 'number'
|
|
43
|
+
? maxTaskCharsOrOptions
|
|
44
|
+
: maxTaskCharsOrOptions?.maxTaskChars ?? positionalOptions.maxTaskChars, 1024 * 1024);
|
|
45
|
+
this.historyRetentionDays = nonNegativeNumber(positionalOptions.historyRetentionDays, undefined);
|
|
46
|
+
this.historyMaxBytes = nonNegativeInteger(positionalOptions.historyMaxBytes, undefined);
|
|
47
|
+
this.now = positionalOptions.now || Date.now;
|
|
48
|
+
const artifactRoot = positionalOptions.rootDir ||
|
|
49
|
+
positionalOptions.artifactRootDir ||
|
|
50
|
+
artifactStorePathForRunStore(filePath);
|
|
51
|
+
this.artifactStore = new ArtifactStore(artifactRoot, {
|
|
52
|
+
...positionalOptions,
|
|
53
|
+
onViewsChanged: (runId, views) => this.applyArtifactViews(runId, views),
|
|
54
|
+
isRunActive: (runId) => isActive(this.runs.get(runId)?.state)
|
|
55
|
+
});
|
|
28
56
|
}
|
|
29
57
|
async init() {
|
|
30
58
|
if (this.initialized)
|
|
@@ -52,8 +80,7 @@ export class RunStore {
|
|
|
52
80
|
}
|
|
53
81
|
}
|
|
54
82
|
}
|
|
55
|
-
|
|
56
|
-
const now = Date.now();
|
|
83
|
+
const now = this.now();
|
|
57
84
|
for (const [id, run] of this.runs) {
|
|
58
85
|
if (!isActive(run.state))
|
|
59
86
|
continue;
|
|
@@ -68,6 +95,16 @@ export class RunStore {
|
|
|
68
95
|
});
|
|
69
96
|
this.dirty = true;
|
|
70
97
|
}
|
|
98
|
+
await this.artifactStore.init();
|
|
99
|
+
for (const [id, run] of this.runs) {
|
|
100
|
+
const views = this.artifactStore.restoreRun(id, run.artifacts);
|
|
101
|
+
if (JSON.stringify(run.artifacts) !== JSON.stringify(views)) {
|
|
102
|
+
run.artifacts = views;
|
|
103
|
+
run.artifactCount = views.length;
|
|
104
|
+
this.dirty = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.initialized = true;
|
|
71
108
|
this.prune();
|
|
72
109
|
if (!(await fileExists(this.filePath)))
|
|
73
110
|
this.dirty = true;
|
|
@@ -76,7 +113,7 @@ export class RunStore {
|
|
|
76
113
|
}
|
|
77
114
|
create(input) {
|
|
78
115
|
this.assertInitialized();
|
|
79
|
-
const now =
|
|
116
|
+
const now = this.now();
|
|
80
117
|
const taskTruncated = input.task.length > this.maxTaskChars;
|
|
81
118
|
const run = {
|
|
82
119
|
id: randomUUID(),
|
|
@@ -89,6 +126,7 @@ export class RunStore {
|
|
|
89
126
|
ownerKeyId: input.ownerKeyId,
|
|
90
127
|
task: taskTruncated ? input.task.slice(0, this.maxTaskChars) : input.task,
|
|
91
128
|
taskTruncated: taskTruncated || undefined,
|
|
129
|
+
retryOfRunId: input.retryOfRunId,
|
|
92
130
|
inputAttachmentCount: input.inputAttachmentCount || undefined,
|
|
93
131
|
state: 'running',
|
|
94
132
|
progress: { phase: '已接收', message: '任务已交给智能体' },
|
|
@@ -109,10 +147,15 @@ export class RunStore {
|
|
|
109
147
|
const output = patch.output === undefined
|
|
110
148
|
? current.output
|
|
111
149
|
: clipTail(patch.output, MAX_OUTPUT_CHARS);
|
|
112
|
-
const updatedAt = patch.updatedAt ??
|
|
150
|
+
const updatedAt = patch.updatedAt ?? this.now();
|
|
151
|
+
const normalizedPatch = structuredClone(patch);
|
|
152
|
+
if (patch.artifacts !== undefined) {
|
|
153
|
+
normalizedPatch.artifacts = this.artifactStore.mergeViews(id, patch.artifacts, false);
|
|
154
|
+
normalizedPatch.artifactCount = normalizedPatch.artifacts.length;
|
|
155
|
+
}
|
|
113
156
|
const next = {
|
|
114
157
|
...current,
|
|
115
|
-
...
|
|
158
|
+
...normalizedPatch,
|
|
116
159
|
output,
|
|
117
160
|
resultSummary: output ? summarize(output) : current.resultSummary,
|
|
118
161
|
updatedAt
|
|
@@ -120,11 +163,54 @@ export class RunStore {
|
|
|
120
163
|
if (patch.endedAt !== undefined) {
|
|
121
164
|
next.durationMs = Math.max(0, patch.endedAt - next.startedAt);
|
|
122
165
|
}
|
|
123
|
-
|
|
166
|
+
// Preserve creation order as the stable tie-breaker for same-ms tasks.
|
|
124
167
|
this.runs.set(id, next);
|
|
125
168
|
this.changed();
|
|
126
169
|
return publicDetail(next);
|
|
127
170
|
}
|
|
171
|
+
/** Queue payload snapshots without blocking the protocol sink. */
|
|
172
|
+
recordArtifacts(runId, artifacts) {
|
|
173
|
+
this.assertInitialized();
|
|
174
|
+
if (!this.runs.has(runId))
|
|
175
|
+
return undefined;
|
|
176
|
+
return this.artifactStore.recordArtifacts(runId, artifacts);
|
|
177
|
+
}
|
|
178
|
+
artifactViews(runId) {
|
|
179
|
+
this.assertInitialized();
|
|
180
|
+
if (!this.runs.has(runId))
|
|
181
|
+
return undefined;
|
|
182
|
+
return this.artifactStore.views(runId);
|
|
183
|
+
}
|
|
184
|
+
async readArtifact(runId, artifactId) {
|
|
185
|
+
this.assertInitialized();
|
|
186
|
+
if (!this.runs.has(runId))
|
|
187
|
+
return undefined;
|
|
188
|
+
return this.artifactStore.readArtifact(runId, artifactId);
|
|
189
|
+
}
|
|
190
|
+
getOwnerKeyId(runId) {
|
|
191
|
+
this.assertInitialized();
|
|
192
|
+
return this.runs.get(runId)?.ownerKeyId;
|
|
193
|
+
}
|
|
194
|
+
metrics() {
|
|
195
|
+
const artifactStoreStats = this.artifactStore.stats();
|
|
196
|
+
return {
|
|
197
|
+
// This is the last successfully persisted JSON size. A dirty store
|
|
198
|
+
// reports that size until its queued write completes, avoiding a
|
|
199
|
+
// full-history serialization on every metrics poll.
|
|
200
|
+
historyBytes: this.historyBytes,
|
|
201
|
+
retainedRuns: this.runs.size,
|
|
202
|
+
pendingWrites: (this.dirty ? 1 : 0) + artifactStoreStats.pendingWrites,
|
|
203
|
+
lastWriteMs: this.lastWriteMs,
|
|
204
|
+
writeFailures: this.writeFailures,
|
|
205
|
+
artifactStoreStats
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
stats() {
|
|
209
|
+
return this.metrics();
|
|
210
|
+
}
|
|
211
|
+
setRetryOfRunId(runId, retryOfRunId) {
|
|
212
|
+
return this.update(runId, { retryOfRunId });
|
|
213
|
+
}
|
|
128
214
|
get(id) {
|
|
129
215
|
this.assertInitialized();
|
|
130
216
|
const run = this.runs.get(id);
|
|
@@ -134,8 +220,10 @@ export class RunStore {
|
|
|
134
220
|
this.assertInitialized();
|
|
135
221
|
const needle = String(query.query || '').trim().toLowerCase();
|
|
136
222
|
const limit = Math.min(200, Math.max(1, Number(query.limit) || 50));
|
|
223
|
+
const offset = Math.max(0, Math.floor(Number(query.offset) || 0));
|
|
137
224
|
const matched = Array.from(this.runs.values())
|
|
138
225
|
.reverse()
|
|
226
|
+
.sort((left, right) => right.startedAt - left.startedAt)
|
|
139
227
|
.filter((run) => !query.agentId || run.agentId === query.agentId)
|
|
140
228
|
.filter((run) => !query.sessionId || run.sessionId === query.sessionId)
|
|
141
229
|
.filter((run) => !query.state || run.state === query.state)
|
|
@@ -144,14 +232,28 @@ export class RunStore {
|
|
|
144
232
|
run.agentName.toLowerCase().includes(needle) ||
|
|
145
233
|
run.id.toLowerCase().includes(needle));
|
|
146
234
|
const total = matched.length;
|
|
147
|
-
const
|
|
148
|
-
|
|
235
|
+
const stats = { active: 0, completed: 0, failed: 0 };
|
|
236
|
+
for (const run of matched) {
|
|
237
|
+
if (isActive(run.state))
|
|
238
|
+
stats.active++;
|
|
239
|
+
if (run.state === 'completed')
|
|
240
|
+
stats.completed++;
|
|
241
|
+
if (run.state === 'failed')
|
|
242
|
+
stats.failed++;
|
|
243
|
+
}
|
|
244
|
+
const runs = matched.slice(offset, offset + limit).map(publicView);
|
|
245
|
+
return { runs, total, stats };
|
|
149
246
|
}
|
|
150
247
|
async flush() {
|
|
151
248
|
this.assertInitialized();
|
|
152
249
|
if (this.persistTimer)
|
|
153
250
|
clearTimeout(this.persistTimer);
|
|
154
251
|
this.persistTimer = undefined;
|
|
252
|
+
if (this.dirty)
|
|
253
|
+
await this.persist();
|
|
254
|
+
await this.artifactStore.flush();
|
|
255
|
+
// Successful artifact commits update run metadata via the callback
|
|
256
|
+
// above, so persist those storage facts as well.
|
|
155
257
|
if (this.dirty)
|
|
156
258
|
await this.persist();
|
|
157
259
|
await this.writeQueue;
|
|
@@ -165,6 +267,7 @@ export class RunStore {
|
|
|
165
267
|
!Array.isArray(parsed.runs)) {
|
|
166
268
|
throw new InvalidRunFileError('Invalid Agent Nexus run history file');
|
|
167
269
|
}
|
|
270
|
+
this.historyBytes = Buffer.byteLength(raw);
|
|
168
271
|
for (const value of parsed.runs) {
|
|
169
272
|
const run = normalizeRun(value);
|
|
170
273
|
if (run)
|
|
@@ -189,12 +292,39 @@ export class RunStore {
|
|
|
189
292
|
this.persistTimer.unref?.();
|
|
190
293
|
}
|
|
191
294
|
prune() {
|
|
192
|
-
|
|
193
|
-
const terminal = Array.from(this.runs.entries())
|
|
295
|
+
const removeOldestTerminal = (predicate) => {
|
|
296
|
+
const terminal = Array.from(this.runs.entries())
|
|
297
|
+
.filter(([, run]) => !isActive(run.state) && predicate(run))
|
|
298
|
+
.sort(([, left], [, right]) => left.updatedAt - right.updatedAt)[0];
|
|
194
299
|
if (!terminal)
|
|
195
|
-
|
|
300
|
+
return false;
|
|
196
301
|
this.runs.delete(terminal[0]);
|
|
302
|
+
this.artifactStore.removeRun(terminal[0]);
|
|
197
303
|
this.dirty = true;
|
|
304
|
+
return true;
|
|
305
|
+
};
|
|
306
|
+
while (this.runs.size > this.maxRuns) {
|
|
307
|
+
if (!removeOldestTerminal(() => true))
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
if (this.historyRetentionDays !== undefined) {
|
|
311
|
+
const cutoff = this.now() - this.historyRetentionDays * 24 * 60 * 60 * 1000;
|
|
312
|
+
while (removeOldestTerminal((run) => (run.endedAt ?? run.updatedAt) <= cutoff)) { }
|
|
313
|
+
}
|
|
314
|
+
if (this.historyMaxBytes !== undefined) {
|
|
315
|
+
const historyBytes = () => Buffer.byteLength(JSON.stringify(Array.from(this.runs.values())));
|
|
316
|
+
while (historyBytes() > this.historyMaxBytes) {
|
|
317
|
+
if (!removeOldestTerminal(() => true)) {
|
|
318
|
+
console.error(JSON.stringify({
|
|
319
|
+
level: 'warn',
|
|
320
|
+
event: 'run_history_budget_exceeded',
|
|
321
|
+
bytes: historyBytes(),
|
|
322
|
+
budget: this.historyMaxBytes,
|
|
323
|
+
activeRunsProtected: Array.from(this.runs.values()).filter((run) => isActive(run.state)).length
|
|
324
|
+
}));
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
198
328
|
}
|
|
199
329
|
}
|
|
200
330
|
persist() {
|
|
@@ -207,13 +337,22 @@ export class RunStore {
|
|
|
207
337
|
};
|
|
208
338
|
const serialized = `${JSON.stringify(payload, null, 2)}\n`;
|
|
209
339
|
const write = async () => {
|
|
340
|
+
const startedAt = this.now();
|
|
210
341
|
const temp = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
342
|
+
try {
|
|
343
|
+
await writeFile(temp, serialized, { encoding: 'utf8', mode: 0o600 });
|
|
344
|
+
await rename(temp, this.filePath);
|
|
345
|
+
await chmod(this.filePath, 0o600).catch(() => undefined);
|
|
346
|
+
this.historyBytes = Buffer.byteLength(serialized);
|
|
347
|
+
this.lastWriteMs = Math.max(0, this.now() - startedAt);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await unlink(temp).catch(() => undefined);
|
|
351
|
+
}
|
|
214
352
|
};
|
|
215
353
|
const next = this.writeQueue.then(write, write).catch((error) => {
|
|
216
354
|
this.dirty = true;
|
|
355
|
+
this.writeFailures++;
|
|
217
356
|
throw error;
|
|
218
357
|
});
|
|
219
358
|
this.writeQueue = next.catch(() => undefined);
|
|
@@ -223,6 +362,19 @@ export class RunStore {
|
|
|
223
362
|
if (!this.initialized)
|
|
224
363
|
throw new Error('RunStore is not initialized');
|
|
225
364
|
}
|
|
365
|
+
applyArtifactViews(runId, views) {
|
|
366
|
+
const run = this.runs.get(runId);
|
|
367
|
+
if (!run)
|
|
368
|
+
return;
|
|
369
|
+
run.artifacts = structuredClone(views);
|
|
370
|
+
run.artifactCount = run.artifacts.length;
|
|
371
|
+
run.updatedAt = this.now();
|
|
372
|
+
if (!this.initialized) {
|
|
373
|
+
this.dirty = true;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.changed();
|
|
377
|
+
}
|
|
226
378
|
}
|
|
227
379
|
export function runStorePathForConfig(configPath) {
|
|
228
380
|
const absolute = path.resolve(configPath);
|
|
@@ -254,13 +406,26 @@ function normalizeRun(value) {
|
|
|
254
406
|
artifactCount: Number.isFinite(run.artifactCount) ? run.artifactCount : 0
|
|
255
407
|
};
|
|
256
408
|
}
|
|
409
|
+
function positiveInteger(value, fallback) {
|
|
410
|
+
return Number.isFinite(value) && Number(value) > 0 ? Math.floor(Number(value)) : fallback;
|
|
411
|
+
}
|
|
412
|
+
function nonNegativeInteger(value, fallback) {
|
|
413
|
+
return Number.isFinite(value) && Number(value) >= 0 ? Math.floor(Number(value)) : fallback;
|
|
414
|
+
}
|
|
415
|
+
function nonNegativeNumber(value, fallback) {
|
|
416
|
+
return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : fallback;
|
|
417
|
+
}
|
|
257
418
|
function publicView(run) {
|
|
258
|
-
const { ownerKeyId: _ownerKeyId, output: _output, artifacts: _artifacts, ...view } = run;
|
|
259
|
-
return structuredClone(view)
|
|
419
|
+
const { ownerKeyId: _ownerKeyId, task, output: _output, artifacts: _artifacts, ...view } = run;
|
|
420
|
+
return structuredClone({ ...view, taskPreview: task.slice(0, 240),
|
|
421
|
+
// Remote errors/progress can be large too; full values remain in details.
|
|
422
|
+
error: view.error?.slice(0, 600),
|
|
423
|
+
progress: { ...view.progress, phase: view.progress.phase.slice(0, 120), message: view.progress.message?.slice(0, 600) }
|
|
424
|
+
});
|
|
260
425
|
}
|
|
261
426
|
function publicDetail(run) {
|
|
262
427
|
const { ownerKeyId: _ownerKeyId, ...detail } = run;
|
|
263
|
-
return structuredClone(detail);
|
|
428
|
+
return structuredClone({ ...detail, taskPreview: run.task.slice(0, 240) });
|
|
264
429
|
}
|
|
265
430
|
function summarize(value) {
|
|
266
431
|
const compact = value.replace(/\s+/g, ' ').trim();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { AdminSessionStore } from '../auth.js';
|
|
3
|
+
import type { AgentdControlPlane } from '../control-plane.js';
|
|
4
|
+
import type { AgentdApiKeyPrincipal, AgentdConfig } from '../types.js';
|
|
5
|
+
export declare const ADMIN_COOKIE = "agent_nexus_admin";
|
|
6
|
+
export interface FailureLimiter {
|
|
7
|
+
assertAllowed(key: string): void;
|
|
8
|
+
failure(key: string): void;
|
|
9
|
+
success(key: string): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function authenticateAdmin(request: IncomingMessage, sessions: AdminSessionStore): void;
|
|
12
|
+
export declare function authenticateApiKey(request: IncomingMessage, config: AgentdConfig, controlPlane: AgentdControlPlane | undefined, limiter: FailureLimiter): AgentdApiKeyPrincipal;
|
|
13
|
+
export declare function fallbackApiKey(config: AgentdConfig, secret: string): AgentdApiKeyPrincipal | undefined;
|
|
14
|
+
export declare function assertAgentScope(principal: AgentdApiKeyPrincipal, agentId: string): void;
|
|
15
|
+
export declare function isInitialized(config: AgentdConfig, controlPlane?: AgentdControlPlane): boolean;
|
|
16
|
+
export declare function needsAdminSetup(config: AgentdConfig, controlPlane?: AgentdControlPlane): any;
|
|
17
|
+
export declare function consoleHost(request: IncomingMessage): import("url").URL;
|
|
18
|
+
export declare function isDirectConsoleHost(request: IncomingMessage, config: AgentdConfig, hostname: string): boolean;
|
|
19
|
+
export declare function assertConsoleHost(request: IncomingMessage, config: AgentdConfig): void;
|
|
20
|
+
export declare function assertTrustedOrigin(request: IncomingMessage, config: AgentdConfig): void;
|
|
21
|
+
export declare function originMatchesHost(origin: string, request: IncomingMessage): boolean;
|
|
22
|
+
export declare function cookieValue(request: IncomingMessage, name: string): string;
|
|
23
|
+
export declare function adminCookie(value: string, ttlMs: number, config: AgentdConfig): string;
|
|
24
|
+
export declare function clearAdminCookie(config: AgentdConfig): string;
|
|
25
|
+
export declare function isMutating(method?: string): method is "POST" | "PUT" | "PATCH" | "DELETE";
|
|
26
|
+
export declare function remoteKey(request: IncomingMessage): string;
|
|
27
|
+
export declare function stringHeader(value: string | string[] | undefined): string;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { RequestError } from './validation.js';
|
|
3
|
+
export const ADMIN_COOKIE = 'agent_nexus_admin';
|
|
4
|
+
export function authenticateAdmin(request, sessions) {
|
|
5
|
+
const id = cookieValue(request, ADMIN_COOKIE);
|
|
6
|
+
if (!id || !sessions.has(id))
|
|
7
|
+
throw new RequestError(401, 'Admin session is required');
|
|
8
|
+
}
|
|
9
|
+
export function authenticateApiKey(request, config, controlPlane, limiter) {
|
|
10
|
+
const rateKey = remoteKey(request);
|
|
11
|
+
limiter.assertAllowed(rateKey);
|
|
12
|
+
const value = stringHeader(request.headers.authorization);
|
|
13
|
+
if (!value.startsWith('Bearer ')) {
|
|
14
|
+
limiter.failure(rateKey);
|
|
15
|
+
throw new RequestError(401, 'Bearer API Key is required');
|
|
16
|
+
}
|
|
17
|
+
const secret = value.slice(7);
|
|
18
|
+
// A live control plane is authoritative, including a negative decision.
|
|
19
|
+
const principal = controlPlane
|
|
20
|
+
? controlPlane.authenticateApiKey(secret)
|
|
21
|
+
: fallbackApiKey(config, secret);
|
|
22
|
+
if (!principal) {
|
|
23
|
+
limiter.failure(rateKey);
|
|
24
|
+
throw new RequestError(401, 'Invalid API Key');
|
|
25
|
+
}
|
|
26
|
+
limiter.success(rateKey);
|
|
27
|
+
return principal;
|
|
28
|
+
}
|
|
29
|
+
export function fallbackApiKey(config, secret) {
|
|
30
|
+
for (const key of config.apiKeys || []) {
|
|
31
|
+
if (key.enabled && safeEqual(key.secret, secret)) {
|
|
32
|
+
return { id: key.id, scope: structuredClone(key.scope) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!config.apiKeys?.some((key) => key.id === 'legacy') && config.authToken && safeEqual(config.authToken, secret)) {
|
|
36
|
+
return { id: 'legacy', scope: { allAgents: true, agentIds: [] } };
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
export function assertAgentScope(principal, agentId) {
|
|
41
|
+
if (!principal.scope.allAgents && !principal.scope.agentIds.includes(agentId)) {
|
|
42
|
+
throw new RequestError(403, 'API Key is not authorized for this agent');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function isInitialized(config, controlPlane) {
|
|
46
|
+
return controlPlane?.isInitialized() ?? config.initialized;
|
|
47
|
+
}
|
|
48
|
+
export function needsAdminSetup(config, controlPlane) {
|
|
49
|
+
const value = controlPlane?.needsAdminSetup;
|
|
50
|
+
return typeof value === 'function' ? value.call(controlPlane) : !config.adminPasswordHash;
|
|
51
|
+
}
|
|
52
|
+
export function consoleHost(request) {
|
|
53
|
+
const host = stringHeader(request.headers.host);
|
|
54
|
+
try {
|
|
55
|
+
if (!/^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9.-]+)(?::\d{1,5})?$/.test(host))
|
|
56
|
+
throw new Error();
|
|
57
|
+
const url = new URL(`http://${host}`);
|
|
58
|
+
return url;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new RequestError(403, 'Console Host is not allowed');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function isDirectConsoleHost(request, config, hostname) {
|
|
65
|
+
const local = request.socket.localAddress?.replace(/^::ffff:/, '');
|
|
66
|
+
const hosts = new Set(['localhost', '127.0.0.1', '[::1]', config.listen.host, local, local && `[${local}]`]);
|
|
67
|
+
hosts.delete('0.0.0.0');
|
|
68
|
+
hosts.delete('::');
|
|
69
|
+
return hosts.has(hostname);
|
|
70
|
+
}
|
|
71
|
+
export function assertConsoleHost(request, config) {
|
|
72
|
+
const url = consoleHost(request);
|
|
73
|
+
if (!isDirectConsoleHost(request, config, url.hostname) && !config.publicOrigins?.some((origin) => originMatchesHost(origin, request))) {
|
|
74
|
+
throw new RequestError(403, 'Console Host is not allowed');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function assertTrustedOrigin(request, config) {
|
|
78
|
+
const origin = stringHeader(request.headers.origin);
|
|
79
|
+
if (!origin)
|
|
80
|
+
throw new RequestError(403, 'Same-origin request is required');
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(origin);
|
|
84
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== origin)
|
|
85
|
+
throw new Error();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new RequestError(403, 'Request origin is invalid');
|
|
89
|
+
}
|
|
90
|
+
const host = consoleHost(request);
|
|
91
|
+
const direct = parsed.protocol === 'http:' && isDirectConsoleHost(request, config, host.hostname);
|
|
92
|
+
if (!originMatchesHost(origin, request) || (!direct && !config.publicOrigins?.includes(origin))) {
|
|
93
|
+
throw new RequestError(403, 'Cross-origin admin request is not allowed');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function originMatchesHost(origin, request) {
|
|
97
|
+
const parsed = new URL(origin);
|
|
98
|
+
return new URL(`${parsed.protocol}//${stringHeader(request.headers.host)}`).origin === parsed.origin;
|
|
99
|
+
}
|
|
100
|
+
export function cookieValue(request, name) {
|
|
101
|
+
const cookie = stringHeader(request.headers.cookie);
|
|
102
|
+
for (const part of cookie.split(';')) {
|
|
103
|
+
const index = part.indexOf('=');
|
|
104
|
+
if (index < 0 || part.slice(0, index).trim() !== name)
|
|
105
|
+
continue;
|
|
106
|
+
return part.slice(index + 1).trim();
|
|
107
|
+
}
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
export function adminCookie(value, ttlMs, config) {
|
|
111
|
+
return [
|
|
112
|
+
`${ADMIN_COOKIE}=${value}`,
|
|
113
|
+
'Path=/',
|
|
114
|
+
'HttpOnly',
|
|
115
|
+
'SameSite=Strict',
|
|
116
|
+
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
|
117
|
+
...(config.secureAdminCookies ? ['Secure'] : [])
|
|
118
|
+
].join('; ');
|
|
119
|
+
}
|
|
120
|
+
export function clearAdminCookie(config) {
|
|
121
|
+
return [
|
|
122
|
+
`${ADMIN_COOKIE}=`,
|
|
123
|
+
'Path=/',
|
|
124
|
+
'HttpOnly',
|
|
125
|
+
'SameSite=Strict',
|
|
126
|
+
'Max-Age=0',
|
|
127
|
+
...(config.secureAdminCookies ? ['Secure'] : [])
|
|
128
|
+
].join('; ');
|
|
129
|
+
}
|
|
130
|
+
export function isMutating(method) {
|
|
131
|
+
return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
132
|
+
}
|
|
133
|
+
export function remoteKey(request) {
|
|
134
|
+
return request.socket.remoteAddress || 'unknown';
|
|
135
|
+
}
|
|
136
|
+
export function stringHeader(value) {
|
|
137
|
+
return Array.isArray(value) ? value[0] || '' : value || '';
|
|
138
|
+
}
|
|
139
|
+
function safeEqual(left, right) {
|
|
140
|
+
const a = Buffer.from(left);
|
|
141
|
+
const b = Buffer.from(right);
|
|
142
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
143
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { SessionManager } from '../session.js';
|
|
3
|
+
/**
|
|
4
|
+
* Own the lifecycle of one session event connection. Authentication and quota
|
|
5
|
+
* admission happen in the data route; this module is responsible only for
|
|
6
|
+
* replay, authorization revocation, heartbeats, and releasing the slot.
|
|
7
|
+
*/
|
|
8
|
+
export declare function streamSessionEvents(request: IncomingMessage, response: ServerResponse, sessions: SessionManager, sessionId: string, after: string | undefined, release: () => void, watchAuthorization: (close: () => void) => (() => void)): void;
|