@llblab/pi-actors 0.43.1 → 0.45.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.
Files changed (79) hide show
  1. package/AGENTS.md +6 -6
  2. package/BACKLOG.md +1 -189
  3. package/CHANGELOG.md +419 -402
  4. package/README.md +8 -6
  5. package/dist/index.js +7 -3
  6. package/dist/lib/async-runs.d.ts +2 -0
  7. package/dist/lib/async-runs.js +41 -34
  8. package/dist/lib/config.js +1 -0
  9. package/dist/lib/file-state.d.ts +3 -1
  10. package/dist/lib/file-state.js +25 -7
  11. package/dist/lib/inspector-overlay.js +46 -30
  12. package/dist/lib/inspector.js +8 -2
  13. package/dist/lib/limits.d.ts +10 -0
  14. package/dist/lib/limits.js +10 -0
  15. package/dist/lib/observability.d.ts +4 -2
  16. package/dist/lib/observability.js +43 -36
  17. package/dist/lib/recipes-discovery.js +20 -3
  18. package/dist/lib/recipes-references.d.ts +10 -0
  19. package/dist/lib/recipes-references.js +168 -5
  20. package/dist/lib/run-evidence-policy.d.ts +95 -0
  21. package/dist/lib/run-evidence-policy.js +177 -0
  22. package/dist/lib/run-ui-runtime.js +2 -0
  23. package/dist/lib/runs-controls.d.ts +7 -5
  24. package/dist/lib/runs-controls.js +186 -49
  25. package/dist/lib/runs-retention.js +27 -14
  26. package/dist/lib/runs-trace.d.ts +25 -1
  27. package/dist/lib/runs-trace.js +410 -21
  28. package/dist/lib/runtime-triage.js +6 -22
  29. package/dist/lib/schema.d.ts +3 -0
  30. package/dist/lib/schema.js +34 -1
  31. package/dist/lib/tools-inspect.js +58 -14
  32. package/dist/lib/tools-local.js +27 -10
  33. package/dist/lib/tools-spawn.js +2 -2
  34. package/dist/lib/trace-projection.js +90 -44
  35. package/dist/scripts/conformance.mjs +5 -0
  36. package/dist/scripts/locker.mjs +31 -74
  37. package/dist/scripts/music-player.mjs +41 -129
  38. package/dist/scripts/release-gates.mjs +26 -4
  39. package/dist/skills/actors/SKILL.md +9 -8
  40. package/dist/skills/swarm/SKILL.md +2 -2
  41. package/docs/README.md +0 -1
  42. package/docs/actor-inspector.md +3 -2
  43. package/docs/async-runs.md +10 -8
  44. package/docs/command-templates.md +2 -2
  45. package/docs/recipe-library.md +4 -2
  46. package/docs/template-recipes.md +10 -4
  47. package/docs/tool-registry.md +1 -1
  48. package/index.ts +9 -3
  49. package/lib/async-runs.ts +51 -54
  50. package/lib/config.ts +4 -0
  51. package/lib/file-state.ts +28 -7
  52. package/lib/inspector-overlay.ts +29 -17
  53. package/lib/inspector.ts +16 -2
  54. package/lib/limits.ts +10 -0
  55. package/lib/observability.ts +55 -57
  56. package/lib/recipes-discovery.ts +23 -3
  57. package/lib/recipes-references.ts +227 -7
  58. package/lib/run-evidence-policy.ts +242 -0
  59. package/lib/run-ui-runtime.ts +2 -0
  60. package/lib/runs-controls.ts +177 -108
  61. package/lib/runs-retention.ts +28 -20
  62. package/lib/runs-trace.ts +496 -21
  63. package/lib/runtime-triage.ts +11 -25
  64. package/lib/schema.ts +53 -1
  65. package/lib/tools-inspect.ts +56 -15
  66. package/lib/tools-local.ts +49 -19
  67. package/lib/tools-spawn.ts +2 -2
  68. package/lib/trace-projection.ts +137 -75
  69. package/package.json +1 -1
  70. package/scripts/conformance.mjs +5 -0
  71. package/scripts/locker.mjs +31 -74
  72. package/scripts/music-player.mjs +41 -129
  73. package/scripts/release-gates.mjs +26 -4
  74. package/skills/actors/SKILL.md +9 -8
  75. package/skills/swarm/SKILL.md +2 -2
  76. package/dist/lib/runtime-notifier.d.ts +0 -48
  77. package/dist/lib/runtime-notifier.js +0 -138
  78. package/docs/0.43-baseline.md +0 -39
  79. package/lib/runtime-notifier.ts +0 -211
