@ours.network/fleet 2.2.6 → 2.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/dist/application/task-room-service.d.ts +11 -0
- package/dist/application/task-room-service.js +37 -14
- package/dist/atomic-file.d.ts +2 -0
- package/dist/atomic-file.js +2 -1
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +6 -0
- package/dist/config.js +6 -3
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +37 -0
- package/dist/owner-channel/commands.js +25 -12
- package/dist/reports/tasks.js +4 -3
- package/dist/rooms-tasks/add-member.js +17 -6
- package/dist/rooms-tasks/cli.js +79 -5
- package/dist/rooms-tasks/config.js +11 -0
- package/dist/rooms-tasks/layout-member-retirement.d.ts +15 -0
- package/dist/rooms-tasks/layout-member-retirement.js +26 -1
- package/dist/rooms-tasks/layout-service.d.ts +2 -1
- package/dist/rooms-tasks/layout-service.js +10 -4
- package/dist/rooms-tasks/layout.d.ts +4 -2
- package/dist/rooms-tasks/layout.js +21 -4
- package/dist/rooms-tasks/member-naming.d.ts +43 -0
- package/dist/rooms-tasks/member-naming.js +186 -0
- package/dist/rooms-tasks/member-overrides.js +1 -0
- package/dist/rooms-tasks/mission-keys.d.ts +83 -0
- package/dist/rooms-tasks/mission-keys.js +250 -0
- package/dist/rooms-tasks/provision.d.ts +4 -0
- package/dist/rooms-tasks/provision.js +37 -3
- package/dist/rooms-tasks/task-lists.d.ts +18 -2
- package/dist/rooms-tasks/task-lists.js +60 -5
- package/dist/rooms-tasks/task-room-name.d.ts +7 -2
- package/dist/rooms-tasks/task-room-name.js +13 -4
- package/dist/rooms-tasks/task-state.d.ts +40 -0
- package/dist/rooms-tasks/task-state.js +288 -45
- package/dist/rooms-tasks/types.d.ts +35 -1
- package/dist/rooms-tasks/types.js +1 -1
- package/dist/web/runtime.js +4 -1
- package/dist/web/server.js +62 -27
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-scoped agent names for task members.
|
|
3
|
+
*
|
|
4
|
+
* <KEY>-<Stem> first member of a stem (no `-1`)
|
|
5
|
+
* <KEY>-<Stem>-<n> later members, n >= 2
|
|
6
|
+
*
|
|
7
|
+
* The stem comes from the explicitly selected named Role when one exists,
|
|
8
|
+
* otherwise from a stable room label, participant key or slot. It is
|
|
9
|
+
* PascalCase Latin alphanumerics, truncated with ordinal headroom so the full
|
|
10
|
+
* name fits the 64-character Fleet role-name budget. The counter bucket is the
|
|
11
|
+
* case-folded truncated stem, chosen before numbering, so labels that collapse
|
|
12
|
+
* to the same stem share one counter and can never produce the same name.
|
|
13
|
+
*
|
|
14
|
+
* Allocations are durable on the task record and keyed by a stable allocation
|
|
15
|
+
* key: provisioning, recovery and restart reuse them; additions and
|
|
16
|
+
* replacements use new keys and consume the next ordinal. Names that existed
|
|
17
|
+
* before mission keys are frozen exactly as launched.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { stateRoot } from '../paths.js';
|
|
22
|
+
export const AGENT_NAME_MAX = 64;
|
|
23
|
+
/** Ordinal suffix headroom: `-999`. */
|
|
24
|
+
export const MAX_ROLE_ORDINAL = 999;
|
|
25
|
+
const SUFFIX_HEADROOM = 1 + String(MAX_ROLE_ORDINAL).length;
|
|
26
|
+
export class MemberNameError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = 'MemberNameError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** PascalCase join of Latin alphanumeric words; diacritics folded, other scripts dropped. */
|
|
35
|
+
export function labelStem(label) {
|
|
36
|
+
const words = label.normalize('NFKD').replace(/\p{M}/gu, '').split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
37
|
+
const joined = words.map(word => word[0].toUpperCase() + word.slice(1)).join('');
|
|
38
|
+
// Fleet role names must start with an alphanumeric; a leading digit is allowed.
|
|
39
|
+
return joined;
|
|
40
|
+
}
|
|
41
|
+
/** First candidate that survives normalization, else `Agent`. */
|
|
42
|
+
export function chooseStem(key, candidates) {
|
|
43
|
+
const budget = AGENT_NAME_MAX - key.length - 1 - SUFFIX_HEADROOM;
|
|
44
|
+
if (budget < 1)
|
|
45
|
+
throw new MemberNameError('invalid_label', `mission key ${key} leaves no room for a role name`);
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
if (!candidate.label)
|
|
48
|
+
continue;
|
|
49
|
+
const stem = labelStem(candidate.label).slice(0, budget);
|
|
50
|
+
if (stem)
|
|
51
|
+
return { stem, source: candidate.source };
|
|
52
|
+
}
|
|
53
|
+
return { stem: 'Agent'.slice(0, budget), source: 'fallback' };
|
|
54
|
+
}
|
|
55
|
+
export const bucketOf = (stem) => stem.toLowerCase();
|
|
56
|
+
/** Own-property read: buckets such as `constructor` must never hit Object.prototype. */
|
|
57
|
+
export const ownEntry = (record, key) => record && Object.hasOwn(record, key) ? record[key] : undefined;
|
|
58
|
+
export function formatMemberName(key, stem, ordinal) {
|
|
59
|
+
return ordinal === 1 ? `${key}-${stem}` : `${key}-${stem}-${ordinal}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pure allocation step: returns the existing allocation for `allocationKey`,
|
|
63
|
+
* or a new one plus the updated naming. The caller persists under the task
|
|
64
|
+
* lock and checks external name conflicts before launch.
|
|
65
|
+
*/
|
|
66
|
+
export function allocateName(naming, key, allocationKey, candidates) {
|
|
67
|
+
const current = naming
|
|
68
|
+
? { version: 2, counters: { ...naming.counters }, allocations: { ...naming.allocations } }
|
|
69
|
+
: { version: 2, counters: {}, allocations: {} };
|
|
70
|
+
const existing = ownEntry(current.allocations, allocationKey);
|
|
71
|
+
if (existing)
|
|
72
|
+
return { naming: naming ?? current, allocation: existing, created: false };
|
|
73
|
+
const { stem, source } = chooseStem(key, candidates);
|
|
74
|
+
const bucket = bucketOf(stem);
|
|
75
|
+
const ordinal = (ownEntry(current.counters, bucket) ?? 0) + 1;
|
|
76
|
+
if (ordinal > MAX_ROLE_ORDINAL)
|
|
77
|
+
throw new MemberNameError('role_ordinal_exhausted', `mission ${key} has used every ${stem} ordinal`);
|
|
78
|
+
const allocation = {
|
|
79
|
+
name: formatMemberName(key, stem, ordinal), bucket, ordinal, label_source: source,
|
|
80
|
+
};
|
|
81
|
+
current.counters[bucket] = ordinal;
|
|
82
|
+
current.allocations[allocationKey] = allocation;
|
|
83
|
+
return { naming: current, allocation, created: true };
|
|
84
|
+
}
|
|
85
|
+
// ── Frozen historical names (one-time migration) ─────────────────────────
|
|
86
|
+
const LEGACY_TEMPLATE_NAME = /^[0-9a-z]{8}-([A-Za-z][A-Za-z0-9_-]*)-([1-9][0-9]*)$/;
|
|
87
|
+
function readJson(path) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code === 'ENOENT')
|
|
93
|
+
return undefined;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Every physical agent a historical task already launched or planned, from all durable sources. */
|
|
98
|
+
function legacyMembers(task) {
|
|
99
|
+
const id8 = task.task_id.slice(0, 8);
|
|
100
|
+
const members = [];
|
|
101
|
+
const snapshot = (task.execution_plan?.snapshot.members ?? []);
|
|
102
|
+
const bySlot = new Map(snapshot.map(member => [member.slot, member]));
|
|
103
|
+
const candidatesFor = (slot, role) => {
|
|
104
|
+
const sealed = bySlot.get(slot);
|
|
105
|
+
return [
|
|
106
|
+
{ label: sealed?.role_preset?.id, source: 'role_override' },
|
|
107
|
+
{ label: sealed?.role_label, source: 'template_ref' },
|
|
108
|
+
{ label: role ?? sealed?.role, source: 'room_role' },
|
|
109
|
+
{ label: slot, source: 'slot' },
|
|
110
|
+
];
|
|
111
|
+
};
|
|
112
|
+
for (const member of snapshot)
|
|
113
|
+
for (let i = 1; i <= member.count; i++)
|
|
114
|
+
members.push({ allocationKey: `initial:${member.slot}:${i}`, name: `${id8}-${member.slot}-${i}`,
|
|
115
|
+
candidates: candidatesFor(member.slot, member.role) });
|
|
116
|
+
const additions = join(stateRoot(), 'member-additions', task.task_id);
|
|
117
|
+
if (existsSync(additions))
|
|
118
|
+
for (const file of readdirSync(additions).filter(name => name.endsWith('.json')).sort()) {
|
|
119
|
+
const receipt = readJson(join(additions, file));
|
|
120
|
+
if (!receipt?.roleId || !receipt.requestId)
|
|
121
|
+
continue;
|
|
122
|
+
const slot = LEGACY_TEMPLATE_NAME.exec(receipt.roleId)?.[1] ?? receipt.roleId;
|
|
123
|
+
members.push({ allocationKey: `add:${receipt.requestId}`, name: receipt.roleId, candidates: candidatesFor(slot) });
|
|
124
|
+
}
|
|
125
|
+
if (task.room_id) {
|
|
126
|
+
const room = readJson(join(stateRoot(), 'rooms', `${task.room_id}.json`));
|
|
127
|
+
for (const seat of room?.member_seats ?? []) {
|
|
128
|
+
const match = LEGACY_TEMPLATE_NAME.exec(seat.role_name);
|
|
129
|
+
const slot = seat.slot ?? match?.[1] ?? seat.role_name;
|
|
130
|
+
members.push({ allocationKey: match ? `initial:${match[1]}:${match[2]}` : `seat:${seat.role_name}`,
|
|
131
|
+
name: seat.role_name, candidates: candidatesFor(slot, seat.cowork_role) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (task.layout) {
|
|
135
|
+
const run = readJson(join(stateRoot(), 'layouts', `${task.layout.run_id}.json`));
|
|
136
|
+
for (const [instanceKey, participant] of Object.entries(run?.participants ?? {})) {
|
|
137
|
+
const agent = participant.instance?.agent;
|
|
138
|
+
if (!agent || participant.owned === false)
|
|
139
|
+
continue;
|
|
140
|
+
const key = participant.participant ?? instanceKey;
|
|
141
|
+
const roles = Object.entries(run?.definition?.rooms ?? {})
|
|
142
|
+
.filter(([room, spec]) => spec.members.includes(key) && (!participant.room || participant.room === room))
|
|
143
|
+
.map(([, spec]) => spec.roles?.[key] ?? key);
|
|
144
|
+
const added = run?.additional_members?.[key]?.role;
|
|
145
|
+
const agreed = added ?? (new Set(roles).size === 1 ? roles[0] : undefined);
|
|
146
|
+
members.push({ allocationKey: `layout:${task.layout.run_id}:${instanceKey}`, name: agent, candidates: [
|
|
147
|
+
{ label: agreed, source: 'room_role' }, { label: key, source: 'participant_key' }
|
|
148
|
+
] });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return members;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Freeze every historical agent name and seed counters by the same label
|
|
155
|
+
* precedence as new names. Deduplicated by physical agent name; counters merge
|
|
156
|
+
* monotonically and existing allocations are never rewritten. Returns the
|
|
157
|
+
* input object unchanged when there is nothing to add.
|
|
158
|
+
*/
|
|
159
|
+
export function freezeLegacyMemberNaming(task) {
|
|
160
|
+
const legacy = legacyMembers(task);
|
|
161
|
+
if (!legacy.length)
|
|
162
|
+
return task.member_naming;
|
|
163
|
+
const naming = task.member_naming
|
|
164
|
+
? { version: 2, counters: { ...task.member_naming.counters }, allocations: { ...task.member_naming.allocations } }
|
|
165
|
+
: { version: 2, counters: {}, allocations: {} };
|
|
166
|
+
const known = new Set(Object.values(naming.allocations).map(allocation => allocation.name));
|
|
167
|
+
const seeded = {};
|
|
168
|
+
let changed = !task.member_naming;
|
|
169
|
+
for (const member of legacy) {
|
|
170
|
+
if (known.has(member.name))
|
|
171
|
+
continue;
|
|
172
|
+
if (ownEntry(naming.allocations, member.allocationKey))
|
|
173
|
+
continue;
|
|
174
|
+
known.add(member.name);
|
|
175
|
+
const { stem } = chooseStem(task.key, member.candidates);
|
|
176
|
+
const bucket = bucketOf(stem);
|
|
177
|
+
seeded[bucket] = (ownEntry(seeded, bucket) ?? 0) + 1;
|
|
178
|
+
naming.allocations[member.allocationKey] = { name: member.name, bucket, frozen: true };
|
|
179
|
+
changed = true;
|
|
180
|
+
}
|
|
181
|
+
for (const [bucket, count] of Object.entries(seeded)) {
|
|
182
|
+
const frozenInBucket = Object.values(naming.allocations).filter(a => a.frozen && a.bucket === bucket).length;
|
|
183
|
+
naming.counters[bucket] = Math.max(ownEntry(naming.counters, bucket) ?? 0, frozenInBucket, count);
|
|
184
|
+
}
|
|
185
|
+
return changed ? naming : task.member_naming;
|
|
186
|
+
}
|
|
@@ -190,6 +190,7 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
|
|
|
190
190
|
loop_source: input.loops !== undefined ? 'cli' : base.loops !== undefined ? 'agent-template' : 'omitted',
|
|
191
191
|
agent_template_hash: hash,
|
|
192
192
|
...(input.role ? { role_preset: { id: input.role, hash: createHash('sha256').update(canonicalJson(rolePreset)).digest('hex') } } : {}),
|
|
193
|
+
...(cfg.agentTemplateRoleRefs?.[source] ? { role_label: cfg.agentTemplateRoleRefs[source] } : {}),
|
|
193
194
|
...(input.brain ? { brain_preset: { id: input.brain, hash: createHash('sha256').update(canonicalJson(brainPreset)).digest('hex') } } : {}),
|
|
194
195
|
agent_projection: redactLaunchDefinition(definition) };
|
|
195
196
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export declare const DEFAULT_MISSION_PREFIX = "MSN";
|
|
2
|
+
/** Never accepted for a custom list; ROOM/LAYOUT case-fold onto legacy agent names. */
|
|
3
|
+
export declare const RESERVED_MISSION_PREFIXES: readonly string[];
|
|
4
|
+
export declare const MISSION_PREFIX_MAX = 6;
|
|
5
|
+
/** Accepted reference form; canonical task IDs never contain '-'. */
|
|
6
|
+
export declare const MISSION_KEY_RE: RegExp;
|
|
7
|
+
export type MissionKeyErrorCode = 'invalid_prefix' | 'duplicate_prefix' | 'prefix_reserved' | 'default_prefix_frozen' | 'task_deleted' | 'idempotency_conflict' | 'key_space_exhausted' | 'invalid_state';
|
|
8
|
+
export declare class MissionKeyError extends Error {
|
|
9
|
+
readonly code: MissionKeyErrorCode;
|
|
10
|
+
constructor(code: MissionKeyErrorCode, message: string);
|
|
11
|
+
}
|
|
12
|
+
export interface PrefixRecord {
|
|
13
|
+
list_id: string;
|
|
14
|
+
last_issued: number;
|
|
15
|
+
/** active: the list's current prefix; retired: an earlier prefix of the same list; tombstoned: its list was deleted. */
|
|
16
|
+
state: 'active' | 'retired' | 'tombstoned';
|
|
17
|
+
}
|
|
18
|
+
export interface MissionKeyRegistry {
|
|
19
|
+
version: 1;
|
|
20
|
+
default_prefix: string;
|
|
21
|
+
prefixes: Record<string, PrefixRecord>;
|
|
22
|
+
/** Set only after every historical row, receipt and ledger entry is durable. */
|
|
23
|
+
keys_migrated?: true;
|
|
24
|
+
}
|
|
25
|
+
export interface CreationReceipt {
|
|
26
|
+
version: 1;
|
|
27
|
+
task_id: string;
|
|
28
|
+
/** Absent only on a tombstone seeded for a row that never had a key. */
|
|
29
|
+
mission_key?: string;
|
|
30
|
+
list_id: string;
|
|
31
|
+
/** sha256 of the canonical original creation input; absent on receipts seeded for legacy rows. */
|
|
32
|
+
fingerprint?: string;
|
|
33
|
+
/** Complete original creation input, so a resumed create never uses retry input. */
|
|
34
|
+
input?: Record<string, unknown>;
|
|
35
|
+
creation_plan?: string;
|
|
36
|
+
state: 'reserved' | 'created' | 'deleted';
|
|
37
|
+
}
|
|
38
|
+
export interface KeyLedgerEntry {
|
|
39
|
+
version: 1;
|
|
40
|
+
key: string;
|
|
41
|
+
task_id: string;
|
|
42
|
+
state: 'live' | 'deleted';
|
|
43
|
+
}
|
|
44
|
+
/** Innermost cross-process lock for registry, receipts and ledger. Re-entrant within a process. */
|
|
45
|
+
export declare function withMissionKeysLock<T>(fn: () => T): T;
|
|
46
|
+
/** Trim and uppercase ASCII letters; every other deviation is an explicit error. */
|
|
47
|
+
export declare function normalizeMissionPrefix(input: unknown): string;
|
|
48
|
+
/**
|
|
49
|
+
* Deterministic convenience suggestion; never authoritative and never a
|
|
50
|
+
* tombstoned, retired, reserved or active prefix.
|
|
51
|
+
*/
|
|
52
|
+
export declare function suggestMissionPrefix(name: string, taken: ReadonlySet<string>): string;
|
|
53
|
+
export declare function readMissionKeyRegistry(): MissionKeyRegistry | undefined;
|
|
54
|
+
export declare function writeMissionKeyRegistry(registry: MissionKeyRegistry): void;
|
|
55
|
+
/**
|
|
56
|
+
* Create the registry on first use. A configured default prefix may change the
|
|
57
|
+
* default list's prefix only until it has issued its first key.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ensureMissionKeyRegistry(configuredDefault?: string): MissionKeyRegistry;
|
|
60
|
+
/** Every prefix a custom list may not take: reserved names, the default, and any ever-issued prefix. */
|
|
61
|
+
export declare function unavailablePrefixes(registry: MissionKeyRegistry): Set<string>;
|
|
62
|
+
export declare function activePrefixFor(registry: MissionKeyRegistry, listId: string): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Claim `prefix` as the active prefix of `listId`. `explicit` is false for a
|
|
65
|
+
* server suggestion, which never adopts a tombstone. Caller holds the lock and
|
|
66
|
+
* writes the registry.
|
|
67
|
+
*/
|
|
68
|
+
export declare function claimPrefix(registry: MissionKeyRegistry, listId: string, prefix: string, explicit: boolean): void;
|
|
69
|
+
/** Deleting a list keeps its prefixes and high-water marks as tombstones. */
|
|
70
|
+
export declare function tombstoneListPrefixes(registry: MissionKeyRegistry, listId: string): void;
|
|
71
|
+
/** Raise a prefix's high-water mark; the caller persists the registry before publishing the key. */
|
|
72
|
+
export declare function issueNextKey(registry: MissionKeyRegistry, listId: string): string;
|
|
73
|
+
/** Monotonically account for an already-issued key. */
|
|
74
|
+
export declare function observeIssuedKey(registry: MissionKeyRegistry, key: string, listId: string): void;
|
|
75
|
+
export declare const readCreationReceipt: (idempotencyKey: string) => CreationReceipt | undefined;
|
|
76
|
+
export declare function writeCreationReceipt(idempotencyKey: string, receipt: CreationReceipt): void;
|
|
77
|
+
export declare function readKeyLedger(key: string): KeyLedgerEntry | undefined;
|
|
78
|
+
/** Create or confirm the ledger entry; a key never moves to another task. */
|
|
79
|
+
export declare function ensureKeyLedger(key: string, taskId: string, state?: KeyLedgerEntry['state']): void;
|
|
80
|
+
export declare function canonicalFingerprint(input: Record<string, unknown>): string;
|
|
81
|
+
/** Parse a user reference; returns the normalized key, or undefined for non-key input. */
|
|
82
|
+
export declare function parseMissionKeyRef(ref: string): string | undefined;
|
|
83
|
+
export declare const missionKeysExist: () => boolean;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human mission keys (`MSN-1`) and the durable state that issues them.
|
|
3
|
+
*
|
|
4
|
+
* The canonical task ID stays the storage, lock and authorization identity.
|
|
5
|
+
* A key is an immutable alias issued once from a per-prefix high-water mark;
|
|
6
|
+
* numbers are never derived from current rows and never reissued.
|
|
7
|
+
*
|
|
8
|
+
* Durable files under `<state>/mission-keys/`, all written by atomic replace
|
|
9
|
+
* while holding the synchronous mission-keys lock (innermost lock: callers may
|
|
10
|
+
* already hold `task-lists.lock` and/or one task lock, never the reverse):
|
|
11
|
+
* registry.json prefixes, owners, high-water marks
|
|
12
|
+
* receipts/<sha256>.json create idempotency receipts, never removed
|
|
13
|
+
* keys/<KEY>.json key ledger: key → canonical task ID, never removed
|
|
14
|
+
*
|
|
15
|
+
* Single ordering invariant for every issued key:
|
|
16
|
+
* high-water mark → receipt → ledger → keyed task row.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { replaceFileAtomically, withSynchronousFileLock } from '../atomic-file.js';
|
|
22
|
+
import { stateRoot } from '../paths.js';
|
|
23
|
+
export const DEFAULT_MISSION_PREFIX = 'MSN';
|
|
24
|
+
/** Never accepted for a custom list; ROOM/LAYOUT case-fold onto legacy agent names. */
|
|
25
|
+
export const RESERVED_MISSION_PREFIXES = ['MSN', 'ROOM', 'LAYOUT', 'TASK'];
|
|
26
|
+
export const MISSION_PREFIX_MAX = 6;
|
|
27
|
+
const PREFIX_RE = /^[A-Z][A-Z0-9]{0,5}$/;
|
|
28
|
+
/** Accepted reference form; canonical task IDs never contain '-'. */
|
|
29
|
+
export const MISSION_KEY_RE = /^([A-Za-z][A-Za-z0-9]{0,5})-([1-9][0-9]{0,8})$/;
|
|
30
|
+
const MAX_MISSION_NUMBER = 999_999_999;
|
|
31
|
+
export class MissionKeyError extends Error {
|
|
32
|
+
code;
|
|
33
|
+
constructor(code, message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.name = 'MissionKeyError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const root = () => join(stateRoot(), 'mission-keys');
|
|
40
|
+
const registryPath = () => join(root(), 'registry.json');
|
|
41
|
+
const lockPath = () => join(root(), 'lock');
|
|
42
|
+
const receiptPath = (idempotencyKey) => join(root(), 'receipts', `${createHash('sha256').update(idempotencyKey).digest('hex')}.json`);
|
|
43
|
+
const ledgerPath = (key) => join(root(), 'keys', `${key}.json`);
|
|
44
|
+
let lockDepth = 0;
|
|
45
|
+
/** Innermost cross-process lock for registry, receipts and ledger. Re-entrant within a process. */
|
|
46
|
+
export function withMissionKeysLock(fn) {
|
|
47
|
+
if (lockDepth > 0)
|
|
48
|
+
return fn();
|
|
49
|
+
return withSynchronousFileLock(lockPath(), () => {
|
|
50
|
+
lockDepth++;
|
|
51
|
+
try {
|
|
52
|
+
return fn();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
lockDepth--;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const assertLocked = () => {
|
|
60
|
+
if (lockDepth === 0)
|
|
61
|
+
throw new MissionKeyError('invalid_state', 'mission-keys lock is not held');
|
|
62
|
+
};
|
|
63
|
+
function readJson(path) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT')
|
|
69
|
+
return undefined;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const writeJson = (path, value) => replaceFileAtomically(path, JSON.stringify(value, null, 2) + '\n', 0o600, {}, { durable: true });
|
|
74
|
+
// ── Prefix rules ─────────────────────────────────────────────────────────
|
|
75
|
+
/** Trim and uppercase ASCII letters; every other deviation is an explicit error. */
|
|
76
|
+
export function normalizeMissionPrefix(input) {
|
|
77
|
+
if (typeof input !== 'string')
|
|
78
|
+
throw new MissionKeyError('invalid_prefix', 'prefix must be a string');
|
|
79
|
+
const value = input.trim().replace(/[a-z]/g, c => c.toUpperCase());
|
|
80
|
+
if (!PREFIX_RE.test(value))
|
|
81
|
+
throw new MissionKeyError('invalid_prefix', `prefix must be 1-${MISSION_PREFIX_MAX} Latin letters or digits starting with a letter, e.g. OPS`);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
/** Latin words of a list name; diacritics are folded, other scripts are dropped. */
|
|
85
|
+
function latinWords(name) {
|
|
86
|
+
return name.normalize('NFKD').replace(/\p{M}/gu, '').toUpperCase()
|
|
87
|
+
.split(/[^A-Z0-9]+/).filter(Boolean);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Deterministic convenience suggestion; never authoritative and never a
|
|
91
|
+
* tombstoned, retired, reserved or active prefix.
|
|
92
|
+
*/
|
|
93
|
+
export function suggestMissionPrefix(name, taken) {
|
|
94
|
+
const words = latinWords(name).filter(word => /^[A-Z]/.test(word));
|
|
95
|
+
let base = words.length > 1
|
|
96
|
+
? words.map(word => word[0]).join('').slice(0, MISSION_PREFIX_MAX)
|
|
97
|
+
: words.length === 1 ? words[0].slice(0, 4) : '';
|
|
98
|
+
if (!PREFIX_RE.test(base))
|
|
99
|
+
base = 'L';
|
|
100
|
+
const free = (candidate) => !taken.has(candidate) && !RESERVED_MISSION_PREFIXES.includes(candidate);
|
|
101
|
+
if (base !== 'L' && free(base))
|
|
102
|
+
return base;
|
|
103
|
+
for (let n = base === 'L' ? 1 : 2; n < 1_000_000; n++) {
|
|
104
|
+
const suffix = String(n);
|
|
105
|
+
const candidate = base.slice(0, MISSION_PREFIX_MAX - suffix.length) + suffix;
|
|
106
|
+
if (PREFIX_RE.test(candidate) && free(candidate))
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
throw new MissionKeyError('key_space_exhausted', 'no free prefix suggestion');
|
|
110
|
+
}
|
|
111
|
+
// ── Registry ─────────────────────────────────────────────────────────────
|
|
112
|
+
export function readMissionKeyRegistry() {
|
|
113
|
+
const value = readJson(registryPath());
|
|
114
|
+
if (value && (value.version !== 1 || typeof value.default_prefix !== 'string' || typeof value.prefixes !== 'object'))
|
|
115
|
+
throw new MissionKeyError('invalid_state', 'invalid mission key registry');
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
export function writeMissionKeyRegistry(registry) {
|
|
119
|
+
assertLocked();
|
|
120
|
+
writeJson(registryPath(), registry);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create the registry on first use. A configured default prefix may change the
|
|
124
|
+
* default list's prefix only until it has issued its first key.
|
|
125
|
+
*/
|
|
126
|
+
export function ensureMissionKeyRegistry(configuredDefault) {
|
|
127
|
+
return withMissionKeysLock(() => {
|
|
128
|
+
const desired = configuredDefault === undefined ? undefined : normalizeMissionPrefix(configuredDefault);
|
|
129
|
+
let registry = readMissionKeyRegistry();
|
|
130
|
+
if (!registry) {
|
|
131
|
+
const prefix = desired ?? DEFAULT_MISSION_PREFIX;
|
|
132
|
+
registry = { version: 1, default_prefix: prefix,
|
|
133
|
+
prefixes: { [prefix]: { list_id: 'default', last_issued: 0, state: 'active' } } };
|
|
134
|
+
writeMissionKeyRegistry(registry);
|
|
135
|
+
return registry;
|
|
136
|
+
}
|
|
137
|
+
if (desired === undefined || desired === registry.default_prefix)
|
|
138
|
+
return registry;
|
|
139
|
+
const current = registry.prefixes[registry.default_prefix];
|
|
140
|
+
if (current && current.last_issued > 0)
|
|
141
|
+
throw new MissionKeyError('default_prefix_frozen', `default list prefix is ${registry.default_prefix}; tasks.default_prefix=${desired} can only change before the first mission key`);
|
|
142
|
+
if (registry.prefixes[desired])
|
|
143
|
+
throw new MissionKeyError('prefix_reserved', `prefix ${desired} is already used by another list`);
|
|
144
|
+
delete registry.prefixes[registry.default_prefix];
|
|
145
|
+
registry.default_prefix = desired;
|
|
146
|
+
registry.prefixes[desired] = { list_id: 'default', last_issued: 0, state: 'active' };
|
|
147
|
+
writeMissionKeyRegistry(registry);
|
|
148
|
+
return registry;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Every prefix a custom list may not take: reserved names, the default, and any ever-issued prefix. */
|
|
152
|
+
export function unavailablePrefixes(registry) {
|
|
153
|
+
return new Set([...RESERVED_MISSION_PREFIXES, registry.default_prefix, ...Object.keys(registry.prefixes)]);
|
|
154
|
+
}
|
|
155
|
+
export function activePrefixFor(registry, listId) {
|
|
156
|
+
return Object.entries(registry.prefixes).find(([, record]) => record.list_id === listId && record.state === 'active')?.[0];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Claim `prefix` as the active prefix of `listId`. `explicit` is false for a
|
|
160
|
+
* server suggestion, which never adopts a tombstone. Caller holds the lock and
|
|
161
|
+
* writes the registry.
|
|
162
|
+
*/
|
|
163
|
+
export function claimPrefix(registry, listId, prefix, explicit) {
|
|
164
|
+
assertLocked();
|
|
165
|
+
if (listId !== 'default' && (RESERVED_MISSION_PREFIXES.includes(prefix) || prefix === registry.default_prefix))
|
|
166
|
+
throw new MissionKeyError('prefix_reserved', `prefix ${prefix} is reserved`);
|
|
167
|
+
const existing = registry.prefixes[prefix];
|
|
168
|
+
if (existing) {
|
|
169
|
+
const ownRetired = existing.list_id === listId && existing.state !== 'tombstoned';
|
|
170
|
+
const reclaim = existing.state === 'tombstoned' && explicit;
|
|
171
|
+
if (existing.list_id === listId && existing.state === 'active')
|
|
172
|
+
return;
|
|
173
|
+
if (!ownRetired && !reclaim)
|
|
174
|
+
throw new MissionKeyError(existing.state === 'active' ? 'duplicate_prefix' : 'prefix_reserved', existing.state === 'active'
|
|
175
|
+
? `prefix ${prefix} is already used by another list`
|
|
176
|
+
: `prefix ${prefix} was used by an earlier list and is reserved`);
|
|
177
|
+
}
|
|
178
|
+
for (const record of Object.values(registry.prefixes))
|
|
179
|
+
if (record.list_id === listId && record.state === 'active')
|
|
180
|
+
record.state = 'retired';
|
|
181
|
+
registry.prefixes[prefix] = { list_id: listId, last_issued: existing?.last_issued ?? 0, state: 'active' };
|
|
182
|
+
}
|
|
183
|
+
/** Deleting a list keeps its prefixes and high-water marks as tombstones. */
|
|
184
|
+
export function tombstoneListPrefixes(registry, listId) {
|
|
185
|
+
assertLocked();
|
|
186
|
+
for (const record of Object.values(registry.prefixes))
|
|
187
|
+
if (record.list_id === listId)
|
|
188
|
+
record.state = 'tombstoned';
|
|
189
|
+
}
|
|
190
|
+
/** Raise a prefix's high-water mark; the caller persists the registry before publishing the key. */
|
|
191
|
+
export function issueNextKey(registry, listId) {
|
|
192
|
+
assertLocked();
|
|
193
|
+
const prefix = activePrefixFor(registry, listId);
|
|
194
|
+
if (!prefix)
|
|
195
|
+
throw new MissionKeyError('invalid_state', `list ${listId} has no active prefix`);
|
|
196
|
+
const record = registry.prefixes[prefix];
|
|
197
|
+
if (record.last_issued >= MAX_MISSION_NUMBER)
|
|
198
|
+
throw new MissionKeyError('key_space_exhausted', `prefix ${prefix} has issued its last key`);
|
|
199
|
+
record.last_issued += 1;
|
|
200
|
+
return `${prefix}-${record.last_issued}`;
|
|
201
|
+
}
|
|
202
|
+
/** Monotonically account for an already-issued key. */
|
|
203
|
+
export function observeIssuedKey(registry, key, listId) {
|
|
204
|
+
const match = MISSION_KEY_RE.exec(key);
|
|
205
|
+
if (!match)
|
|
206
|
+
return;
|
|
207
|
+
const prefix = match[1].toUpperCase(), n = Number(match[2]);
|
|
208
|
+
const record = registry.prefixes[prefix]
|
|
209
|
+
?? (registry.prefixes[prefix] = { list_id: listId, last_issued: 0, state: 'tombstoned' });
|
|
210
|
+
record.last_issued = Math.max(record.last_issued, n);
|
|
211
|
+
}
|
|
212
|
+
// ── Receipts and ledger ──────────────────────────────────────────────────
|
|
213
|
+
export const readCreationReceipt = (idempotencyKey) => readJson(receiptPath(idempotencyKey));
|
|
214
|
+
export function writeCreationReceipt(idempotencyKey, receipt) {
|
|
215
|
+
assertLocked();
|
|
216
|
+
const previous = readCreationReceipt(idempotencyKey);
|
|
217
|
+
// A deletion tombstone is terminal: nothing may revive the receipt.
|
|
218
|
+
if (previous?.state === 'deleted' && receipt.state !== 'deleted')
|
|
219
|
+
throw new MissionKeyError('task_deleted', `mission ${previous.mission_key} was deleted`);
|
|
220
|
+
writeJson(receiptPath(idempotencyKey), receipt);
|
|
221
|
+
}
|
|
222
|
+
export function readKeyLedger(key) {
|
|
223
|
+
if (!/^[A-Z][A-Z0-9]{0,5}-[1-9][0-9]*$/.test(key))
|
|
224
|
+
return undefined;
|
|
225
|
+
return readJson(ledgerPath(key));
|
|
226
|
+
}
|
|
227
|
+
/** Create or confirm the ledger entry; a key never moves to another task. */
|
|
228
|
+
export function ensureKeyLedger(key, taskId, state = 'live') {
|
|
229
|
+
assertLocked();
|
|
230
|
+
const previous = readKeyLedger(key);
|
|
231
|
+
if (previous && previous.task_id !== taskId)
|
|
232
|
+
throw new MissionKeyError('invalid_state', `mission key ${key} already belongs to another task`);
|
|
233
|
+
if (previous && (previous.state === state || previous.state === 'deleted'))
|
|
234
|
+
return;
|
|
235
|
+
writeJson(ledgerPath(key), { version: 1, key, task_id: taskId, state });
|
|
236
|
+
}
|
|
237
|
+
export function canonicalFingerprint(input) {
|
|
238
|
+
const sorted = (value) => Array.isArray(value) ? value.map(sorted)
|
|
239
|
+
: value && typeof value === 'object'
|
|
240
|
+
? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined)
|
|
241
|
+
.map(key => [key, sorted(value[key])]))
|
|
242
|
+
: value;
|
|
243
|
+
return createHash('sha256').update(JSON.stringify(sorted(input))).digest('hex');
|
|
244
|
+
}
|
|
245
|
+
/** Parse a user reference; returns the normalized key, or undefined for non-key input. */
|
|
246
|
+
export function parseMissionKeyRef(ref) {
|
|
247
|
+
const match = MISSION_KEY_RE.exec(ref);
|
|
248
|
+
return match ? `${match[1].toUpperCase()}-${match[2]}` : undefined;
|
|
249
|
+
}
|
|
250
|
+
export const missionKeysExist = () => existsSync(registryPath());
|
|
@@ -15,6 +15,8 @@ export interface ProvisionMembersInput {
|
|
|
15
15
|
startupWait?: Partial<StartupWaitPolicy>;
|
|
16
16
|
/** Append only the supplied new slots to an already active room. */
|
|
17
17
|
append?: boolean;
|
|
18
|
+
/** Durable name allocation key for an appended member (`add:<requestId>`). */
|
|
19
|
+
allocationKey?: string;
|
|
18
20
|
}
|
|
19
21
|
export interface StartupWaitPolicy {
|
|
20
22
|
timeoutMs: number;
|
|
@@ -23,6 +25,8 @@ export interface StartupWaitPolicy {
|
|
|
23
25
|
now(): number;
|
|
24
26
|
sleep(ms: number): Promise<void>;
|
|
25
27
|
}
|
|
28
|
+
/** A new name must not collide with any local agent state or a permanent identity. */
|
|
29
|
+
export declare function assertMemberNameAvailable(name: string): void;
|
|
26
30
|
export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
|
|
27
31
|
/** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
|
|
28
32
|
export declare function reconcileExistingTaskMembers(input: {
|
|
@@ -19,7 +19,9 @@ import { SessionControlError } from '../session/types.js';
|
|
|
19
19
|
import { closeManagedRoom, roomCloseLockPath, CLOSE_LOCK_STALE_MS } from './close.js';
|
|
20
20
|
import { withFileLock } from '../atomic-file.js';
|
|
21
21
|
import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
|
|
22
|
-
import { TaskStateError, taskDeletionState } from './task-state.js';
|
|
22
|
+
import { TaskStateError, allocateTaskMemberNames, taskDeletionState } from './task-state.js';
|
|
23
|
+
import { MemberNameError } from './member-naming.js';
|
|
24
|
+
import { assertMemberNotPermanent } from './member-ownership.js';
|
|
23
25
|
import { buildRoomMemberTask, sha256Text } from './member-startup.js';
|
|
24
26
|
import { agentDir } from '../paths.js';
|
|
25
27
|
import { readProvenance } from '../creation.js';
|
|
@@ -92,11 +94,43 @@ function expandMembers(template, prefix) {
|
|
|
92
94
|
launchDefinitionId: slot.launch_definition_id ?? slot.agent_template,
|
|
93
95
|
agentTemplateHash: slot.agent_template_hash,
|
|
94
96
|
loopSource: slot.loop_source ?? 'omitted',
|
|
97
|
+
ordinal: i,
|
|
98
|
+
candidates: [
|
|
99
|
+
{ label: slot.role_preset?.id, source: 'role_override' },
|
|
100
|
+
{ label: slot.role_label, source: 'template_ref' },
|
|
101
|
+
{ label: slot.role, source: 'room_role' },
|
|
102
|
+
{ label: slot.slot, source: 'slot' },
|
|
103
|
+
],
|
|
95
104
|
});
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
return result;
|
|
99
108
|
}
|
|
109
|
+
/** A new name must not collide with any local agent state or a permanent identity. */
|
|
110
|
+
export function assertMemberNameAvailable(name) {
|
|
111
|
+
const taken = () => new MemberNameError('agent_name_taken', `agent name ${name} is already used on this host or ours daemon, possibly by another Fleet; give this Fleet's lists distinct prefixes`);
|
|
112
|
+
if (existsSync(agentDir(name)) || existsSync(agentDir(name, true)))
|
|
113
|
+
throw taken();
|
|
114
|
+
try {
|
|
115
|
+
assertMemberNotPermanent(name);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw taken();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Members of a keyed task take their durable role-scoped names; unkeyed tasks
|
|
123
|
+
* and non-task rooms keep the legacy `prefix-slot-i` derivation.
|
|
124
|
+
*/
|
|
125
|
+
function nameMembers(input, members) {
|
|
126
|
+
if (!input.taskId)
|
|
127
|
+
return members;
|
|
128
|
+
const names = allocateTaskMemberNames(input.taskId, members.map(member => ({
|
|
129
|
+
allocationKey: input.append && input.allocationKey ? input.allocationKey : `initial:${member.slot}:${member.ordinal}`,
|
|
130
|
+
candidates: member.candidates,
|
|
131
|
+
})), assertMemberNameAvailable);
|
|
132
|
+
return names ? members.map((member, index) => ({ ...member, name: names[index] })) : members;
|
|
133
|
+
}
|
|
100
134
|
function settingsFor(member, cfg, sealed) {
|
|
101
135
|
const definition = sealed
|
|
102
136
|
? sealed[member.launchDefinitionId]
|
|
@@ -406,7 +440,7 @@ export async function provisionMembers(input) {
|
|
|
406
440
|
if (!task || !['active', 'review'].includes(task.state) || room?.state !== 'active'
|
|
407
441
|
|| room.task_id !== task.task_id || room.room_identity_cid !== task.room_identity_cid)
|
|
408
442
|
throw new Error('Adding an agent requires the exact active task room');
|
|
409
|
-
const names = expandMembers(input.template, shortId(task.task_id)).map(m => m.name);
|
|
443
|
+
const names = nameMembers(input, expandMembers(input.template, shortId(task.task_id))).map(m => m.name);
|
|
410
444
|
if (room.member_seats.some(seat => names.includes(seat.role_name)))
|
|
411
445
|
throw new Error('Member slot is already recorded; inspect its existing launch');
|
|
412
446
|
if (room.member_seats.some(seat => seat.seat_state !== 'active'))
|
|
@@ -426,7 +460,7 @@ async function provisionMembersUnlocked(input) {
|
|
|
426
460
|
if (taskId && taskDeletionState(taskId) !== 'none')
|
|
427
461
|
throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
|
|
428
462
|
const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
|
|
429
|
-
const members = expandMembers(template, prefix);
|
|
463
|
+
const members = nameMembers(input, expandMembers(template, prefix));
|
|
430
464
|
// Resolve every Agent before persisting launch intent or touching Cowork membership.
|
|
431
465
|
const sealed = template.launch_snapshot_hash
|
|
432
466
|
? readLaunchSnapshot(template.launch_snapshot_hash) : undefined;
|
|
@@ -11,9 +11,25 @@ export declare const withTaskListsLock: <T>(fn: () => T | Promise<T>) => Promise
|
|
|
11
11
|
export declare function normalizeTaskListName(input: string): string;
|
|
12
12
|
export declare function readTaskLists(): TaskListRecord[];
|
|
13
13
|
export declare function resolveTaskList(name: string, lists?: TaskListRecord[]): TaskListRecord;
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Server suggestion for a new list's prefix; convenience only, validated again
|
|
16
|
+
* on create.
|
|
17
|
+
*/
|
|
18
|
+
export declare function suggestTaskListPrefix(name: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Caller must hold withTaskListsLock. The prefix registry is written before
|
|
21
|
+
* the list row, so a crash leaves at worst an orphan reservation that only an
|
|
22
|
+
* explicit reclaim can reuse; a key is never reissued.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createTaskListLocked(name: string, prefix?: string): TaskListRecord;
|
|
25
|
+
/**
|
|
26
|
+
* Caller must hold withTaskListsLock. Issued keys keep their old prefix; the
|
|
27
|
+
* previous prefix stays reserved to this list.
|
|
28
|
+
*/
|
|
29
|
+
export declare function setTaskListPrefixLocked(listName: string, prefix: string): TaskListRecord;
|
|
16
30
|
/** Caller must hold withTaskListsLock. */
|
|
17
31
|
export declare function renameTaskListLocked(currentName: string, nextName: string): TaskListRecord;
|
|
18
32
|
/** Caller must hold withTaskListsLock. */
|
|
19
33
|
export declare function deleteTaskListRecordLocked(listId: string): void;
|
|
34
|
+
/** Caller holds withTaskListsLock; persists prefixes assigned by migration. */
|
|
35
|
+
export declare function writeTaskListPrefixesLocked(prefixes: Map<string, string>): TaskListRecord[];
|