pi-ultracode 0.1.3 → 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.
@@ -0,0 +1,318 @@
1
+ export const DEFAULT_MAX_AGENTS = 128;
2
+ export const ABSOLUTE_MAX_AGENTS = 1024;
3
+ export const MAX_ACTIVE_WORKFLOWS_PER_SESSION = 4;
4
+
5
+ export const WORKFLOW_POLICY_ERROR_CODE = "WORKFLOW_POLICY_ERROR";
6
+ export const WORKFLOW_ABORT_ERROR_CODE = "WORKFLOW_ABORTED";
7
+ export const WORKFLOW_STALL_ERROR_CODE = "WORKFLOW_STALLED";
8
+ export const WORKFLOW_CLEANUP_TIMEOUT_ERROR_CODE = "WORKFLOW_CLEANUP_TIMEOUT";
9
+
10
+ export class WorkflowPolicyError extends Error {
11
+ readonly code = WORKFLOW_POLICY_ERROR_CODE;
12
+
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "WorkflowPolicyError";
16
+ }
17
+ }
18
+
19
+ export class WorkflowAbortError extends Error {
20
+ readonly code = WORKFLOW_ABORT_ERROR_CODE;
21
+
22
+ constructor(message = "workflow aborted") {
23
+ super(message);
24
+ this.name = "WorkflowAbortError";
25
+ }
26
+ }
27
+
28
+ export class WorkflowStallError extends Error {
29
+ readonly code = WORKFLOW_STALL_ERROR_CODE;
30
+
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "WorkflowStallError";
34
+ }
35
+ }
36
+
37
+ export class WorkflowCleanupTimeoutError extends Error {
38
+ readonly code = WORKFLOW_CLEANUP_TIMEOUT_ERROR_CODE;
39
+
40
+ constructor(timeoutMs: number) {
41
+ super(`workflow cleanup did not drain within ${timeoutMs}ms`);
42
+ this.name = "WorkflowCleanupTimeoutError";
43
+ }
44
+ }
45
+
46
+ export function isWorkflowPolicyError(error: unknown): error is WorkflowPolicyError {
47
+ return !!error && typeof error === "object" && (error as any).code === WORKFLOW_POLICY_ERROR_CODE;
48
+ }
49
+
50
+ export function isWorkflowAbortError(error: unknown): error is WorkflowAbortError {
51
+ return !!error && typeof error === "object" && (error as any).code === WORKFLOW_ABORT_ERROR_CODE;
52
+ }
53
+
54
+ export function isWorkflowStallError(error: unknown): error is WorkflowStallError {
55
+ return !!error && typeof error === "object" && (error as any).code === WORKFLOW_STALL_ERROR_CODE;
56
+ }
57
+
58
+ export function isWorkflowCleanupTimeoutError(error: unknown): error is WorkflowCleanupTimeoutError {
59
+ return !!error && typeof error === "object" && (error as any).code === WORKFLOW_CLEANUP_TIMEOUT_ERROR_CODE;
60
+ }
61
+
62
+ export function normalizeMaxAgents(value: unknown, name = "maxAgents"): number {
63
+ if (value === undefined || value === null) return DEFAULT_MAX_AGENTS;
64
+ if (typeof value !== "number" || !Number.isInteger(value)) {
65
+ throw new WorkflowPolicyError(`workflow ${name} must be an integer between 1 and ${ABSOLUTE_MAX_AGENTS}`);
66
+ }
67
+ if (value < 1 || value > ABSOLUTE_MAX_AGENTS) {
68
+ throw new WorkflowPolicyError(`workflow ${name} must be between 1 and ${ABSOLUTE_MAX_AGENTS} (got ${value})`);
69
+ }
70
+ return value;
71
+ }
72
+
73
+ export function normalizeReservationSize(value: unknown, fallback: number): number {
74
+ if (value === undefined || value === null) return fallback;
75
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > ABSOLUTE_MAX_AGENTS) {
76
+ throw new WorkflowPolicyError(`parallel reserveAgents must be an integer between 0 and ${ABSOLUTE_MAX_AGENTS}`);
77
+ }
78
+ return value;
79
+ }
80
+
81
+ interface SlotEntry {
82
+ /**
83
+ * Return path for an unused slot. The first live reservation in this stack gets
84
+ * the slot back; if none remain, the slot returns to root capacity. This lets a
85
+ * nested panel transfer a sibling's branch slot without permanently stealing it
86
+ * from its immediate parent, while still preserving the parent's own upstream
87
+ * return path when that parent is released later.
88
+ */
89
+ returnStack: string[];
90
+ }
91
+
92
+ interface Reservation {
93
+ id: string;
94
+ slots: SlotEntry[];
95
+ contingentSlots: number;
96
+ contingentParentIds: string[];
97
+ released: boolean;
98
+ }
99
+
100
+ export interface PanelReservation {
101
+ panelReservationId: string;
102
+ branchReservationIds: string[];
103
+ }
104
+
105
+ export class AgentAdmission {
106
+ readonly maxAgents: number;
107
+ private rootFree: number;
108
+ private consumed = 0;
109
+ private nextReservation = 0;
110
+ private readonly reservations = new Map<string, Reservation>();
111
+
112
+ constructor(maxAgents: number, usedAgents = 0) {
113
+ this.maxAgents = normalizeMaxAgents(maxAgents);
114
+ if (!Number.isInteger(usedAgents) || usedAgents < 0 || usedAgents > this.maxAgents) {
115
+ throw new WorkflowPolicyError(
116
+ `workflow usedAgents must be an integer between 0 and maxAgents=${this.maxAgents}`,
117
+ );
118
+ }
119
+ this.consumed = usedAgents;
120
+ this.rootFree = this.maxAgents - usedAgents;
121
+ }
122
+
123
+ get usedAgents(): number {
124
+ return this.consumed;
125
+ }
126
+
127
+ get availableAgents(): number {
128
+ return this.rootFree;
129
+ }
130
+
131
+ reservePanel(requestedSlots: number, parentReservationIds: string[] = []): string {
132
+ const slots = normalizeReservationSize(requestedSlots, requestedSlots);
133
+ const parents = this.uniqueReservations(parentReservationIds);
134
+ const parentFree = parents.reduce((sum, reservation) => sum + reservation.slots.length, 0);
135
+ const totalFree = parentFree + this.rootFree;
136
+ if (totalFree < slots) {
137
+ throw new WorkflowPolicyError(
138
+ `parallel() needs ${slots} agent slot(s), but only ${totalFree} remain under maxAgents=${this.maxAgents}`,
139
+ );
140
+ }
141
+
142
+ const entries: SlotEntry[] = [];
143
+ let remaining = slots;
144
+ for (const parent of parents) {
145
+ while (remaining > 0 && parent.slots.length > 0) {
146
+ const slot = parent.slots.shift()!;
147
+ entries.push({ returnStack: [parent.id, ...slot.returnStack] });
148
+ remaining--;
149
+ }
150
+ if (remaining === 0) break;
151
+ }
152
+ if (remaining > 0) {
153
+ this.rootFree -= remaining;
154
+ for (let i = 0; i < remaining; i++) entries.push({ returnStack: [] });
155
+ }
156
+
157
+ return this.createReservation(entries).id;
158
+ }
159
+
160
+ reservePanelWithBranchNeeds(
161
+ requestedSlots: number,
162
+ branchNeedsSlot: boolean[],
163
+ parentReservationIds: string[] = [],
164
+ contingentSlots = 0,
165
+ ): PanelReservation {
166
+ const branchCount = branchNeedsSlot.length;
167
+ const requiredBranches = branchNeedsSlot.filter(Boolean).length;
168
+ const slots = normalizeReservationSize(requestedSlots, requestedSlots);
169
+ if (branchCount > ABSOLUTE_MAX_AGENTS) {
170
+ throw new WorkflowPolicyError(`parallel branch count must be at most ${ABSOLUTE_MAX_AGENTS}`);
171
+ }
172
+ if (slots < requiredBranches) {
173
+ throw new WorkflowPolicyError(
174
+ `parallel replay needs at least ${requiredBranches} live branch slot(s); got ${slots}`,
175
+ );
176
+ }
177
+ const contingent = normalizeReservationSize(contingentSlots, contingentSlots);
178
+ const panelReservationId = this.reservePanel(slots, parentReservationIds);
179
+ const panelReservation = this.requireReservation(panelReservationId);
180
+ panelReservation.contingentSlots = contingent;
181
+ panelReservation.contingentParentIds = [...parentReservationIds];
182
+ const branchReservationIds: string[] = [];
183
+ try {
184
+ for (const needsSlot of branchNeedsSlot) {
185
+ branchReservationIds.push(this.reservePanel(needsSlot ? 1 : 0, [panelReservationId]));
186
+ }
187
+ return { panelReservationId, branchReservationIds };
188
+ } catch (error) {
189
+ for (const id of branchReservationIds) this.releasePanel(id);
190
+ this.releasePanel(panelReservationId);
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ reservePanelWithBranches(
196
+ requestedSlots: number,
197
+ branchCount: number,
198
+ parentReservationIds: string[] = [],
199
+ ): PanelReservation {
200
+ const slots = normalizeReservationSize(requestedSlots, requestedSlots);
201
+ if (!Number.isInteger(branchCount) || branchCount < 0 || branchCount > ABSOLUTE_MAX_AGENTS) {
202
+ throw new WorkflowPolicyError(`parallel branch count must be an integer between 0 and ${ABSOLUTE_MAX_AGENTS}`);
203
+ }
204
+ if (slots < branchCount) {
205
+ throw new WorkflowPolicyError(
206
+ `parallel reserveAgents must be at least the number of thunks (${branchCount}); got ${slots}`,
207
+ );
208
+ }
209
+
210
+ const panelReservationId = this.reservePanel(slots, parentReservationIds);
211
+ const branchReservationIds: string[] = [];
212
+ try {
213
+ for (let i = 0; i < branchCount; i++) {
214
+ branchReservationIds.push(this.reservePanel(1, [panelReservationId]));
215
+ }
216
+ return { panelReservationId, branchReservationIds };
217
+ } catch (error) {
218
+ for (const id of branchReservationIds) this.releasePanel(id);
219
+ this.releasePanel(panelReservationId);
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ releasePanel(id: string): void {
225
+ const reservation = this.reservations.get(id);
226
+ if (!reservation || reservation.released) return;
227
+ reservation.released = true;
228
+ for (const slot of reservation.slots) this.returnSlot(slot);
229
+ reservation.slots = [];
230
+ this.reservations.delete(id);
231
+ }
232
+
233
+ consumeAgent(reservationIds: string[] = []): number {
234
+ const reservations = this.uniqueReservations(reservationIds);
235
+ for (const reservation of reservations) {
236
+ if (reservation.slots.length > 0) {
237
+ reservation.slots.shift();
238
+ this.consumed++;
239
+ return this.consumed;
240
+ }
241
+ }
242
+ for (const reservation of reservations) {
243
+ if (reservation.contingentSlots > 0 && this.acquireContingentSlot(reservation)) {
244
+ reservation.contingentSlots--;
245
+ this.consumed++;
246
+ return this.consumed;
247
+ }
248
+ }
249
+ if (reservationIds.length > 0) {
250
+ throw new WorkflowPolicyError(
251
+ "parallel() reservation has no agent slots left; increase reserveAgents for this panel or lower fan-out",
252
+ );
253
+ }
254
+ if (this.rootFree < 1) {
255
+ throw new WorkflowPolicyError(`workflow exceeded maxAgents=${this.maxAgents} (no agent slots remain)`);
256
+ }
257
+ this.rootFree--;
258
+ this.consumed++;
259
+ return this.consumed;
260
+ }
261
+
262
+ private createReservation(slots: SlotEntry[]): Reservation {
263
+ const id = `res_${++this.nextReservation}`;
264
+ const reservation: Reservation = {
265
+ id,
266
+ slots,
267
+ contingentSlots: 0,
268
+ contingentParentIds: [],
269
+ released: false,
270
+ };
271
+ this.reservations.set(id, reservation);
272
+ return reservation;
273
+ }
274
+
275
+ private uniqueReservations(ids: string[]): Reservation[] {
276
+ const out: Reservation[] = [];
277
+ const seen = new Set<string>();
278
+ for (const id of ids) {
279
+ if (!id || seen.has(id)) continue;
280
+ seen.add(id);
281
+ out.push(this.requireReservation(id));
282
+ }
283
+ return out;
284
+ }
285
+
286
+ private requireReservation(id: string): Reservation {
287
+ const reservation = this.reservations.get(id);
288
+ if (!reservation || reservation.released) {
289
+ throw new WorkflowPolicyError(`unknown or expired parallel reservation: ${id}`);
290
+ }
291
+ return reservation;
292
+ }
293
+
294
+ private acquireContingentSlot(reservation: Reservation): boolean {
295
+ for (const parent of this.uniqueReservations(reservation.contingentParentIds)) {
296
+ if (parent.slots.length === 0) continue;
297
+ parent.slots.shift();
298
+ return true;
299
+ }
300
+ if (this.rootFree < 1) return false;
301
+ this.rootFree--;
302
+ return true;
303
+ }
304
+
305
+ private returnSlot(slot: SlotEntry): void {
306
+ const [targetId, ...rest] = slot.returnStack;
307
+ if (!targetId) {
308
+ this.rootFree++;
309
+ return;
310
+ }
311
+ const target = this.reservations.get(targetId);
312
+ if (target && !target.released) {
313
+ target.slots.push({ returnStack: rest });
314
+ return;
315
+ }
316
+ this.returnSlot({ returnStack: rest });
317
+ }
318
+ }
@@ -310,6 +310,7 @@ export class WorkflowAgentRunner {
310
310
  sessionManager: SessionManager.inMemory(cwd),
311
311
  settingsManager: SettingsManager.create(cwd, agentDir),
312
312
  customTools,
313
+ excludeTools: ["workflow"],
313
314
  ...(model ? { model: model as any } : {}),
314
315
  ...(level ? { thinkingLevel: level as any } : {}),
315
316
  ...(toolAllowlist ? { tools: toolAllowlist } : {}),
@@ -31,6 +31,8 @@ export interface WorkflowAgentUsageSnapshot {
31
31
 
32
32
  export interface WorkflowAgentSnapshot {
33
33
  id: number;
34
+ /** Stable orchestration identity; display order continues to use id. */
35
+ callPath?: string;
34
36
  label: string;
35
37
  phase?: string;
36
38
  workflowPath?: string[];
@@ -84,16 +86,19 @@ export interface WorkflowSnapshot {
84
86
  spentTokens: number;
85
87
  newTokens: number;
86
88
  replayedTokens: number;
89
+ /** Effective logical agent-slot cap for this run; usage-only, not a token budget. */
90
+ maxAgents?: number;
91
+ /** Lifetime live-agent admissions across resume generations. */
92
+ agentsUsed?: number;
87
93
  /** Sanitized session-scoped workflow detail manifest for headless consumers. */
88
94
  detailsManifestPath?: string;
89
- budgetTotal: number | null;
90
95
  durationMs?: number;
91
96
  result?: unknown;
92
97
  status: "running" | "completed" | "aborted" | "failed";
93
98
  }
94
99
 
95
100
  export interface RenderOptions {
96
- maxAgents?: number;
101
+ maxAgentRows?: number;
97
102
  maxLogs?: number;
98
103
  showResultPreviews?: boolean;
99
104
  /** @deprecated No-op retained for source compatibility. */
@@ -102,7 +107,7 @@ export interface RenderOptions {
102
107
  now?: number;
103
108
  }
104
109
 
105
- export function createSnapshot(meta: WorkflowMeta, runId: string, budgetTotal: number | null): WorkflowSnapshot {
110
+ export function createSnapshot(meta: WorkflowMeta, runId: string): WorkflowSnapshot {
106
111
  return {
107
112
  runId,
108
113
  name: safeDisplayText(meta.name, 120) || "workflow",
@@ -119,7 +124,6 @@ export function createSnapshot(meta: WorkflowMeta, runId: string, budgetTotal: n
119
124
  spentTokens: 0,
120
125
  newTokens: 0,
121
126
  replayedTokens: 0,
122
- budgetTotal,
123
127
  status: "running",
124
128
  };
125
129
  }
@@ -134,7 +138,7 @@ export function recompute(snapshot: WorkflowSnapshot): WorkflowSnapshot {
134
138
  }
135
139
 
136
140
  export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderOptions = {}): string[] {
137
- const maxAgents = options.maxAgents ?? 6;
141
+ const maxAgentRows = options.maxAgentRows ?? 6;
138
142
  const maxLogs = options.maxLogs ?? 2;
139
143
  const showResultPreviews = options.showResultPreviews ?? false;
140
144
  const now = options.now ?? Date.now();
@@ -145,9 +149,6 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
145
149
  : snapshot.spentTokens
146
150
  ? ` · ${formatTokens(snapshot.spentTokens)} output token`
147
151
  : "";
148
- const budget = snapshot.budgetTotal
149
- ? ` · ${formatTokens(snapshot.spentTokens)}/${formatTokens(snapshot.budgetTotal)} out`
150
- : "";
151
152
  const stateParts = [
152
153
  snapshot.runningCount > 0 ? `${snapshot.runningCount} running` : undefined,
153
154
  snapshot.errorCount > 0 ? `${snapshot.errorCount} errors` : undefined,
@@ -155,7 +156,10 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
155
156
  ].filter(Boolean);
156
157
  const state = stateParts.length ? `, ${stateParts.join(", ")}` : "";
157
158
  const cached = snapshot.cachedCount ? ` · ${snapshot.cachedCount} cached` : "";
158
- const header = `◆ ${statusMark(snapshot.status)} ${shorten(snapshot.name, 60)} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${tokens}${budget}`;
159
+ const agentSlots = snapshot.maxAgents
160
+ ? ` · agents ${snapshot.agentsUsed ?? snapshot.agentCount}/${snapshot.maxAgents}`
161
+ : "";
162
+ const header = `◆ ${statusMark(snapshot.status)} ${shorten(snapshot.name, 60)} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${agentSlots}${tokens}`;
159
163
  const lines = [header];
160
164
 
161
165
  const phaseNames = unique([
@@ -186,19 +190,19 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
186
190
  lines.push(
187
191
  ` ${marker} ${shorten(phase, 60)} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${cancelled ? ` · ${cancelled} cancelled` : ""}`,
188
192
  );
189
- for (const agent of agents.slice(-maxAgents)) {
193
+ for (const agent of agents.slice(-maxAgentRows)) {
190
194
  lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
191
195
  }
192
- if (agents.length > maxAgents) lines.push(` … ${agents.length - maxAgents} earlier agents`);
196
+ if (agents.length > maxAgentRows) lines.push(` … ${agents.length - maxAgentRows} earlier agents`);
193
197
  }
194
198
 
195
199
  const unphased = snapshot.agents.filter((a) => !rendered.has(a));
196
200
  if (unphased.length) {
197
201
  lines.push(" (unphased)");
198
- for (const agent of unphased.slice(-maxAgents)) {
202
+ for (const agent of unphased.slice(-maxAgentRows)) {
199
203
  lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
200
204
  }
201
- if (unphased.length > maxAgents) lines.push(` … ${unphased.length - maxAgents} earlier agents`);
205
+ if (unphased.length > maxAgentRows) lines.push(` … ${unphased.length - maxAgentRows} earlier agents`);
202
206
  }
203
207
 
204
208
  for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${shorten(log, 100)}`);
Binary file
@@ -3,15 +3,21 @@
3
3
  * `agent(prompt, { schema })`) into a TypeBox `TSchema` so Pi can both validate
4
4
  * the subagent's structured output and serialize a faithful tool schema to the model.
5
5
  *
6
- * Covers the common JSON Schema subset; anything unrecognized falls back to
7
- * `Type.Unsafe`, which preserves the raw schema for the model without crashing.
6
+ * Covers the explicitly validated workflow JSON Schema subset. Unsupported
7
+ * keywords are rejected before conversion rather than silently weakened.
8
8
  */
9
9
 
10
10
  import { Type, type TSchema } from "typebox";
11
+ import { assertWorkflowSchemaLimit } from "./value-limits.ts";
11
12
 
12
13
  type Json = Record<string, any>;
13
14
 
14
15
  export function jsonSchemaToTypeBox(schema: unknown): TSchema {
16
+ assertWorkflowSchemaLimit(schema);
17
+ return convertJsonSchema(schema);
18
+ }
19
+
20
+ function convertJsonSchema(schema: unknown): TSchema {
15
21
  if (!schema || typeof schema !== "object") {
16
22
  // No constraints -> accept anything.
17
23
  return Type.Unsafe<unknown>({});
@@ -21,27 +27,29 @@ export function jsonSchemaToTypeBox(schema: unknown): TSchema {
21
27
 
22
28
  // Composite keywords first.
23
29
  if (Array.isArray(node.enum)) {
24
- const literals = node.enum.map((value: unknown) => Type.Literal(value as any));
25
- return withAnnotations(literals.length === 1 ? literals[0] : Type.Union(literals), annotations);
30
+ return withAnnotations(Type.Unsafe<unknown>({
31
+ ...(node.type !== undefined ? { type: node.type } : {}),
32
+ enum: node.enum,
33
+ }), annotations);
26
34
  }
27
35
  if ("const" in node) {
28
- return withAnnotations(Type.Literal(node.const), annotations);
36
+ return withAnnotations(Type.Unsafe<unknown>({
37
+ ...(node.type !== undefined ? { type: node.type } : {}),
38
+ const: node.const,
39
+ }), annotations);
29
40
  }
30
41
  if (Array.isArray(node.anyOf)) {
31
- return withAnnotations(Type.Union(node.anyOf.map(jsonSchemaToTypeBox)), annotations);
32
- }
33
- if (Array.isArray(node.oneOf)) {
34
- return withAnnotations(Type.Union(node.oneOf.map(jsonSchemaToTypeBox)), annotations);
42
+ return withAnnotations(Type.Union(node.anyOf.map(convertJsonSchema)), annotations);
35
43
  }
36
44
  if (Array.isArray(node.allOf)) {
37
- return withAnnotations(Type.Intersect(node.allOf.map(jsonSchemaToTypeBox)), annotations);
45
+ return withAnnotations(Type.Intersect(node.allOf.map(convertJsonSchema)), annotations);
38
46
  }
39
47
 
40
48
  const type = node.type;
41
49
  if (Array.isArray(type)) {
42
50
  // e.g. ["string", "null"]
43
51
  return withAnnotations(
44
- Type.Union(type.map((t: string) => jsonSchemaToTypeBox({ ...node, type: t, enum: undefined }))),
52
+ Type.Union(type.map((t: string) => convertJsonSchema({ ...node, type: t, enum: undefined }))),
45
53
  annotations,
46
54
  );
47
55
  }
@@ -70,33 +78,47 @@ export function jsonSchemaToTypeBox(schema: unknown): TSchema {
70
78
  }
71
79
 
72
80
  function objectSchema(node: Json): TSchema {
73
- const properties: Record<string, TSchema> = {};
81
+ const properties: Record<string, TSchema> = Object.create(null);
74
82
  const required: string[] = Array.isArray(node.required) ? node.required : [];
75
83
  const props = (node.properties ?? {}) as Json;
76
84
  for (const [key, value] of Object.entries(props)) {
77
- const child = jsonSchemaToTypeBox(value);
85
+ const child = convertJsonSchema(value);
78
86
  properties[key] = required.includes(key) ? child : Type.Optional(child);
79
87
  }
80
88
  const options: Json = {};
81
89
  if (node.additionalProperties === false) options.additionalProperties = false;
82
90
  else if (node.additionalProperties && typeof node.additionalProperties === "object") {
83
- options.additionalProperties = jsonSchemaToTypeBox(node.additionalProperties);
91
+ options.additionalProperties = convertJsonSchema(node.additionalProperties);
84
92
  }
85
93
  return Type.Object(properties, options);
86
94
  }
87
95
 
88
96
  function arraySchema(node: Json): TSchema {
89
- const items = node.items ? jsonSchemaToTypeBox(Array.isArray(node.items) ? node.items[0] : node.items) : Type.Unknown();
90
97
  const options: Json = {};
91
98
  if (typeof node.minItems === "number") options.minItems = node.minItems;
92
99
  if (typeof node.maxItems === "number") options.maxItems = node.maxItems;
93
100
  if (node.uniqueItems === true) options.uniqueItems = true;
94
- return Type.Array(items, options);
101
+ if (Array.isArray(node.items)) {
102
+ return Type.Tuple(node.items.map(convertJsonSchema), {
103
+ ...options,
104
+ additionalItems: true,
105
+ minItems: options.minItems ?? 0,
106
+ });
107
+ }
108
+ return Type.Array(node.items ? convertJsonSchema(node.items) : Type.Unknown(), options);
95
109
  }
96
110
 
97
111
  function numericAndStringConstraints(node: Json): Json {
98
112
  const out: Json = {};
99
- for (const key of ["minimum", "maximum", "minLength", "maxLength", "pattern", "format"]) {
113
+ for (const key of [
114
+ "minimum",
115
+ "maximum",
116
+ "exclusiveMinimum",
117
+ "exclusiveMaximum",
118
+ "multipleOf",
119
+ "minLength",
120
+ "maxLength",
121
+ ]) {
100
122
  if (node[key] !== undefined) out[key] = node[key];
101
123
  }
102
124
  return out;
@@ -0,0 +1,60 @@
1
+ import * as path from "node:path";
2
+ import { MAX_ACTIVE_WORKFLOWS_PER_SESSION, WorkflowPolicyError } from "./admission.ts";
3
+
4
+ interface ScopeState {
5
+ activeRunIds: Set<string>;
6
+ }
7
+
8
+ const scopes = new Map<string, ScopeState>();
9
+
10
+ export interface WorkflowLease {
11
+ readonly runsDir: string;
12
+ readonly runId: string;
13
+ release(): void;
14
+ }
15
+
16
+ export function acquireWorkflowLease(runsDir: string, runId: string): WorkflowLease {
17
+ const key = normalizeScope(runsDir);
18
+ let state = scopes.get(key);
19
+ if (!state) {
20
+ state = { activeRunIds: new Set() };
21
+ scopes.set(key, state);
22
+ }
23
+ if (state.activeRunIds.has(runId)) {
24
+ throw new WorkflowPolicyError(`workflow runId ${runId} is already active in this session`);
25
+ }
26
+ if (state.activeRunIds.size >= MAX_ACTIVE_WORKFLOWS_PER_SESSION) {
27
+ throw new WorkflowPolicyError(
28
+ `too many active workflows in this session (${state.activeRunIds.size}/${MAX_ACTIVE_WORKFLOWS_PER_SESSION}); wait for one to finish`,
29
+ );
30
+ }
31
+ state.activeRunIds.add(runId);
32
+ let released = false;
33
+ return {
34
+ runsDir: key,
35
+ runId,
36
+ release() {
37
+ if (released) return;
38
+ released = true;
39
+ const current = scopes.get(key);
40
+ current?.activeRunIds.delete(runId);
41
+ if (current && current.activeRunIds.size === 0) scopes.delete(key);
42
+ },
43
+ };
44
+ }
45
+
46
+ export function activeWorkflowCount(runsDir: string): number {
47
+ return scopes.get(normalizeScope(runsDir))?.activeRunIds.size ?? 0;
48
+ }
49
+
50
+ export function clearWorkflowLeasesForTests(): void {
51
+ scopes.clear();
52
+ }
53
+
54
+ function normalizeScope(runsDir: string): string {
55
+ try {
56
+ return path.resolve(runsDir);
57
+ } catch {
58
+ return runsDir;
59
+ }
60
+ }