@@ -122,6 +122,25 @@ function getRecipeCommandTemplateConfig(
122
122
  : config) as CommandTemplates.CommandTemplateConfig;
123
123
  }
124
124
 
125
+ function getRecipeDiagnosticTemplateConfig(
126
+ config: TemplateRecipeConfig,
127
+ ): CommandTemplates.CommandTemplateObjectConfig {
128
+ const templateConfig = getRecipeCommandTemplateConfig(config);
129
+ return typeof templateConfig === "object" && !Array.isArray(templateConfig)
130
+ ? {
131
+ ...templateConfig,
132
+ defaults: {
133
+ ...(templateConfig.defaults ?? {}),
134
+ ...(config.defaults ?? {}),
135
+ ...(config.values ?? {}),
136
+ },
137
+ }
138
+ : {
139
+ defaults: { ...(config.defaults ?? {}), ...(config.values ?? {}) },
140
+ template: templateConfig,
141
+ };
142
+ }
143
+
125
144
  function getRecipeConfigDiagnostics(
126
145
  file: string,
127
146
  config: TemplateRecipeConfig | undefined,
@@ -131,7 +150,7 @@ function getRecipeConfigDiagnostics(
131
150
  return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
132
151
  }
133
152
  return CommandTemplates.getCommandTemplateWarnings(
134
- getRecipeCommandTemplateConfig(config),
153
+ getRecipeDiagnosticTemplateConfig(config),
135
154
  ).map((warning) => `Recipe ${file}: ${warning}`);
136
155
  }
137
156
 
