@hivemagnus/ardent-studio 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +12 -0
- package/README.md +133 -2
- package/dist/agents/config-edit.d.ts +20 -0
- package/dist/agents/config-edit.js +75 -0
- package/dist/agents/directory.d.ts +27 -0
- package/dist/agents/directory.js +97 -0
- package/dist/agents/operations.d.ts +47 -0
- package/dist/agents/operations.js +93 -0
- package/dist/auth/links.d.ts +27 -0
- package/dist/auth/links.js +46 -0
- package/dist/auth/oathweave.d.ts +46 -0
- package/dist/auth/oathweave.js +123 -0
- package/dist/blobs/store.d.ts +45 -0
- package/dist/blobs/store.js +159 -0
- package/dist/clients/chattermost.d.ts +16 -0
- package/dist/clients/chattermost.js +36 -0
- package/dist/clients/hivemem.d.ts +85 -0
- package/dist/clients/hivemem.js +89 -0
- package/dist/clients/magnus.d.ts +851 -0
- package/dist/clients/magnus.js +541 -0
- package/dist/clients/service-token.d.ts +24 -0
- package/dist/clients/service-token.js +87 -0
- package/dist/clients/sse.d.ts +8 -0
- package/dist/clients/sse.js +42 -0
- package/dist/conductor/activity.d.ts +38 -0
- package/dist/conductor/activity.js +101 -0
- package/dist/conductor/addressing.d.ts +24 -0
- package/dist/conductor/addressing.js +42 -0
- package/dist/conductor/conductor.d.ts +225 -0
- package/dist/conductor/conductor.js +939 -0
- package/dist/conductor/follower.d.ts +48 -0
- package/dist/conductor/follower.js +154 -0
- package/dist/conductor/live.d.ts +23 -0
- package/dist/conductor/live.js +32 -0
- package/dist/conductor/prompting.d.ts +74 -0
- package/dist/conductor/prompting.js +177 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.js +163 -0
- package/dist/db/database.d.ts +12 -0
- package/dist/db/database.js +62 -0
- package/dist/db/json.d.ts +8 -0
- package/dist/db/json.js +13 -0
- package/dist/db/migrations.d.ts +6 -0
- package/dist/db/migrations.js +571 -0
- package/dist/db/repos/agents.d.ts +86 -0
- package/dist/db/repos/agents.js +92 -0
- package/dist/db/repos/audit.d.ts +37 -0
- package/dist/db/repos/audit.js +20 -0
- package/dist/db/repos/events.d.ts +29 -0
- package/dist/db/repos/events.js +55 -0
- package/dist/db/repos/files.d.ts +41 -0
- package/dist/db/repos/files.js +72 -0
- package/dist/db/repos/idempotency.d.ts +27 -0
- package/dist/db/repos/idempotency.js +46 -0
- package/dist/db/repos/links.d.ts +47 -0
- package/dist/db/repos/links.js +99 -0
- package/dist/db/repos/projects.d.ts +44 -0
- package/dist/db/repos/projects.js +107 -0
- package/dist/db/repos/proposals.d.ts +46 -0
- package/dist/db/repos/proposals.js +65 -0
- package/dist/db/repos/review.d.ts +191 -0
- package/dist/db/repos/review.js +195 -0
- package/dist/db/repos/room.d.ts +163 -0
- package/dist/db/repos/room.js +151 -0
- package/dist/db/repos/sessions.d.ts +36 -0
- package/dist/db/repos/sessions.js +30 -0
- package/dist/db/repos/summaries.d.ts +22 -0
- package/dist/db/repos/summaries.js +105 -0
- package/dist/db/repos/tasks.d.ts +103 -0
- package/dist/db/repos/tasks.js +112 -0
- package/dist/db/repos/team.d.ts +49 -0
- package/dist/db/repos/team.js +95 -0
- package/dist/db/repos/templates.d.ts +55 -0
- package/dist/db/repos/templates.js +120 -0
- package/dist/db/store.d.ts +57 -0
- package/dist/db/store.js +108 -0
- package/dist/events/bus.d.ts +21 -0
- package/dist/events/bus.js +31 -0
- package/dist/events/types.d.ts +3 -0
- package/dist/events/types.js +1 -0
- package/dist/ids.d.ts +28 -0
- package/dist/ids.js +81 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/knowledge/extract.d.ts +17 -0
- package/dist/knowledge/extract.js +82 -0
- package/dist/knowledge/sync.d.ts +40 -0
- package/dist/knowledge/sync.js +172 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +11 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +42 -0
- package/dist/review/anchors.d.ts +16 -0
- package/dist/review/anchors.js +67 -0
- package/dist/review/context.d.ts +4 -0
- package/dist/review/context.js +29 -0
- package/dist/review/docx.d.ts +29 -0
- package/dist/review/docx.js +21 -0
- package/dist/review/export.d.ts +16 -0
- package/dist/review/export.js +84 -0
- package/dist/review/importer.d.ts +68 -0
- package/dist/review/importer.js +215 -0
- package/dist/review/index.d.ts +13 -0
- package/dist/review/index.js +7 -0
- package/dist/server/app.d.ts +5 -0
- package/dist/server/app.js +44 -0
- package/dist/server/deps.d.ts +43 -0
- package/dist/server/deps.js +17 -0
- package/dist/server/env.d.ts +6 -0
- package/dist/server/env.js +1 -0
- package/dist/server/errors.d.ts +12 -0
- package/dist/server/errors.js +25 -0
- package/dist/server/routes/agent-studio.d.ts +5 -0
- package/dist/server/routes/agent-studio.js +441 -0
- package/dist/server/routes/agents.d.ts +3 -0
- package/dist/server/routes/agents.js +4 -0
- package/dist/server/routes/files.d.ts +3 -0
- package/dist/server/routes/files.js +88 -0
- package/dist/server/routes/health.d.ts +3 -0
- package/dist/server/routes/health.js +31 -0
- package/dist/server/routes/links.d.ts +3 -0
- package/dist/server/routes/links.js +79 -0
- package/dist/server/routes/projects.d.ts +3 -0
- package/dist/server/routes/projects.js +119 -0
- package/dist/server/routes/review.d.ts +3 -0
- package/dist/server/routes/review.js +198 -0
- package/dist/server/routes/room.d.ts +9 -0
- package/dist/server/routes/room.js +224 -0
- package/dist/server/routes/stream.d.ts +4 -0
- package/dist/server/routes/stream.js +104 -0
- package/dist/server/routes/team.d.ts +3 -0
- package/dist/server/routes/team.js +183 -0
- package/dist/server/routes/templates.d.ts +11 -0
- package/dist/server/routes/templates.js +103 -0
- package/dist/server/routes/work.d.ts +3 -0
- package/dist/server/routes/work.js +139 -0
- package/dist/server/routing.d.ts +31 -0
- package/dist/server/routing.js +132 -0
- package/dist/service.d.ts +34 -0
- package/dist/service.js +93 -0
- package/dist/shared/browser.d.ts +5824 -0
- package/dist/shared/browser.js +1318 -0
- package/dist/team/proposals.d.ts +129 -0
- package/dist/team/proposals.js +349 -0
- package/dist/team/service.d.ts +12 -0
- package/dist/team/service.js +13 -0
- package/dist/team/validation.d.ts +37 -0
- package/dist/team/validation.js +157 -0
- package/dist/templates/secrets.d.ts +2 -0
- package/dist/templates/secrets.js +23 -0
- package/dist/templates/seeds.d.ts +34 -0
- package/dist/templates/seeds.js +277 -0
- package/dist/url.d.ts +2 -0
- package/dist/url.js +7 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/docs/openapi-browser.json +12658 -0
- package/package.json +91 -4
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// Magnus is the only way ArdentStudio reaches an agent. It calls the Magnus control API
|
|
2
|
+
// with its own Oathweave service identity (audience `magnus`) and, for work a person asked
|
|
3
|
+
// for, forwards that person's Oathweave subject in x-acting-subject so Magnus can check
|
|
4
|
+
// and audit both. ArdentStudio never talks to nodes or Ardent directly and holds no Ardent token.
|
|
5
|
+
//
|
|
6
|
+
// Released Magnus 2.1.x serves the profile, installation, resource and action routes. The Studio
|
|
7
|
+
// routes (sessions, task streams, streamed artifacts, New Agent, missions, pause, previews,
|
|
8
|
+
// development) are typed here from Magnus's `packages/contracts/src/ardent-service.ts`
|
|
9
|
+
// (`ardentServiceRoutes`) and `browser.ts`, because the published @hivemagnus/magnus-contracts
|
|
10
|
+
// does not contain them yet. Switch these local schemas to that package once a release ships them.
|
|
11
|
+
// The agent changes (identity-save, skill-set, config-save and reading config) need an acting
|
|
12
|
+
// Magnus ADMIN in x-acting-subject; their shapes are typed here from the same file.
|
|
13
|
+
// A node agent reports `capabilities.studio`: 1 for sessions, streams and artifacts, 2 for missions,
|
|
14
|
+
// pause and resume, previews and development; below that Magnus answers 409 AGENT_UPGRADE_REQUIRED.
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { parseSse } from './sse.js';
|
|
17
|
+
import { trimTrailingSlashes } from '../url.js';
|
|
18
|
+
export const ACTING_SUBJECT_HEADER = 'x-acting-subject';
|
|
19
|
+
export class MagnusError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
code;
|
|
22
|
+
name = 'MagnusError';
|
|
23
|
+
constructor(message, status, code) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const REF_PART = /^[A-Za-z0-9._-]{1,128}$/;
|
|
30
|
+
export function parseAgentRef(ref) {
|
|
31
|
+
const [nodeId, profile, extra] = ref.split('~');
|
|
32
|
+
if (extra !== undefined || !nodeId || !profile || !REF_PART.test(nodeId) || !REF_PART.test(profile))
|
|
33
|
+
throw new MagnusError('Invalid agent reference', 400, 'INVALID_AGENT_REF');
|
|
34
|
+
return { nodeId, profile };
|
|
35
|
+
}
|
|
36
|
+
export function agentRef(nodeId, profile) {
|
|
37
|
+
return `${nodeId}~${profile}`;
|
|
38
|
+
}
|
|
39
|
+
// ── Shapes of the released Magnus routes (packages/contracts ardent.ts, routes/ardent.ts) ──
|
|
40
|
+
export const magnusArdentProfileSchema = z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
name: z.string(),
|
|
43
|
+
backend: z.enum(['llamacpp', 'codex']).nullable(),
|
|
44
|
+
apiPort: z.number().int().nullable(),
|
|
45
|
+
running: z.boolean(),
|
|
46
|
+
a2aEnabled: z.boolean(),
|
|
47
|
+
channels: z.record(z.string(), z.boolean()),
|
|
48
|
+
error: z.string().nullable(),
|
|
49
|
+
nodeId: z.string(),
|
|
50
|
+
nodeName: z.string(),
|
|
51
|
+
hostname: z.string(),
|
|
52
|
+
installationVersion: z.string().nullable(),
|
|
53
|
+
reachable: z.boolean(),
|
|
54
|
+
});
|
|
55
|
+
export const magnusArdentInstallationSchema = z.object({
|
|
56
|
+
installed: z.boolean(),
|
|
57
|
+
version: z.string().nullable(),
|
|
58
|
+
owner: z.string(),
|
|
59
|
+
profiles: z.array(magnusArdentProfileSchema.pick({ id: true, name: true, backend: true, apiPort: true, running: true, a2aEnabled: true, channels: true, error: true })),
|
|
60
|
+
checkedAt: z.string(),
|
|
61
|
+
nodeId: z.string(),
|
|
62
|
+
nodeName: z.string(),
|
|
63
|
+
hostname: z.string(),
|
|
64
|
+
reachable: z.boolean(),
|
|
65
|
+
error: z.string().nullable(),
|
|
66
|
+
/** The node agent's Studio level (ARDENT_STUDIO_CAPABILITY). Absent on agents that predate it. */
|
|
67
|
+
capabilities: z.object({ studio: z.number().int().min(0).optional() }).loose().optional(),
|
|
68
|
+
});
|
|
69
|
+
// Ardent management shapes, as Ardent's OpenAPI document defines them.
|
|
70
|
+
export const ardentTaskStateSchema = z.enum(['queued', 'running', 'input_required', 'completed', 'partial', 'failed', 'cancelled', 'unknown']);
|
|
71
|
+
export const ardentTaskSchema = z.object({
|
|
72
|
+
id: z.string(), caller: z.string(), context: z.string(), session_id: z.string().nullable(), prompt: z.string(), content_hash: z.string(),
|
|
73
|
+
idempotency_key: z.string().nullable(), backend: z.string(), state: ardentTaskStateSchema, result: z.string().nullable(), error: z.string().nullable(),
|
|
74
|
+
deadline_ms: z.number(), created_ms: z.number(), updated_ms: z.number(),
|
|
75
|
+
});
|
|
76
|
+
export const ardentTaskEventSchema = z.object({ sequence: z.number().int(), task_id: z.string(), kind: z.string(), detail: z.unknown().optional(), created_ms: z.number() });
|
|
77
|
+
export const ardentStatusSchema = z.object({ version: z.string(), profile: z.string(), backend: z.string(), tasks: z.number().int() });
|
|
78
|
+
export const ardentMemoryStatusSchema = z.object({
|
|
79
|
+
status: z.enum(['ready', 'degraded']), agent_id: z.string(), workspace_id: z.string(),
|
|
80
|
+
retrieval: z.string().nullable(), storage: z.string().nullable(), reason: z.string().nullable(),
|
|
81
|
+
});
|
|
82
|
+
export const ardentSkillSchema = z.object({ name: z.string(), enabled: z.boolean() }).loose();
|
|
83
|
+
/** Admission of task-submit, mission-submit and preview-submit: durable acceptance, never completion. */
|
|
84
|
+
export const ardentAcceptedTaskSchema = z.object({ task_id: z.string(), state: z.string() });
|
|
85
|
+
/** Actions released Magnus accepts on POST …/action (an ADMIN person; a service identity gets the Studio subset below). */
|
|
86
|
+
export const RELEASED_ACTIONS = ['profile-create', 'config-validate', 'config-save', 'peer-validate', 'peer-save', 'identity-save', 'identity-restore',
|
|
87
|
+
'provider-test', 'channel-test', 'peer-test', 'memory-search', 'task-submit', 'task-cancel', 'skill-set', 'service-start', 'service-stop',
|
|
88
|
+
'service-restart', 'package-install', 'context-stage', 'context-adopt', 'context-rollback'];
|
|
89
|
+
/**
|
|
90
|
+
* Actions added for Ardent Project Studio. session-open needs studio >= 1, skill-install studio >= 3 and a
|
|
91
|
+
* Magnus with a HiveSkills registry, the others studio >= 2 and Ardent 2.2.0.
|
|
92
|
+
*/
|
|
93
|
+
export const STUDIO_ACTIONS = ['session-open', 'mission-submit', 'task-pause', 'task-resume', 'preview-submit', 'development-note', 'skill-install'];
|
|
94
|
+
/**
|
|
95
|
+
* Agent changes Magnus runs for a service identity only with x-acting-subject naming a linked Magnus
|
|
96
|
+
* ADMIN (Magnus ARDENT_SERVICE_ADMIN_ACTIONS). Without one: 403 ACTING_ADMIN_REQUIRED; an unlinked
|
|
97
|
+
* subject: 403 ACTING_SUBJECT_UNLINKED. A stale revision answers 409 CONFLICT. Reading config needs
|
|
98
|
+
* the same acting ADMIN.
|
|
99
|
+
*/
|
|
100
|
+
export const ARDENT_SERVICE_ADMIN_ACTIONS = ['identity-save', 'skill-set', 'config-save', 'skill-install'];
|
|
101
|
+
/**
|
|
102
|
+
* The only configuration values a service's config-save may change (Magnus ARDENT_SERVICE_CONFIG_PATHS).
|
|
103
|
+
* A change anywhere else answers 403 CONFIG_PATH_FORBIDDEN. An array is one value.
|
|
104
|
+
*/
|
|
105
|
+
export const ARDENT_SERVICE_CONFIG_PATHS = [
|
|
106
|
+
'execution.backend',
|
|
107
|
+
'providers.llamacpp.model', 'providers.llamacpp.request_timeout_seconds', 'providers.llamacpp.context_window_tokens',
|
|
108
|
+
'providers.llamacpp.max_output_tokens', 'providers.llamacpp.reasoning_effort',
|
|
109
|
+
'providers.codex.model',
|
|
110
|
+
'skills.enabled',
|
|
111
|
+
'artifacts.max_bytes',
|
|
112
|
+
];
|
|
113
|
+
/**
|
|
114
|
+
* Whether Magnus lets the Studio service identity run service-restart. It does not: a service may run
|
|
115
|
+
* only the session, task, mission, preview, development and ARDENT_SERVICE_ADMIN_ACTIONS actions, so a
|
|
116
|
+
* profile restart stays a person's act in the Magnus console.
|
|
117
|
+
*/
|
|
118
|
+
export const ARDENT_SERVICE_CAN_RESTART = false;
|
|
119
|
+
/** Node agent Studio levels (Magnus ARDENT_STUDIO_CAPABILITY). */
|
|
120
|
+
export const ARDENT_STUDIO_CAPABILITY = { bridge: 1, missions: 2, skillInstall: 3 };
|
|
121
|
+
/** Journal event kinds Ardent 2.2.0 adds. Others appear too; readers accept unknown kinds. */
|
|
122
|
+
export const ARDENT_STUDIO_EVENT_KINDS = ['mission', 'team_handoff', 'team_ask_user', 'team_propose_decision', 'artifact_registered', 'development_note',
|
|
123
|
+
'team_result', 'team_result_rejected', 'pause_requested', 'paused', 'resumed', 'preview'];
|
|
124
|
+
const utf8Length = (value) => Buffer.byteLength(value, 'utf8');
|
|
125
|
+
const guid = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
126
|
+
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
127
|
+
const boundedId = z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/);
|
|
128
|
+
const missionText = (bytes) => z.string().trim().min(1).refine(value => utf8Length(value) <= bytes, `Exceeds ${bytes} bytes`);
|
|
129
|
+
const missionTexts = (minimum = 0) => z.array(missionText(1000)).min(minimum).max(32).refine(value => new Set(value).size === value.length, 'Duplicate entries');
|
|
130
|
+
export const ardentSessionContextSchema = z.enum(['direct', 'mission', 'preview']);
|
|
131
|
+
/** GET …/identity/soul and …/identity/identity, and the identity-save answer. */
|
|
132
|
+
export const ardentIdentityFileSchema = z.object({ kind: z.enum(['soul', 'identity']), name: z.string(), text: z.string(), revision: digest });
|
|
133
|
+
/** GET …/config: the raw config.yaml and its revision (the base for skill-set and config-save). Secrets appear only as environment variable names. */
|
|
134
|
+
export const ardentConfigFileSchema = z.object({ revision: digest, text: z.string() });
|
|
135
|
+
/** The skill-set and config-save answer: the new configuration revision. The change applies once the profile restarts. */
|
|
136
|
+
export const ardentConfigSavedSchema = z.object({ revision: digest, activation_required: z.boolean() });
|
|
137
|
+
/** action identity-save */
|
|
138
|
+
export const ardentIdentityEditSchema = z.object({ text: z.string().min(1).max(128 * 1024), revision: digest }).strict();
|
|
139
|
+
/** action skill-set: revision is the configuration revision. */
|
|
140
|
+
export const ardentSkillEditSchema = z.object({ enabled: z.boolean(), revision: digest }).strict();
|
|
141
|
+
export const ardentSkillTargetSchema = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
|
|
142
|
+
/**
|
|
143
|
+
* action skill-install (Magnus ardentSkillInstallSchema): a HiveSkills catalog release that Magnus fetches,
|
|
144
|
+
* checks against the catalog digest and installs onto the profile; with enable, the configuration revision.
|
|
145
|
+
*/
|
|
146
|
+
export const ardentSkillInstallSchema = z.object({
|
|
147
|
+
source: z.literal('hiveskills'),
|
|
148
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}$/),
|
|
149
|
+
version: z.string().max(64).regex(/^v?\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i),
|
|
150
|
+
channel: z.enum(['stable', 'canary']).optional(),
|
|
151
|
+
enable: z.boolean().optional(),
|
|
152
|
+
revision: digest.optional(),
|
|
153
|
+
}).strict().refine(value => !value.enable || value.revision !== undefined, { message: 'enable needs the configuration revision', path: ['revision'] });
|
|
154
|
+
/** The skill-install answer. A failed enable leaves the skill installed, with enable_error saying why. */
|
|
155
|
+
export const ardentSkillInstallResultSchema = z.object({
|
|
156
|
+
source: z.literal('hiveskills'), name: z.string(), version: z.string(), sha256: digest, files: z.number().int(),
|
|
157
|
+
changed: z.boolean(), enabled: z.boolean(), revision: digest.nullable(), enable_error: z.string().nullable(), activation_required: z.boolean(),
|
|
158
|
+
});
|
|
159
|
+
/** action config-save: the whole config.yaml and the configuration revision it replaces. */
|
|
160
|
+
export const ardentConfigEditSchema = z.object({ text: z.string().min(1).max(200_000), revision: digest }).strict();
|
|
161
|
+
/** action session-open */
|
|
162
|
+
export const ardentSessionOpenSchema = z.object({ key: z.string().trim().min(1).max(256), reset: z.boolean().optional(), context: ardentSessionContextSchema.optional() }).strict();
|
|
163
|
+
export const ardentSessionSchema = z.object({ id: guid, caller: z.string(), context: z.string(), key: z.string(), created_ms: z.number(), updated_ms: z.number() });
|
|
164
|
+
/** action task-submit */
|
|
165
|
+
export const ardentTaskSubmitSchema = z.object({ prompt: z.string().trim().min(1).max(16_000), idempotency_key: guid, session_id: guid.optional() }).strict();
|
|
166
|
+
/** Ardent TeamMission 1.0 (src/team-contract.ts). */
|
|
167
|
+
export const ardentTeamMissionSchema = z.object({
|
|
168
|
+
id: boundedId,
|
|
169
|
+
revision: z.number().int().min(1).max(2_147_483_647),
|
|
170
|
+
parent_task: boundedId.nullable(),
|
|
171
|
+
constraints: missionTexts(),
|
|
172
|
+
allowed_actions: z.array(z.enum(['observe', 'analyze', 'create_artifact', 'create_draft', 'reversible_change', 'deploy', 'external_publish', 'destructive_response']))
|
|
173
|
+
.min(1).max(8).refine(value => new Set(value).size === value.length, 'Duplicate action'),
|
|
174
|
+
/** Tool and memory grants, such as tool:artifact_write or project:<id>. */
|
|
175
|
+
resources: missionTexts(1),
|
|
176
|
+
/** Unix seconds. */
|
|
177
|
+
deadline: z.number().int().min(1).max(253_402_300_799),
|
|
178
|
+
acceptance_criteria: missionTexts(1),
|
|
179
|
+
expected_artifacts: missionTexts(),
|
|
180
|
+
}).strict();
|
|
181
|
+
export const ARDENT_CONTEXT_ITEMS_MAX_BYTES = 64 * 1024;
|
|
182
|
+
export const ardentContextItemSchema = z.object({
|
|
183
|
+
title: z.string().trim().min(1).max(200), mime: z.string().regex(/^[a-z]+\/[A-Za-z0-9.+-]+$/).max(100).optional(), text: z.string().min(1),
|
|
184
|
+
}).strict();
|
|
185
|
+
/** action mission-submit (the whole action body is at most 200 KB). The session must have context mission. */
|
|
186
|
+
export const ardentMissionSubmitSchema = z.object({
|
|
187
|
+
session_id: guid.optional(),
|
|
188
|
+
objective: z.string().trim().min(1).max(16_000),
|
|
189
|
+
mission: ardentTeamMissionSchema,
|
|
190
|
+
context_items: z.array(ardentContextItemSchema).max(64).optional()
|
|
191
|
+
.refine(items => utf8Length((items ?? []).map(item => item.title + item.text).join('')) <= ARDENT_CONTEXT_ITEMS_MAX_BYTES, 'Context items exceed 64 KiB'),
|
|
192
|
+
labels: z.record(z.string().regex(/^[a-z][a-z0-9._:-]{0,63}$/), z.string().max(256)).refine(value => Object.keys(value).length <= 32, 'At most 32 labels').optional(),
|
|
193
|
+
idempotency_key: z.string().min(1).max(128),
|
|
194
|
+
}).strict();
|
|
195
|
+
/** action preview-submit (at most 300 KB). Needs x-acting-subject. */
|
|
196
|
+
export const ardentPreviewOverridesSchema = z.object({
|
|
197
|
+
soul: z.string().min(1).max(131_072).optional(),
|
|
198
|
+
identity: z.string().min(1).max(131_072).optional(),
|
|
199
|
+
skills_enabled: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/)).max(100).optional(),
|
|
200
|
+
model: z.string().trim().min(1).max(200).optional(),
|
|
201
|
+
}).strict();
|
|
202
|
+
export const ardentPreviewSubmitSchema = z.object({
|
|
203
|
+
prompt: z.string().trim().min(1).max(16_000),
|
|
204
|
+
overrides: ardentPreviewOverridesSchema.optional(),
|
|
205
|
+
session_id: guid.optional(),
|
|
206
|
+
idempotency_key: z.string().min(1).max(128).optional(),
|
|
207
|
+
}).strict();
|
|
208
|
+
/** action development-note. Needs x-acting-subject. */
|
|
209
|
+
export const ardentDevelopmentNoteSchema = z.object({
|
|
210
|
+
kind: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
|
|
211
|
+
title: z.string().trim().min(1).max(200),
|
|
212
|
+
body: z.string().trim().min(1).max(8000),
|
|
213
|
+
sources: z.array(z.object({ kind: z.enum(['task', 'artifact', 'memory', 'url', 'identity', 'skill', 'note']), ref: z.string().trim().min(1).max(512) }).strict()).max(16).optional(),
|
|
214
|
+
author: z.enum(['operator', 'agent']).optional(),
|
|
215
|
+
}).strict();
|
|
216
|
+
export const ardentDevelopmentRecordSchema = z.object({
|
|
217
|
+
id: z.string(), agent_ref: z.string(), kind: z.string(), title: z.string(), body: z.string(),
|
|
218
|
+
author: z.enum(['agent', 'operator', 'runtime']), epistemic: z.enum(['agent_reported', 'operator_reported', 'observed']),
|
|
219
|
+
before_sha256: digest.nullable(), after_sha256: digest.nullable(),
|
|
220
|
+
sources: z.array(z.object({ kind: z.string(), ref: z.string() })), created_at: z.string(),
|
|
221
|
+
});
|
|
222
|
+
export const ardentDevelopmentPageSchema = z.object({ records: z.array(ardentDevelopmentRecordSchema), next: z.string().nullable() });
|
|
223
|
+
/** actions task-pause and task-resume. A finished task answers 409. */
|
|
224
|
+
export const ardentTaskPauseStateSchema = z.object({ task_id: z.string(), state: z.string(), admission: z.enum(['open', 'paused']) });
|
|
225
|
+
/** Ardent artifact manifest 1.0 (key fields; Ardent adds others). */
|
|
226
|
+
export const ardentArtifactManifestSchema = z.looseObject({
|
|
227
|
+
schema_version: z.string(), name: z.string(), mime: z.string(), sha256: digest, size: z.number().int().min(0),
|
|
228
|
+
revision: z.number().int(), supersedes_sha256: digest.nullable(),
|
|
229
|
+
acceptance_status: z.enum(['unreviewed', 'approved', 'partial', 'rejected']), created_at: z.number(), expires_at: z.number().optional(),
|
|
230
|
+
});
|
|
231
|
+
export const ardentArtifactRecordSchema = z.object({ task_id: z.string(), superseded_by_sha256: digest.nullable(), manifest: ardentArtifactManifestSchema });
|
|
232
|
+
export const ardentTaskArtifactsSchema = z.object({ artifacts: z.array(ardentArtifactRecordSchema) });
|
|
233
|
+
// Task stream events (GET …/tasks/:taskId/stream).
|
|
234
|
+
/** task.text: the current step's visible reply text (on connect and when a step starts). task.delta appends to it. Never replayed. */
|
|
235
|
+
export const ardentTaskTextEventSchema = z.object({ step: z.number().int(), text: z.string() });
|
|
236
|
+
/** task.event: one journal event; its SSE id is the sequence to resume after. */
|
|
237
|
+
export const ardentTaskJournalEventSchema = ardentTaskEventSchema;
|
|
238
|
+
/** task.done: the final task record. */
|
|
239
|
+
export const ardentTaskDoneEventSchema = z.object({ id: z.string(), state: z.string(), result: z.string().nullable().optional(), error: z.string().nullable().optional() }).loose();
|
|
240
|
+
// New Agent (POST /ardent/agents) and its operations.
|
|
241
|
+
const credentialFreeUrl = (value) => {
|
|
242
|
+
try {
|
|
243
|
+
const url = new URL(value);
|
|
244
|
+
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const identityText = z.string().max(131_072).refine(value => value.trim().length > 0 && utf8Length(value) <= 131_072, 'Identity text must be 1 to 128 KiB');
|
|
251
|
+
/** An Emberpath alias or an OpenAI-compatible endpoint and model. Never credentials. */
|
|
252
|
+
export const ardentAgentModelSchema = z.union([
|
|
253
|
+
z.object({ emberpath_alias: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/) }).strict(),
|
|
254
|
+
z.object({ base_url: z.string().max(2048).refine(credentialFreeUrl, 'Expected an HTTP(S) URL without credentials, query or fragment'), model: z.string().min(1).max(200) }).strict(),
|
|
255
|
+
]);
|
|
256
|
+
export const ardentAgentDeployRequestSchema = z.object({
|
|
257
|
+
nodeId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
|
|
258
|
+
profile: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
|
|
259
|
+
apiPort: z.number().int().min(1024).max(65535).optional(),
|
|
260
|
+
soul_md: identityText,
|
|
261
|
+
identity_md: identityText,
|
|
262
|
+
model: ardentAgentModelSchema,
|
|
263
|
+
skills: z.array(z.string().regex(/^[A-Za-z0-9_-]{1,100}$/)).max(100).default([]),
|
|
264
|
+
hivemem: z.literal(true),
|
|
265
|
+
artifacts_max_bytes: z.number().int().min(1024).max(67_108_864).optional(),
|
|
266
|
+
}).strict();
|
|
267
|
+
export const ardentAgentStepSchema = z.object({
|
|
268
|
+
name: z.string(),
|
|
269
|
+
status: z.enum(['pending', 'running', 'succeeded', 'skipped', 'failed']),
|
|
270
|
+
startedAt: z.string().nullable(),
|
|
271
|
+
finishedAt: z.string().nullable(),
|
|
272
|
+
receipt: z.record(z.string(), z.unknown()).nullable(),
|
|
273
|
+
error: z.string().nullable(),
|
|
274
|
+
});
|
|
275
|
+
export const agentOperationSchema = z.object({
|
|
276
|
+
id: z.string(),
|
|
277
|
+
kind: z.enum(['deploy', 'hivemem']),
|
|
278
|
+
nodeId: z.string(),
|
|
279
|
+
profile: z.string(),
|
|
280
|
+
status: z.enum(['running', 'succeeded', 'failed']),
|
|
281
|
+
steps: z.array(ardentAgentStepSchema),
|
|
282
|
+
error: z.string().nullable(),
|
|
283
|
+
createdBy: z.string(),
|
|
284
|
+
attempts: z.number().int(),
|
|
285
|
+
createdAt: z.string(),
|
|
286
|
+
updatedAt: z.string(),
|
|
287
|
+
completedAt: z.string().nullable(),
|
|
288
|
+
}).loose();
|
|
289
|
+
const envelope = z.object({ success: z.literal(true), data: z.unknown() });
|
|
290
|
+
const failure = z.object({ success: z.literal(false), error: z.object({ code: z.string(), message: z.string() }) });
|
|
291
|
+
export class MagnusClient {
|
|
292
|
+
options;
|
|
293
|
+
request;
|
|
294
|
+
baseUrl;
|
|
295
|
+
constructor(options) {
|
|
296
|
+
this.options = options;
|
|
297
|
+
this.request = options.fetch ?? ((input, init) => fetch(input, init));
|
|
298
|
+
this.baseUrl = trimTrailingSlashes(options.baseUrl);
|
|
299
|
+
}
|
|
300
|
+
// ── Released routes ──
|
|
301
|
+
/** Every Magnus-managed Ardent profile across the fleet. */
|
|
302
|
+
async listProfiles(context = {}) {
|
|
303
|
+
return z.array(magnusArdentProfileSchema).parse(await this.json('GET', '/api/v1/ardent/profiles', context));
|
|
304
|
+
}
|
|
305
|
+
async listInstallations(context = {}) {
|
|
306
|
+
return z.array(magnusArdentInstallationSchema).parse(await this.json('GET', '/api/v1/ardent/installations', context));
|
|
307
|
+
}
|
|
308
|
+
async getInstallation(nodeId, context = {}) {
|
|
309
|
+
if (!REF_PART.test(nodeId))
|
|
310
|
+
throw new MagnusError('Invalid node ID', 400, 'INVALID_AGENT_REF');
|
|
311
|
+
return magnusArdentInstallationSchema.parse(await this.json('GET', `/api/v1/ardent/installations/${nodeId}`, context));
|
|
312
|
+
}
|
|
313
|
+
/** Read an allow-listed Ardent management resource through Magnus. */
|
|
314
|
+
async resource(ref, resource, context = {}) {
|
|
315
|
+
const { nodeId, profile } = parseAgentRef(ref);
|
|
316
|
+
const query = new URLSearchParams();
|
|
317
|
+
for (const [key, value] of Object.entries(context.query ?? {}))
|
|
318
|
+
if (value !== undefined)
|
|
319
|
+
query.set(key, String(value));
|
|
320
|
+
const suffix = query.size ? `?${query}` : '';
|
|
321
|
+
return this.json('GET', `/api/v1/ardent/installations/${nodeId}/profiles/${profile}/${resource}${suffix}`, context);
|
|
322
|
+
}
|
|
323
|
+
async status(ref, context = {}) { return ardentStatusSchema.parse(await this.resource(ref, 'status', context)); }
|
|
324
|
+
async memoryStatus(ref, context = {}) { return ardentMemoryStatusSchema.parse(await this.resource(ref, 'memory/status', context)); }
|
|
325
|
+
async task(ref, taskId, context = {}) { return ardentTaskSchema.parse(await this.resource(ref, `tasks/${uuid(taskId)}`, context)); }
|
|
326
|
+
async skills(ref, context = {}) {
|
|
327
|
+
const data = await this.resource(ref, 'skills', context);
|
|
328
|
+
const list = z.union([z.array(ardentSkillSchema), z.object({ skills: z.array(ardentSkillSchema) }).loose()]).parse(data);
|
|
329
|
+
return Array.isArray(list) ? list : list.skills;
|
|
330
|
+
}
|
|
331
|
+
async taskEvents(ref, taskId, after = 0, context = {}) {
|
|
332
|
+
return z.object({ events: z.array(ardentTaskEventSchema) }).parse(await this.resource(ref, `tasks/${uuid(taskId)}/events`, { ...context, query: { after } })).events;
|
|
333
|
+
}
|
|
334
|
+
async identity(ref, kind, context = {}) {
|
|
335
|
+
return ardentIdentityFileSchema.parse(await this.resource(ref, `identity/${kind}`, context));
|
|
336
|
+
}
|
|
337
|
+
/** The profile's config.yaml and its revision. Needs an acting Magnus ADMIN. */
|
|
338
|
+
async config(ref, context = {}) {
|
|
339
|
+
return ardentConfigFileSchema.parse(await this.resource(ref, 'config', context));
|
|
340
|
+
}
|
|
341
|
+
/** Artifact bytes, buffered (Magnus caps them; see MAGNUS_AGENT_ARDENT_ARTIFACT_MAX_BYTES). Prefer artifactStream for large files. */
|
|
342
|
+
async artifact(ref, sha256, context = {}) {
|
|
343
|
+
const response = await this.artifactStream(ref, sha256, context);
|
|
344
|
+
return new Uint8Array(await new Response(response.body).arrayBuffer());
|
|
345
|
+
}
|
|
346
|
+
/** Artifact bytes as Magnus streams them, never buffered here. The caller verifies the digest. */
|
|
347
|
+
async artifactStream(ref, sha256, context = {}) {
|
|
348
|
+
const { nodeId, profile } = parseAgentRef(ref);
|
|
349
|
+
if (!/^[a-f0-9]{64}$/.test(sha256))
|
|
350
|
+
throw new MagnusError('Invalid artifact ID', 400, 'VALIDATION_ERROR');
|
|
351
|
+
const response = await this.send('GET', `/api/v1/ardent/installations/${nodeId}/profiles/${profile}/artifacts/${sha256}`, context, undefined, { accept: 'application/octet-stream' }, 0);
|
|
352
|
+
if (!response.ok || !response.body)
|
|
353
|
+
await this.fail(response);
|
|
354
|
+
const length = Number(response.headers.get('content-length') ?? '');
|
|
355
|
+
return { body: response.body, size: Number.isSafeInteger(length) && length >= 0 && response.headers.has('content-length') ? length : undefined };
|
|
356
|
+
}
|
|
357
|
+
/** Run a management action through Magnus's audited action route. */
|
|
358
|
+
async action(ref, action, input = {}, context = {}) {
|
|
359
|
+
const { nodeId, profile } = parseAgentRef(ref);
|
|
360
|
+
const payload = { action };
|
|
361
|
+
if (input.target !== undefined)
|
|
362
|
+
payload.target = input.target;
|
|
363
|
+
if (input.body !== undefined)
|
|
364
|
+
payload.body = input.body;
|
|
365
|
+
return this.json('POST', `/api/v1/ardent/installations/${nodeId}/profiles/${profile}/action`, context, payload);
|
|
366
|
+
}
|
|
367
|
+
/** A direct task. The prompt is at most 16000 characters; the idempotency key is a UUID; a session (studio >= 1) carries the conversation. */
|
|
368
|
+
async submitTask(ref, body, context = {}) {
|
|
369
|
+
const parsed = ardentTaskSubmitSchema.safeParse(body);
|
|
370
|
+
if (!parsed.success)
|
|
371
|
+
throw new MagnusError(`Task submission is invalid: ${parsed.error.issues[0]?.message ?? 'invalid'}`, 400, 'VALIDATION_ERROR');
|
|
372
|
+
return ardentAcceptedTaskSchema.parse(await this.action(ref, 'task-submit', { body: parsed.data }, context));
|
|
373
|
+
}
|
|
374
|
+
async cancelTask(ref, taskId, context = {}) {
|
|
375
|
+
return this.action(ref, 'task-cancel', { target: uuid(taskId) }, context);
|
|
376
|
+
}
|
|
377
|
+
async memorySearch(ref, query, context = {}) {
|
|
378
|
+
return this.action(ref, 'memory-search', { body: { query } }, context);
|
|
379
|
+
}
|
|
380
|
+
/** Ardent rejects a stale revision with 409, so concurrent edits never overwrite each other. */
|
|
381
|
+
async saveIdentity(ref, kind, body, context = {}) {
|
|
382
|
+
return ardentIdentityFileSchema.parse(await this.action(ref, 'identity-save', { target: kind, body: validated(ardentIdentityEditSchema, body, 'identity-save') }, context));
|
|
383
|
+
}
|
|
384
|
+
/** Each change answers the next configuration revision: chain it into the next skill-set or config-save. */
|
|
385
|
+
async setSkill(ref, name, body, context = {}) {
|
|
386
|
+
const target = validated(ardentSkillTargetSchema, name, 'skill-set');
|
|
387
|
+
return ardentConfigSavedSchema.parse(await this.action(ref, 'skill-set', { target, body: validated(ardentSkillEditSchema, body, 'skill-set') }, context));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Install a HiveSkills catalog release (studio >= 3). Magnus answers 503 HIVESKILLS_UNCONFIGURED without a registry,
|
|
391
|
+
* 409 SKILL_VERSION_UNAVAILABLE or SKILL_CONFLICT, and 422 SKILL_ARCHIVE_INVALID; a Magnus that predates the action
|
|
392
|
+
* answers 400 "Invalid Ardent action".
|
|
393
|
+
*/
|
|
394
|
+
async installSkill(ref, body, context = {}) {
|
|
395
|
+
return ardentSkillInstallResultSchema.parse(await this.action(ref, 'skill-install', { body: validated(ardentSkillInstallSchema, body, 'skill-install') }, context));
|
|
396
|
+
}
|
|
397
|
+
/** Magnus refuses (403 CONFIG_PATH_FORBIDDEN) a text that changes anything outside ARDENT_SERVICE_CONFIG_PATHS. */
|
|
398
|
+
async saveConfig(ref, body, context = {}) {
|
|
399
|
+
return ardentConfigSavedSchema.parse(await this.action(ref, 'config-save', { body: validated(ardentConfigEditSchema, body, 'config-save') }, context));
|
|
400
|
+
}
|
|
401
|
+
// ── Studio routes (typed locally; see the header) ──
|
|
402
|
+
/** The node agent's Studio level for a node: 0 when it reports none. */
|
|
403
|
+
async studioLevel(nodeId, context = {}) {
|
|
404
|
+
return (await this.getInstallation(nodeId, context)).capabilities?.studio ?? 0;
|
|
405
|
+
}
|
|
406
|
+
/** Open (or reopen: the same key answers the same session) an Ardent session. studio >= 1; a context needs studio >= 2. */
|
|
407
|
+
async openSession(ref, body, context = {}) {
|
|
408
|
+
return ardentSessionSchema.parse(await this.action(ref, 'session-open', { body: ardentSessionOpenSchema.parse(body) }, context));
|
|
409
|
+
}
|
|
410
|
+
/** Submit a team mission in a session opened with context mission. studio >= 2. */
|
|
411
|
+
async submitMission(ref, body, context = {}) {
|
|
412
|
+
const parsed = ardentMissionSubmitSchema.safeParse(body);
|
|
413
|
+
if (!parsed.success)
|
|
414
|
+
throw new MagnusError(`Mission is invalid: ${parsed.error.issues[0]?.message ?? 'invalid'}`, 400, 'VALIDATION_ERROR');
|
|
415
|
+
return ardentAcceptedTaskSchema.parse(await this.action(ref, 'mission-submit', { body: parsed.data }, context));
|
|
416
|
+
}
|
|
417
|
+
/** Pause at the next step boundary. studio >= 2; a finished task answers 409. */
|
|
418
|
+
async pauseTask(ref, taskId, context = {}) {
|
|
419
|
+
return ardentTaskPauseStateSchema.parse(await this.action(ref, 'task-pause', { target: uuid(taskId) }, context));
|
|
420
|
+
}
|
|
421
|
+
async resumeTask(ref, taskId, context = {}) {
|
|
422
|
+
return ardentTaskPauseStateSchema.parse(await this.action(ref, 'task-resume', { target: uuid(taskId) }, context));
|
|
423
|
+
}
|
|
424
|
+
/** A side-effect-free preview task with draft overrides. studio >= 2; needs the acting person. */
|
|
425
|
+
async submitPreview(ref, body, context) {
|
|
426
|
+
if (!context.actingSubject)
|
|
427
|
+
throw new MagnusError('A preview needs the acting person', 400, 'ACTING_SUBJECT_REQUIRED');
|
|
428
|
+
return ardentAcceptedTaskSchema.parse(await this.action(ref, 'preview-submit', { body: ardentPreviewSubmitSchema.parse(body) }, context));
|
|
429
|
+
}
|
|
430
|
+
/** Record a development note. studio >= 2; needs the acting person. */
|
|
431
|
+
async addDevelopmentNote(ref, body, context) {
|
|
432
|
+
if (!context.actingSubject)
|
|
433
|
+
throw new MagnusError('A development note needs the acting person', 400, 'ACTING_SUBJECT_REQUIRED');
|
|
434
|
+
return ardentDevelopmentRecordSchema.parse(await this.action(ref, 'development-note', { body: ardentDevelopmentNoteSchema.parse(body) }, context));
|
|
435
|
+
}
|
|
436
|
+
/** Artifact manifests a task registered, oldest first. studio >= 2. */
|
|
437
|
+
async taskArtifacts(ref, taskId, context = {}) {
|
|
438
|
+
return ardentTaskArtifactsSchema.parse(await this.resource(ref, `tasks/${uuid(taskId)}/artifacts`, context)).artifacts;
|
|
439
|
+
}
|
|
440
|
+
/** One artifact manifest. studio >= 2. */
|
|
441
|
+
async artifactManifest(ref, sha256, context = {}) {
|
|
442
|
+
if (!/^[a-f0-9]{64}$/.test(sha256))
|
|
443
|
+
throw new MagnusError('Invalid artifact ID', 400, 'VALIDATION_ERROR');
|
|
444
|
+
return ardentArtifactRecordSchema.parse(await this.resource(ref, `artifacts/${sha256}/manifest`, context));
|
|
445
|
+
}
|
|
446
|
+
/** Portable development records, cursor-paginated. studio >= 2. */
|
|
447
|
+
async development(ref, query = {}, context = {}) {
|
|
448
|
+
if (query.after !== undefined && !/^[A-Za-z0-9_-]{1,400}$/.test(query.after))
|
|
449
|
+
throw new MagnusError('Invalid development cursor', 400, 'VALIDATION_ERROR');
|
|
450
|
+
return ardentDevelopmentPageSchema.parse(await this.resource(ref, 'development', { ...context, query }));
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Follow a task's journal and live reply text (GET …/tasks/:id/stream, piped unbuffered by
|
|
454
|
+
* Magnus). Yields task.event (resumable, id = journal sequence), task.text, task.delta and
|
|
455
|
+
* task.done. Ardent ends a stream after 60 seconds; reconnect with `after`.
|
|
456
|
+
*/
|
|
457
|
+
async *streamTask(ref, taskId, options = {}) {
|
|
458
|
+
const { nodeId, profile } = parseAgentRef(ref);
|
|
459
|
+
const query = options.after !== undefined ? `?after=${options.after}` : '';
|
|
460
|
+
const response = await this.send('GET', `/api/v1/ardent/installations/${nodeId}/profiles/${profile}/tasks/${uuid(taskId)}/stream${query}`, options, undefined, { accept: 'text/event-stream' }, 0);
|
|
461
|
+
if (!response.ok || !response.body)
|
|
462
|
+
await this.fail(response);
|
|
463
|
+
yield* parseSse(response.body);
|
|
464
|
+
}
|
|
465
|
+
/** Magnus New Agent deploy operation. Magnus runs it only when the acting person is a Magnus ADMIN. */
|
|
466
|
+
async createAgent(body, context) {
|
|
467
|
+
if (!context.actingSubject)
|
|
468
|
+
throw new MagnusError('New Agent needs the acting person', 400, 'ACTING_SUBJECT_REQUIRED');
|
|
469
|
+
return z.object({ operationId: z.string() }).parse(await this.json('POST', '/api/v1/ardent/agents', context, ardentAgentDeployRequestSchema.parse(body)));
|
|
470
|
+
}
|
|
471
|
+
/** Step receipts of a New Agent or Enable HiveMem operation. */
|
|
472
|
+
async agentOperation(operationId, context = {}) {
|
|
473
|
+
return agentOperationSchema.parse(await this.json('GET', `/api/v1/ardent/agents/operations/${operationIdOf(operationId)}`, context));
|
|
474
|
+
}
|
|
475
|
+
/** Live operation updates: `operation` events, then one `done`. */
|
|
476
|
+
async *streamAgentOperation(operationId, options = {}) {
|
|
477
|
+
const response = await this.send('GET', `/api/v1/ardent/agents/operations/${operationIdOf(operationId)}/stream`, options, undefined, { accept: 'text/event-stream' }, 0);
|
|
478
|
+
if (!response.ok || !response.body)
|
|
479
|
+
await this.fail(response);
|
|
480
|
+
yield* parseSse(response.body);
|
|
481
|
+
}
|
|
482
|
+
/** Resume a failed operation from its first unfinished step. Needs an acting ADMIN. */
|
|
483
|
+
async resumeAgentOperation(operationId, context) {
|
|
484
|
+
if (!context.actingSubject)
|
|
485
|
+
throw new MagnusError('Resuming an operation needs the acting person', 400, 'ACTING_SUBJECT_REQUIRED');
|
|
486
|
+
return z.object({ operationId: z.string() }).parse(await this.json('POST', `/api/v1/ardent/agents/operations/${operationIdOf(operationId)}/resume`, context));
|
|
487
|
+
}
|
|
488
|
+
// ── Transport ──
|
|
489
|
+
async json(method, path, context, body) {
|
|
490
|
+
const response = await this.send(method, path, context, body);
|
|
491
|
+
if (!response.ok)
|
|
492
|
+
await this.fail(response);
|
|
493
|
+
const parsed = envelope.safeParse(await response.json().catch(() => null));
|
|
494
|
+
if (!parsed.success)
|
|
495
|
+
throw new MagnusError('Magnus answered with an unexpected body', 502, 'BAD_GATEWAY');
|
|
496
|
+
return parsed.data.data;
|
|
497
|
+
}
|
|
498
|
+
async send(method, path, context, body, extraHeaders = {}, timeoutMs = this.options.timeoutMs ?? 20_000) {
|
|
499
|
+
const attempt = async () => {
|
|
500
|
+
const headers = { authorization: `Bearer ${await this.options.tokens.token()}`, accept: 'application/json', ...extraHeaders };
|
|
501
|
+
if (context.actingSubject)
|
|
502
|
+
headers[ACTING_SUBJECT_HEADER] = context.actingSubject;
|
|
503
|
+
if (body !== undefined)
|
|
504
|
+
headers['content-type'] = 'application/json';
|
|
505
|
+
const signals = [context.signal, timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined].filter((signal) => Boolean(signal));
|
|
506
|
+
return this.request(`${this.baseUrl}${path}`, {
|
|
507
|
+
method, headers, redirect: 'error', ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
508
|
+
...(signals.length ? { signal: AbortSignal.any(signals) } : {}),
|
|
509
|
+
});
|
|
510
|
+
};
|
|
511
|
+
let response = await attempt();
|
|
512
|
+
if (response.status === 401) {
|
|
513
|
+
// A revoked or rotated service token: fetch a new one once.
|
|
514
|
+
await response.body?.cancel();
|
|
515
|
+
this.options.tokens.invalidate();
|
|
516
|
+
response = await attempt();
|
|
517
|
+
}
|
|
518
|
+
return response;
|
|
519
|
+
}
|
|
520
|
+
async fail(response) {
|
|
521
|
+
const parsed = failure.safeParse(await response.json().catch(() => null));
|
|
522
|
+
const message = parsed.success ? parsed.data.error.message.slice(0, 300) : `Magnus answered HTTP ${response.status}`;
|
|
523
|
+
throw new MagnusError(message, response.status, parsed.success ? parsed.data.error.code : `HTTP_${response.status}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function validated(schema, value, action) {
|
|
527
|
+
const parsed = schema.safeParse(value);
|
|
528
|
+
if (!parsed.success)
|
|
529
|
+
throw new MagnusError(`The ${action} request is invalid: ${parsed.error.issues[0]?.message ?? 'invalid'}`, 400, 'VALIDATION_ERROR');
|
|
530
|
+
return parsed.data;
|
|
531
|
+
}
|
|
532
|
+
function operationIdOf(value) {
|
|
533
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value))
|
|
534
|
+
throw new MagnusError('Invalid operation ID', 400, 'VALIDATION_ERROR');
|
|
535
|
+
return value;
|
|
536
|
+
}
|
|
537
|
+
function uuid(value) {
|
|
538
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value))
|
|
539
|
+
throw new MagnusError('Invalid Ardent task ID', 400, 'VALIDATION_ERROR');
|
|
540
|
+
return value;
|
|
541
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ServiceTokenOptions {
|
|
2
|
+
tokenUrl: string;
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
scope?: string | undefined;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
}
|
|
9
|
+
export declare class ServiceTokenError extends Error {
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class ServiceTokenProvider {
|
|
13
|
+
private readonly options;
|
|
14
|
+
private cached;
|
|
15
|
+
private inFlight;
|
|
16
|
+
private retryAfter;
|
|
17
|
+
private readonly request;
|
|
18
|
+
private readonly now;
|
|
19
|
+
constructor(options: ServiceTokenOptions);
|
|
20
|
+
token(): Promise<string>;
|
|
21
|
+
/** Forget the cached token, for example after the resource answered 401. */
|
|
22
|
+
invalidate(): void;
|
|
23
|
+
private fetchToken;
|
|
24
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ArdentStudio's own service identity: short-lived Oathweave client_credentials tokens,
|
|
2
|
+
// one machine client per audience (Oathweave binds a client to one audience). A token is
|
|
3
|
+
// fetched on first use and replaced before it expires; nothing has to renew it by hand.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
export class ServiceTokenError extends Error {
|
|
6
|
+
name = 'ServiceTokenError';
|
|
7
|
+
}
|
|
8
|
+
const tokenResponse = z.object({ access_token: z.string().min(1), expires_in: z.number().positive().optional(), token_type: z.string().optional() });
|
|
9
|
+
const MIN_LIFETIME_MS = 10_000;
|
|
10
|
+
const MAX_LIFETIME_MS = 3600_000;
|
|
11
|
+
const FAILURE_BACKOFF_MS = 5_000;
|
|
12
|
+
function unverifiedExpiry(token) {
|
|
13
|
+
try {
|
|
14
|
+
const claims = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8'));
|
|
15
|
+
return claims && typeof claims === 'object' && 'exp' in claims && typeof claims.exp === 'number' ? claims.exp * 1000 : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class ServiceTokenProvider {
|
|
22
|
+
options;
|
|
23
|
+
cached;
|
|
24
|
+
inFlight;
|
|
25
|
+
retryAfter = 0;
|
|
26
|
+
request;
|
|
27
|
+
now;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.request = options.fetch ?? ((input, init) => fetch(input, init));
|
|
31
|
+
this.now = options.now ?? Date.now;
|
|
32
|
+
}
|
|
33
|
+
async token() {
|
|
34
|
+
const at = this.now();
|
|
35
|
+
const usable = this.cached && this.cached.expiresAt - 5_000 > at ? this.cached : undefined;
|
|
36
|
+
if (usable && usable.refreshAt > at)
|
|
37
|
+
return usable.token;
|
|
38
|
+
if (this.inFlight)
|
|
39
|
+
return usable ? usable.token : this.inFlight;
|
|
40
|
+
if (at < this.retryAfter) {
|
|
41
|
+
if (usable)
|
|
42
|
+
return usable.token;
|
|
43
|
+
throw new ServiceTokenError('Oathweave token request is backing off after a failure');
|
|
44
|
+
}
|
|
45
|
+
const refresh = this.fetchToken().finally(() => { this.inFlight = undefined; });
|
|
46
|
+
this.inFlight = refresh;
|
|
47
|
+
if (usable) {
|
|
48
|
+
refresh.catch(() => undefined);
|
|
49
|
+
return usable.token;
|
|
50
|
+
}
|
|
51
|
+
return refresh;
|
|
52
|
+
}
|
|
53
|
+
/** Forget the cached token, for example after the resource answered 401. */
|
|
54
|
+
invalidate() {
|
|
55
|
+
this.cached = undefined;
|
|
56
|
+
}
|
|
57
|
+
async fetchToken() {
|
|
58
|
+
const startedAt = this.now();
|
|
59
|
+
const form = new URLSearchParams({ grant_type: 'client_credentials', client_id: this.options.clientId, client_secret: this.options.clientSecret });
|
|
60
|
+
if (this.options.scope)
|
|
61
|
+
form.set('scope', this.options.scope);
|
|
62
|
+
try {
|
|
63
|
+
const response = await this.request(this.options.tokenUrl, {
|
|
64
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
65
|
+
body: form.toString(), redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
66
|
+
});
|
|
67
|
+
// Never include the response body: it may echo the request.
|
|
68
|
+
if (!response.ok)
|
|
69
|
+
throw new ServiceTokenError(`Oathweave token request failed (HTTP ${response.status})`);
|
|
70
|
+
const parsed = tokenResponse.safeParse(await response.json().catch(() => null));
|
|
71
|
+
if (!parsed.success)
|
|
72
|
+
throw new ServiceTokenError('Oathweave token response is malformed');
|
|
73
|
+
const lifetime = Math.min(MAX_LIFETIME_MS, Math.max(MIN_LIFETIME_MS, (parsed.data.expires_in ?? 300) * 1000));
|
|
74
|
+
const expiresAt = Math.min(startedAt + lifetime, unverifiedExpiry(parsed.data.access_token) ?? Number.POSITIVE_INFINITY);
|
|
75
|
+
if (expiresAt - 5_000 <= this.now())
|
|
76
|
+
throw new ServiceTokenError('Oathweave issued an expired token');
|
|
77
|
+
const span = expiresAt - startedAt;
|
|
78
|
+
this.cached = { token: parsed.data.access_token, expiresAt, refreshAt: startedAt + Math.max(0, Math.min(span * 0.8, span - 30_000)) };
|
|
79
|
+
this.retryAfter = 0;
|
|
80
|
+
return parsed.data.access_token;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.retryAfter = this.now() + FAILURE_BACKOFF_MS;
|
|
84
|
+
throw error instanceof ServiceTokenError ? error : new ServiceTokenError(`Oathweave token request failed (${error instanceof Error ? error.name : 'error'})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|