@@ -141,7 +160,7 @@ function getRecipeRiskLabels(
141
160
  if (!config) return [];
142
161
  const labels = new Set(
143
162
  CommandTemplates.getCommandTemplateRiskLabels(
144
- getRecipeCommandTemplateConfig(config),
163
+ getRecipeDiagnosticTemplateConfig(config),
145
164
  ),
146
165
  );
147
166
  if (config.async === true) labels.add("risk.long_running");
@@ -846,9 +865,10 @@ export function toRegisteredTool(
846
865
  assertToolSafeRepeatConfig(
847
866
  argTemplateConfig,
848
867
  explicitArgTypes,
849
- cfg.defaults ?? {},
868
+ { ...(cfg.defaults ?? {}), ...(cfg.values ?? {}) },
850
869
  );
851
870
  const argTypes = Schema.getTemplateArgTypes(argTemplateConfig);
871
+ Schema.assertCompatibleToolArgTypes(explicitArgTypes, argTypes);
852
872
  return {
853
873
  name: entry.id,
854
874
  description,
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { basename, dirname, extname, resolve } from "node:path";
9
+ import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
10
10
 
11
11
  import type {
12
12
  CommandTemplateConfig,
@@ -15,6 +15,7 @@ import type {
15
15
  import * as CommandTemplates from "./command-templates.ts";
16
16
  import * as Paths from "./paths.ts";
17
17
  import * as RecipeControl from "./recipe-control.ts";
18
+ import * as Schema from "./schema.ts";
18
19
 
19
20
  const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
20
21
  const MAX_RECIPE_IMPORT_DEPTH = 32;
@@ -57,6 +58,8 @@ export interface TemplateRecipeDefinition {
57
58
 
58
59
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
59
60
  async?: boolean;
61
+ recipe_dir?: string;
62
+ skill_dir?: string;
60
63
  }
61
64
 
62
65
  interface ImportedRecipe {
@@ -74,6 +77,7 @@ export interface TemplateRecipeContextRecord {
74
77
  file: string;
75
78
  import_path: string[];
76
79
  name: string;
80
+ qualified_name?: string;
77
81
  recipe: Record<string, unknown>;
78
82
  role: "entry" | "import";
79
83
  }
@@ -82,10 +86,120 @@ export interface ReadResolvedRecipeConfigOptions {
82
86
  includeActorRecipeContext?: boolean;
83
87
  }
84
88
 
89
+ const RUNTIME_RECIPE_VALUE_NAMES = new Set(["recipe_dir", "skill_dir"]);
90
+ const activeSkillRecipeRoots = new Map<string, Set<string>>();
91
+
92
+ export interface ActiveSkillRecipeSource {
93
+ name: string;
94
+ filePath?: string;
95
+ baseDir?: string;
96
+ }
97
+
98
+ export function setActiveSkillRecipeSources(
99
+ skills: ActiveSkillRecipeSource[],
100
+ ): void {
101
+ activeSkillRecipeRoots.clear();
102
+ for (const skill of skills) {
103
+ const name = skill.name.trim();
104
+ const skillDir = skill.baseDir
105
+ ? resolve(skill.baseDir)
106
+ : skill.filePath
107
+ ? dirname(resolve(skill.filePath))
108
+ : undefined;
109
+ if (!name || !skillDir) continue;
110
+ const roots = activeSkillRecipeRoots.get(name) ?? new Set<string>();
111
+ roots.add(resolve(skillDir, "recipes"));
112
+ activeSkillRecipeRoots.set(name, roots);
113
+ }
114
+ }
115
+
116
+ export function getActiveSkillRecipeNamespaces(): Record<string, string[]> {
117
+ return Object.fromEntries(
118
+ [...activeSkillRecipeRoots.entries()]
119
+ .sort(([left], [right]) => left.localeCompare(right))
120
+ .map(([name, roots]) => [name, [...roots].sort()]),
121
+ );
122
+ }
123
+
124
+ function qualifiedRecipeNameForFile(file: string): string | undefined {
125
+ const path = resolve(file);
126
+ const stdRelation = relative(Paths.getPackagedRecipeRoot(), path);
127
+ if (
128
+ stdRelation &&
129
+ !stdRelation.startsWith("..") &&
130
+ !isAbsolute(stdRelation)
131
+ ) {
132
+ return `std:${stdRelation.replace(/\.(?:json|md)$/u, "")}`;
133
+ }
134
+ for (const [skillName, roots] of activeSkillRecipeRoots) {
135
+ if (roots.size !== 1) continue;
136
+ const root = [...roots][0];
137
+ const relation = relative(root, path);
138
+ if (relation && !relation.startsWith("..") && !isAbsolute(relation))
139
+ return `skill:${skillName}/${relation.replaceAll("\\", "/").replace(/\.(?:json|md)$/u, "")}`;
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ function qualifiedRecipeCandidates(value: string): string[] | undefined {
145
+ if (value.startsWith("std:")) {
146
+ return recipeNameFiles(value.slice(4)).map((file) =>
147
+ resolve(Paths.getPackagedRecipeRoot(), file),
148
+ );
149
+ }
150
+ if (!value.startsWith("skill:")) return undefined;
151
+ const qualified = value.slice(6);
152
+ const separator = qualified.indexOf("/");
153
+ if (separator <= 0 || separator === qualified.length - 1)
154
+ throw new Error(`Invalid Skill Recipe reference: ${value}`);
155
+ const skillName = qualified.slice(0, separator);
156
+ const recipeName = qualified.slice(separator + 1);
157
+ const roots = [...(activeSkillRecipeRoots.get(skillName) ?? [])];
158
+ if (roots.length === 0)
159
+ throw new Error(`Active Skill Recipe namespace not found: ${skillName}`);
160
+ if (roots.length > 1)
161
+ throw new Error(
162
+ `Ambiguous active Skill Recipe namespace ${skillName}: ${roots.join(", ")}`,
163
+ );
164
+ const root = roots[0];
165
+ return recipeNameFiles(recipeName).map((file) => {
166
+ const candidate = resolve(root, file);
167
+ const relation = relative(root, candidate);
168
+ if (!relation || relation.startsWith("..") || isAbsolute(relation))
169
+ throw new Error(`Skill Recipe reference escapes its recipes root: ${value}`);
170
+ return candidate;
171
+ });
172
+ }
173
+
85
174
  function hasWhitespace(value: string): boolean {
86
175
  return /\s/.test(value);
87
176
  }
88
177
 
178
+ function findOwningSkillDir(recipeFile: string): string | undefined {
179
+ let current = dirname(recipeFile);
180
+ while (true) {
181
+ if (existsSync(resolve(current, "SKILL.md"))) return current;
182
+ const parent = dirname(current);
183
+ if (parent === current) return undefined;
184
+ current = parent;
185
+ }
186
+ }
187
+
188
+ function assertRuntimeRecipeValuesNotDeclared(raw: Record<string, unknown>): void {
189
+ const args = Array.isArray(raw.args)
190
+ ? Schema.getExplicitToolArgNames(raw.args.map(String))
191
+ : [];
192
+ for (const name of RUNTIME_RECIPE_VALUE_NAMES) {
193
+ if (
194
+ args.includes(name) ||
195
+ (isRecord(raw.defaults) && Object.hasOwn(raw.defaults, name)) ||
196
+ (isRecord(raw.values) && Object.hasOwn(raw.values, name))
197
+ ) {
198
+ throw new Error(`Recipe runtime value is reserved: ${name}`);
199
+ }
200
+ }
201
+ }
202
+
89
203
  export function resolveRecipePath(
90
204
  value: string,
91
205
  recipeRoot = Paths.getRecipeRoot(),
@@ -125,6 +239,8 @@ function recipeCandidatePaths(
125
239
  value: string,
126
240
  currentRecipeRoot: string,
127
241
  ): string[] {
242
+ const qualified = qualifiedRecipeCandidates(value.trim());
243
+ if (qualified) return qualified;
128
244
  if (!isBareRecipeName(value))
129
245
  return [resolveRecipePath(value, currentRecipeRoot)];
130
246
  const roots = [
@@ -646,6 +762,63 @@ function mergeDefaults(
646
762
  return Object.keys(merged).length > 0 ? merged : undefined;
647
763
  }
648
764
 
765
+ function assertRecipeDefaultsDeclared(
766
+ args: string[] | undefined,
767
+ defaults: Record<string, unknown> | undefined,
768
+ label: string,
769
+ ): void {
770
+ const spec = Schema.parseToolArgDeclarationList(args ?? []);
771
+ if (spec.error) throw new Error(spec.error);
772
+ if (spec.args.length === 0) return;
773
+ const declared = new Set(spec.args);
774
+ for (const key of Object.keys(defaults ?? {})) {
775
+ if (!declared.has(key)) throw new Error(`Unknown ${label} argument: ${key}`);
776
+ }
777
+ }
778
+
779
+ function assertRecipeArgumentContract(
780
+ config: Pick<
781
+ TemplateRecipeDefinition,
782
+ "args" | "defaults" | "values" | "template"
783
+ >,
784
+ label = "Recipe default",
785
+ ): void {
786
+ assertRecipeDefaultsDeclared(config.args, config.defaults, label);
787
+ const spec = Schema.parseToolArgDeclarationList(config.args ?? []);
788
+ if (spec.error) throw new Error(spec.error);
789
+ Schema.assertCompatibleToolArgTypes(
790
+ spec.argTypes,
791
+ Schema.getTemplateArgTypes({ template: config.template }),
792
+ );
793
+ const staticValues = Object.fromEntries(
794
+ Object.entries({ ...config.defaults, ...config.values }).filter(
795
+ ([, value]) =>
796
+ typeof value !== "string" || !/\{[^{}]+\}/.test(value),
797
+ ),
798
+ );
799
+ Schema.normalizeRuntimeValues(staticValues, spec.argTypes);
800
+ }
801
+
802
+ function resolveRecipeValueLayers(
803
+ args: string[] | undefined,
804
+ ...layers: Array<Record<string, unknown> | undefined>
805
+ ): Record<string, unknown> | undefined {
806
+ const spec = Schema.parseToolArgDeclarationList(args ?? []);
807
+ if (spec.error) throw new Error(spec.error);
808
+ const merged = mergeDefaults(spec.defaults, ...layers);
809
+ if (!merged) return undefined;
810
+ const staticValues = Object.fromEntries(
811
+ Object.entries(merged).filter(
812
+ ([, value]) =>
813
+ typeof value !== "string" || !/\{[^{}]+\}/.test(value),
814
+ ),
815
+ );
816
+ return {
817
+ ...merged,
818
+ ...Schema.normalizeRuntimeValues(staticValues, spec.argTypes),
819
+ };
820
+ }
821
+
649
822
  function applyDefaultsToTemplate(
650
823
  template: CommandTemplateValue,
651
824
  defaults: Record<string, unknown> | undefined,
@@ -655,6 +828,7 @@ function applyDefaultsToTemplate(
655
828
  delete cleanOverrides.name;
656
829
  delete cleanOverrides.template;
657
830
  delete cleanOverrides.values;
831
+ delete cleanOverrides.defaults;
658
832
  if (typeof template === "object" && !Array.isArray(template)) {
659
833
  return {
660
834
  ...template,
@@ -716,11 +890,22 @@ function applyDelegatedRecipeToNode(
716
890
  delegated: TemplateRecipeConfig,
717
891
  overrides: Record<string, unknown> = {},
718
892
  ): CommandTemplateValue {
719
- return applyDefaultsToTemplate(
720
- delegated.template,
893
+ const nodeDefaults = isRecord(overrides.defaults)
894
+ ? overrides.defaults
895
+ : undefined;
896
+ const nodeValues = isRecord(overrides.values) ? overrides.values : undefined;
897
+ const values = resolveRecipeValueLayers(
898
+ delegated.args,
899
+ delegated.defaults,
721
900
  delegated.values,
722
- overrides,
901
+ nodeDefaults,
902
+ nodeValues,
903
+ {
904
+ ...(delegated.recipe_dir ? { recipe_dir: delegated.recipe_dir } : {}),
905
+ ...(delegated.skill_dir ? { skill_dir: delegated.skill_dir } : {}),
906
+ },
723
907
  );
908
+ return applyDefaultsToTemplate(delegated.template, values, overrides);
724
909
  }
725
910
 
726
911
  function expandRecipeDelegations(
@@ -830,7 +1015,8 @@ function expandImportNodes(
830
1015
  ? record.defaults
831
1016
  : undefined;
832
1017
  const nodeValues = isRecord(record.values) ? record.values : undefined;
833
- const defaults = mergeDefaults(
1018
+ const defaults = resolveRecipeValueLayers(
1019
+ imported.config.args,
834
1020
  imported.defaults,
835
1021
  imported.values,
836
1022
  nodeDefaults,
@@ -897,6 +1083,8 @@ export function readResolvedRecipeConfig(
897
1083
  const raw = readRawRecipeConfig(path);
898
1084
  if (!raw || !Object.hasOwn(raw, "template")) return undefined;
899
1085
  RecipeControl.assertRecipeHasNoMailbox(raw);
1086
+ assertRuntimeRecipeValuesNotDeclared(raw);
1087
+ assertRecipeArgumentContract(raw as unknown as TemplateRecipeDefinition);
900
1088
  const imports: Record<string, ImportedRecipe> = {};
901
1089
  for (const [alias, binding] of Object.entries(getRecipeImports(raw))) {
902
1090
  const importPath = resolveRecipeImportPath(
@@ -913,13 +1101,22 @@ export function readResolvedRecipeConfig(
913
1101
  typeof binding === "string" ? undefined : binding.defaults;
914
1102
  const bindingValues =
915
1103
  typeof binding === "string" ? undefined : binding.values;
1104
+ assertRecipeDefaultsDeclared(
1105
+ config.args,
1106
+ bindingDefaults,
1107
+ `Recipe import ${alias} default`,
1108
+ );
916
1109
  imports[alias] = {
917
1110
  alias,
918
1111
  file: importPath,
919
1112
  name: config.name ?? alias,
920
1113
  config,
921
1114
  defaults: { ...(config.defaults ?? {}), ...(bindingDefaults ?? {}) },
922
- values: { ...(bindingValues ?? {}) },
1115
+ values: {
1116
+ ...(bindingValues ?? {}),
1117
+ ...(config.recipe_dir ? { recipe_dir: config.recipe_dir } : {}),
1118
+ ...(config.skill_dir ? { skill_dir: config.skill_dir } : {}),
1119
+ },
923
1120
  };
924
1121
  }
925
1122
  const substituted = substituteImportRefs(raw, imports) as Record<
@@ -945,6 +1142,21 @@ export function readResolvedRecipeConfig(
945
1142
  )
946
1143
  : expandRecipeDelegations(expandedImportsTemplate, path, stack, options);
947
1144
  const recipeName = getRecipeIdFromPath(path);
1145
+ const recipeDir = dirname(path);
1146
+ const skillDir = findOwningSkillDir(path);
1147
+ if (
1148
+ !skillDir &&
1149
+ JSON.stringify({
1150
+ artifacts: substituted.artifacts,
1151
+ defaults: substituted.defaults,
1152
+ template: substituted.template,
1153
+ values: substituted.values,
1154
+ }).includes("{skill_dir}")
1155
+ ) {
1156
+ throw new Error(
1157
+ `Recipe uses {skill_dir} outside an owning Skill directory: ${path}`,
1158
+ );
1159
+ }
948
1160
  const templateWithContext = options.includeActorRecipeContext
949
1161
  ? withActorRecipeContext(expandedTemplate, {
950
1162
  file: path,
@@ -967,6 +1179,8 @@ export function readResolvedRecipeConfig(
967
1179
  );
968
1180
  return {
969
1181
  name: recipeName,
1182
+ recipe_dir: recipeDir,
1183
+ ...(skillDir ? { skill_dir: skillDir } : {}),
970
1184
  ...(typeof substituted.description === "string" &&
971
1185
  substituted.description.trim()
972
1186
  ? { description: substituted.description.trim() }
@@ -1059,7 +1273,11 @@ export function readResolvedRecipeConfig(
1059
1273
  ...(typeof substituted.repeat === "number"
1060
1274
  ? { repeat: substituted.repeat }
1061
1275
  : {}),
1062
- ...(isRecord(substituted.values) ? { values: substituted.values } : {}),
1276
+ values: {
1277
+ ...(isRecord(substituted.values) ? substituted.values : {}),
1278
+ recipe_dir: recipeDir,
1279
+ ...(skillDir ? { skill_dir: skillDir } : {}),
1280
+ },
1063
1281
  ...(isRecord(substituted.usage) ? { usage: substituted.usage } : {}),
1064
1282
  };
1065
1283
  }
@@ -1084,12 +1302,14 @@ function collectRecipeContextRecords(
1084
1302
  }
1085
1303
  const raw = readRawRecipeConfig(path);
1086
1304
  if (!raw || !Object.hasOwn(raw, "template")) return [];
1305
+ const qualifiedName = qualifiedRecipeNameForFile(path);
1087
1306
  const record: TemplateRecipeContextRecord = {
1088
1307
  ...(alias ? { alias } : {}),
1089
1308
  depth: stack.length,
1090
1309
  file: path,
1091
1310
  import_path: importPath,
1092
1311
  name: getRecipeIdFromPath(path),
1312
+ ...(qualifiedName ? { qualified_name: qualifiedName } : {}),
1093
1313
  recipe: raw,
1094
1314
  role: stack.length === 0 ? "entry" : "import",
1095
1315
  };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Pure bounded Run-evidence policy.
3
+ * Zones: Trace retention, compaction accounting, Control capacity and admission
4
+ * Owns deterministic limits policy; journal I/O, locks, lifecycle authorization, and projection stay in adapters.
5
+ * Trace keeps the newest physical suffix because semantic priority would silently
6
+ * redefine causal history. Admitted Controls never expire: only a real terminal
7
+ * transition or generation replacement proves that durable work is finished.
8
+ */
9
+
10
+ import * as Limits from "./limits.ts";
11
+
12
+ export const TRACE_COMPACTION_KIND = "runtime.trace_compacted";
13
+ export const TRACE_COMPACTION_VERSION = 1;
14
+ export type TraceJournalUsage = { bytes: number; events: number };
15
+ export type TraceRetentionCandidate<T> = { encodedBytes: number; value: T };
16
+ export type TraceRetentionSelection<T> = {
17
+ droppedEvents: number; retained: TraceRetentionCandidate<T>[];
18
+ retainedBytes: number; retainedEvents: number;
19
+ };
20
+ export interface TraceCompactionData {
21
+ version: typeof TRACE_COMPACTION_VERSION;
22
+ compactions_total: number;
23
+ dropped_valid_events_total: number;
24
+ dropped_malformed_lines_total: number;
25
+ dropped_bytes_total: number;
26
+ dropped_event_count_exact: boolean;
27
+ retained_events: number;
28
+ retained_bytes: number;
29
+ history_complete: false;
30
+ }
31
+ export interface TraceCompactionDelta {
32
+ dropped_valid_events: number;
33
+ dropped_malformed_lines: number;
34
+ dropped_bytes: number;
35
+ dropped_event_count_exact: boolean;
36
+ retained_events: number;
37
+ retained_bytes: number;
38
+ }
39
+ export type RunControlStatus = "queued" | "delivered" | "claimed" | "handled" | "failed";
40
+ export type RunControlCapacity = {
41
+ available: number; backpressured: boolean; limit: number;
42
+ oldest_pending_at?: string; pending: number;
43
+ };
44
+ export type RunControlAdmissionIntegrity =
45
+ | "valid" | "unreadable" | "malformed" | "noncanonical"
46
+ | "invalid_record" | "invalid_status_timestamp" | "generation_mismatch"
47
+ | "journal_bytes";
48
+ export interface RunControlAdmissionErrorDetails {
49
+ reason: "control_backpressure" | "control_journal_integrity";
50
+ pending: number;
51
+ limit: number;
52
+ journal_bytes: number;
53
+ journal_limit: number;
54
+ oldest_pending_at?: string;
55
+ run_instance_id: string;
56
+ next_actions: string[];
57
+ integrity_reason?: string;
58
+ }
59
+ export type RunControlAdmissionDecision =
60
+ { admitted: true; capacity: RunControlCapacity } |
61
+ { admitted: false; capacity: RunControlCapacity; error: RunControlAdmissionErrorDetails };
62
+ export type RunControlAdmissionInput = {
63
+ integrity: RunControlAdmissionIntegrity; journalBytes: number;
64
+ newRecordBytes: number; records: readonly unknown[];
65
+ retainedJournalBytes: number; runInstanceId: string;
66
+ capacity?: RunControlCapacity;
67
+ };
68
+
69
+ const PENDING_CONTROL_STATUSES = new Set<RunControlStatus>(["queued", "delivered", "claimed"]);
70
+ const TERMINAL_CONTROL_STATUSES = new Set<RunControlStatus>(["handled", "failed"]);
71
+ const CONTROL_ADMISSION_NEXT_ACTIONS = [
72
+ "inspect target=run:<id> view=control",
73
+ "inspect target=runtime view=triage",
74
+ ];
75
+
76
+ function isCount(value: number): boolean {
77
+ return Number.isSafeInteger(value) && value >= 0;
78
+ }
79
+ function assertCount(value: number, name: string): void {
80
+ if (!isCount(value)) throw new RangeError(`${name} must be a non-negative safe integer`);
81
+ }
82
+ function boundedAdd(left: number, right: number): number {
83
+ return Math.min(Number.MAX_SAFE_INTEGER, left + right);
84
+ }
85
+ export function traceAppendFits(usage: TraceJournalUsage, encodedEventBytes: number): boolean {
86
+ if (!isCount(usage.events) || !isCount(usage.bytes) || !isCount(encodedEventBytes)) return false;
87
+ return usage.events + 1 <= Limits.TRACE_JOURNAL_MAX_EVENTS &&
88
+ usage.bytes + encodedEventBytes <= Limits.TRACE_JOURNAL_MAX_BYTES;
89
+ }
90
+
91
+ export function selectNewestTraceSuffix<T>(
92
+ candidates: readonly TraceRetentionCandidate<T>[],
93
+ reserve: { bytes?: number; events?: number } = {},
94
+ ): TraceRetentionSelection<T> {
95
+ const reservedBytes = reserve.bytes ?? 0;
96
+ const reservedEvents = reserve.events ?? 0;
97
+ assertCount(reservedBytes, "reserved Trace bytes");
98
+ assertCount(reservedEvents, "reserved Trace events");
99
+ const byteBudget = Math.max(0, Limits.TRACE_JOURNAL_TARGET_BYTES - reservedBytes);
100
+ const eventBudget = Math.max(0, Limits.TRACE_JOURNAL_TARGET_EVENTS - reservedEvents);
101
+ let retainedBytes = 0;
102
+ let start = candidates.length;
103
+ while (start > 0 && candidates.length - start < eventBudget) {
104
+ const candidate = candidates[start - 1]!;
105
+ assertCount(candidate.encodedBytes, "encoded Trace bytes");
106
+ if (retainedBytes + candidate.encodedBytes > byteBudget) break;
107
+ retainedBytes += candidate.encodedBytes;
108
+ start -= 1;
109
+ }
110
+ const retained = candidates.slice(start);
111
+ return {
112
+ droppedEvents: candidates.length - retained.length,
113
+ retained,
114
+ retainedBytes,
115
+ retainedEvents: retained.length,
116
+ };
117
+ }
118
+
119
+ export function accumulateTraceCompactionStatistics(
120
+ previous: TraceCompactionData | undefined,
121
+ delta: TraceCompactionDelta,
122
+ ): TraceCompactionData {
123
+ for (const [name, value] of Object.entries(delta)) {
124
+ if (name !== "dropped_event_count_exact") assertCount(value as number, name);
125
+ }
126
+ const prior = previous ?? {
127
+ compactions_total: 0,
128
+ dropped_bytes_total: 0,
129
+ dropped_event_count_exact: true,
130
+ dropped_malformed_lines_total: 0,
131
+ dropped_valid_events_total: 0,
132
+ };
133
+ const eventCountOverflow = prior.dropped_valid_events_total +
134
+ delta.dropped_valid_events > Number.MAX_SAFE_INTEGER;
135
+ return {
136
+ version: TRACE_COMPACTION_VERSION,
137
+ compactions_total: boundedAdd(prior.compactions_total, 1),
138
+ dropped_valid_events_total: boundedAdd(prior.dropped_valid_events_total, delta.dropped_valid_events),
139
+ dropped_malformed_lines_total: boundedAdd(prior.dropped_malformed_lines_total, delta.dropped_malformed_lines),
140
+ dropped_bytes_total: boundedAdd(prior.dropped_bytes_total, delta.dropped_bytes),
141
+ dropped_event_count_exact: prior.dropped_event_count_exact &&
142
+ delta.dropped_event_count_exact && !eventCountOverflow,
143
+ retained_events: delta.retained_events,
144
+ retained_bytes: delta.retained_bytes,
145
+ history_complete: false,
146
+ };
147
+ }
148
+
149
+ export function traceCompactionMarkerInput(data: TraceCompactionData): {
150
+ data: TraceCompactionData; kind: typeof TRACE_COMPACTION_KIND; level: "warning";
151
+ } {
152
+ return { data, kind: TRACE_COMPACTION_KIND, level: "warning" };
153
+ }
154
+
155
+ export function classifyRunControlRecord(value: unknown): "pending" | "terminal" | undefined {
156
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
157
+ const status = (value as { status?: unknown }).status as RunControlStatus;
158
+ if (PENDING_CONTROL_STATUSES.has(status)) return "pending";
159
+ if (TERMINAL_CONTROL_STATUSES.has(status)) return "terminal";
160
+ return undefined;
161
+ }
162
+
163
+ export function runControlStatusTimestamp(
164
+ record: Record<string, unknown>, status: RunControlStatus,
165
+ ): unknown {
166
+ if (status === "failed") return record.failed_at;
167
+ if (status === "handled") return record.handled_at;
168
+ if (status === "claimed") return record.claimed_at;
169
+ if (status === "delivered") return record.delivered_at;
170
+ return record.queued_at;
171
+ }
172
+
173
+ export function computeRunControlCapacity(records: readonly unknown[]): RunControlCapacity {
174
+ let oldest: { timestamp: string; timestampMs: number } | undefined;
175
+ let pending = 0;
176
+ for (const value of records) {
177
+ if (classifyRunControlRecord(value) !== "pending") continue;
178
+ pending += 1;
179
+ const record = value as Record<string, unknown>;
180
+ const timestamp = runControlStatusTimestamp(record, record.status as RunControlStatus);
181
+ const timestampMs = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN;
182
+ if (typeof timestamp === "string" && Number.isFinite(timestampMs) &&
183
+ (!oldest || timestampMs < oldest.timestampMs)) {
184
+ oldest = { timestamp, timestampMs };
185
+ }
186
+ }
187
+ return {
188
+ available: Math.max(0, Limits.RUN_CONTROL_PENDING_LIMIT - pending),
189
+ backpressured: pending >= Limits.RUN_CONTROL_PENDING_LIMIT,
190
+ limit: Limits.RUN_CONTROL_PENDING_LIMIT,
191
+ ...(oldest ? { oldest_pending_at: oldest.timestamp } : {}),
192
+ pending,
193
+ };
194
+ }
195
+
196
+ export function decideRunControlAdmission(
197
+ input: RunControlAdmissionInput,
198
+ ): RunControlAdmissionDecision {
199
+ const capacity = input.capacity ?? computeRunControlCapacity(input.records);
200
+ const reject = (
201
+ reason: RunControlAdmissionErrorDetails["reason"],
202
+ integrityReason?: string,
203
+ ): RunControlAdmissionDecision => ({
204
+ admitted: false,
205
+ capacity,
206
+ error: {
207
+ reason,
208
+ pending: capacity.pending,
209
+ limit: capacity.limit,
210
+ journal_bytes: input.journalBytes,
211
+ journal_limit: Limits.RUN_CONTROL_JOURNAL_MAX_BYTES,
212
+ ...(capacity.oldest_pending_at ? { oldest_pending_at: capacity.oldest_pending_at } : {}),
213
+ run_instance_id: input.runInstanceId,
214
+ next_actions: [...CONTROL_ADMISSION_NEXT_ACTIONS],
215
+ ...(integrityReason ? { integrity_reason: integrityReason } : {}),
216
+ },
217
+ });
218
+ if (input.integrity !== "valid") return reject("control_journal_integrity", input.integrity);
219
+ if (!isCount(input.journalBytes) || !isCount(input.retainedJournalBytes) ||
220
+ !isCount(input.newRecordBytes) ||
221
+ input.journalBytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES) {
222
+ return reject("control_journal_integrity", "journal_bytes");
223
+ }
224
+ for (const value of input.records) {
225
+ const classification = classifyRunControlRecord(value);
226
+ if (!classification) return reject("control_journal_integrity", "invalid_record");
227
+ if (classification !== "pending") continue;
228
+ const record = value as Record<string, unknown>;
229
+ const timestamp = runControlStatusTimestamp(record, record.status as RunControlStatus);
230
+ if (typeof timestamp !== "string" || !Number.isFinite(Date.parse(timestamp))) {
231
+ return reject("control_journal_integrity", "invalid_status_timestamp");
232
+ }
233
+ if (record.run_instance_id !== input.runInstanceId) {
234
+ return reject("control_journal_integrity", "generation_mismatch");
235
+ }
236
+ }
237
+ if (capacity.backpressured ||
238
+ input.retainedJournalBytes + input.newRecordBytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES) {
239
+ return reject("control_backpressure");
240
+ }
241
+ return { admitted: true, capacity };
242
+ }
@@ -106,6 +106,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
106
106
  ownerId: deps.getRunOwnerId(ctx),
107
107
  sink: Pi.createNotificationSink(deps.pi, ctx),
108
108
  state: observation,
109
+ includeAttention: true,
109
110
  });
110
111
  reportDiagnostics(ctx);
111
112
  },
@@ -141,6 +142,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
141
142
  },
142
143
  start(ctx) {
143
144
  close();
145
+ Observability.primeRunAttentionState(observation, deps.getRunOwnerId(ctx));
144
146
  update(ctx, true, true);
145
147
  watcher.refresh();
146
148
  reconciliation.start();