diff --git a/src/resources/extensions/sf/tests/assertion-coverage.test.mjs b/src/resources/extensions/sf/tests/assertion-coverage.test.mjs new file mode 100644 index 000000000..53d272088 --- /dev/null +++ b/src/resources/extensions/sf/tests/assertion-coverage.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + assessAssertionCoverage, + fulfilledAssertionIdsFromHandoff, + requiredAssertionIdsFromContract, +} from "../uok/assertion-coverage.js"; + +test("requiredAssertionIdsFromContract_when_droid_and_sf_fields_present_returns_unique_required_ids", () => { + assert.deepEqual( + requiredAssertionIdsFromContract({ + assertionIds: ["VAL-001", "VAL-002"], + fulfills: ["VAL-002", "VAL-003"], + validationContract: { + assertions: ["VAL-004"], + fulfills: ["VAL-003"], + }, + unitContract: { + assertionIds: ["VAL-005"], + }, + }), + ["VAL-001", "VAL-002", "VAL-003", "VAL-004", "VAL-005"], + ); +}); + +test("fulfilledAssertionIdsFromHandoff_when_closeout_uses_alias_fields_returns_unique_fulfilled_ids", () => { + assert.deepEqual( + fulfilledAssertionIdsFromHandoff({ + fulfilledAssertions: ["VAL-001", "VAL-002"], + assertionsFulfilled: ["VAL-002", "VAL-003"], + fulfills: ["VAL-004"], + }), + ["VAL-001", "VAL-002", "VAL-003", "VAL-004"], + ); +}); + +test("assessAssertionCoverage_when_handoff_covers_all_required_assertions_returns_ok", () => { + const result = assessAssertionCoverage( + { + fulfills: ["VAL-LOG-001", "VAL-LOG-002"], + }, + { + fulfilledAssertions: ["VAL-LOG-002", "VAL-LOG-001"], + }, + ); + + assert.deepEqual(result, { + ok: true, + required: ["VAL-LOG-001", "VAL-LOG-002"], + fulfilled: ["VAL-LOG-002", "VAL-LOG-001"], + missing: [], + extra: [], + }); +}); + +test("assessAssertionCoverage_when_handoff_omits_required_assertion_reports_missing_and_extra", () => { + const result = assessAssertionCoverage( + { + validationContract: { + assertionIds: ["VAL-A", "VAL-B"], + }, + }, + { + fulfilledAssertions: ["VAL-A", "VAL-C"], + }, + ); + + assert.equal(result.ok, false); + assert.deepEqual(result.required, ["VAL-A", "VAL-B"]); + assert.deepEqual(result.fulfilled, ["VAL-A", "VAL-C"]); + assert.deepEqual(result.missing, ["VAL-B"]); + assert.deepEqual(result.extra, ["VAL-C"]); +}); diff --git a/src/resources/extensions/sf/tests/model-route-snapshot.test.mjs b/src/resources/extensions/sf/tests/model-route-snapshot.test.mjs new file mode 100644 index 000000000..beeb03418 --- /dev/null +++ b/src/resources/extensions/sf/tests/model-route-snapshot.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + buildModelRouteSnapshot, + redactModelConfigSecrets, + sanitizeModelRouteSnapshot, +} from "../uok/model-route-snapshot.js"; + +test("sanitizeModelRouteSnapshot_when_route_has_secret_fields_omits_credentials", () => { + assert.deepEqual( + sanitizeModelRouteSnapshot({ + provider: "zai", + id: "glm-5.1", + displayName: "Z.AI GLM", + api: "openai-chat", + authMode: "apiKey", + baseUrl: "https://api.example.test/v1", + apiKey: "secret-key", + headers: { Authorization: "Bearer secret" }, + noImageSupport: true, + }), + { + provider: "zai", + model: "glm-5.1", + displayName: "Z.AI GLM", + api: "openai-chat", + authMode: "apiKey", + baseUrl: "https://api.example.test/v1", + isCustom: false, + byok: true, + noImageSupport: true, + }, + ); +}); + +test("redactModelConfigSecrets_when_config_contains_credentials_replaces_secret_values", () => { + assert.deepEqual( + redactModelConfigSecrets({ + provider: "kimi-coding", + apiKey: "key-123", + authHeader: "x-api-key", + headers: { Authorization: "Bearer token" }, + baseUrl: "https://api.example.test/v1", + }), + { + provider: "kimi-coding", + apiKey: "[REDACTED]", + authHeader: "[REDACTED]", + headers: "[REDACTED]", + baseUrl: "https://api.example.test/v1", + }, + ); +}); + +test("buildModelRouteSnapshot_when_route_and_fallbacks_present_keeps_policy_auto_and_routes_safe", () => { + const snapshot = buildModelRouteSnapshot({ + role: "worker", + unitType: "execute-task", + unitId: "M001/S01/T01", + route: { + provider: "kimi-coding", + model: "kimi-k2.6", + authMode: "apiKey", + apiKey: "secret", + }, + routingReason: "auto selector", + fallbacksTried: [ + { + provider: "ollama-cloud", + model: "deepseek-v4-pro", + authMode: "apiKey", + apiKey: "secret", + }, + ], + configEvidence: { + apiKey: "secret", + baseUrl: "https://api.example.test/v1", + }, + }); + + assert.equal(snapshot.schemaVersion, 1); + assert.deepEqual(snapshot.requestedPolicy, { mode: "auto", constraints: [] }); + assert.equal(snapshot.route.provider, "kimi-coding"); + assert.equal(snapshot.route.model, "kimi-k2.6"); + assert.equal(snapshot.route.byok, true); + assert.equal(snapshot.route.apiKey, undefined); + assert.equal(snapshot.fallbacksTried[0].provider, "ollama-cloud"); + assert.equal(snapshot.fallbacksTried[0].apiKey, undefined); + assert.equal(snapshot.configEvidence.apiKey, "[REDACTED]"); + assert.equal(snapshot.configEvidence.baseUrl, "https://api.example.test/v1"); +}); diff --git a/src/resources/extensions/sf/tests/progress-event.test.mjs b/src/resources/extensions/sf/tests/progress-event.test.mjs new file mode 100644 index 000000000..3d7f65fbf --- /dev/null +++ b/src/resources/extensions/sf/tests/progress-event.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + buildUokProgressEvent, + UOK_PROGRESS_EVENT_TYPES, + validateUokProgressEvent, +} from "../uok/progress-event.js"; + +test("buildUokProgressEvent_when_unit_selected_returns_stable_machine_event", () => { + assert.deepEqual( + buildUokProgressEvent({ + ts: "2026-05-08T17:00:00.000Z", + eventType: "unit_selected", + unitType: "execute-task", + unitId: "M001/S01/T01", + role: "worker", + sessionId: "session-1", + workerSessionId: "worker-1", + traceId: "trace-1", + data: { source: "dispatch" }, + }), + { + schemaVersion: 1, + ts: "2026-05-08T17:00:00.000Z", + eventType: "unit_selected", + unitType: "execute-task", + unitId: "M001/S01/T01", + role: "worker", + sessionId: "session-1", + workerSessionId: "worker-1", + traceId: "trace-1", + data: { source: "dispatch" }, + }, + ); +}); + +test("validateUokProgressEvent_when_event_type_unknown_reports_allowed_types", () => { + const result = validateUokProgressEvent({ + eventType: "invented_event", + data: {}, + }); + + assert.equal(result.ok, false); + assert.match(result.issues[0], /eventType must be one of/); + assert.match(result.issues[0], /unit_selected/); + assert.match(result.issues[0], /unit_handoff_recorded/); +}); + +test("buildUokProgressEvent_when_payload_data_is_not_object_rejects_event", () => { + assert.throws( + () => + buildUokProgressEvent({ + eventType: "worker_started", + data: "not-object", + }), + /Invalid UOK progress event: data must be an object/, + ); +}); + +test("progressEventTypes_include_droid_value_events_without_concrete_model_config", () => { + assert.ok(UOK_PROGRESS_EVENT_TYPES.includes("model_auto_resolved")); + assert.ok(UOK_PROGRESS_EVENT_TYPES.includes("validation_started")); + assert.ok(UOK_PROGRESS_EVENT_TYPES.includes("unit_handoff_recorded")); + assert.equal( + UOK_PROGRESS_EVENT_TYPES.some((type) => type.includes("custom:")), + false, + ); +}); diff --git a/src/resources/extensions/sf/tests/tool-command-registry.test.mjs b/src/resources/extensions/sf/tests/tool-command-registry.test.mjs new file mode 100644 index 000000000..c7be9c04d --- /dev/null +++ b/src/resources/extensions/sf/tests/tool-command-registry.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + normalizeToolCommandRegistry, + validateToolCommandRegistry, +} from "../uok/tool-command-registry.js"; + +test("normalizeToolCommandRegistry_when_commands_and_daemon_services_present_returns_worker_tool_allowlist", () => { + assert.deepEqual( + normalizeToolCommandRegistry({ + commands: { + test: " npm run test:unit ", + typecheck: "npm run typecheck:extensions", + }, + daemonServices: [" postgres ", "redis", "redis"], + }), + { + schemaVersion: 1, + commands: { + test: "npm run test:unit", + typecheck: "npm run typecheck:extensions", + }, + daemonServices: ["postgres", "redis"], + allowedCommands: ["npm run test:unit", "npm run typecheck:extensions"], + allowedCommandNames: ["test", "typecheck"], + allowedDaemonServices: ["postgres", "redis"], + }, + ); +}); + +test("normalizeToolCommandRegistry_when_fields_missing_returns_empty_allowlists", () => { + assert.deepEqual(normalizeToolCommandRegistry({}), { + schemaVersion: 1, + commands: {}, + daemonServices: [], + allowedCommands: [], + allowedCommandNames: [], + allowedDaemonServices: [], + }); +}); + +test("validateToolCommandRegistry_when_commands_not_object_reports_clear_issue", () => { + const result = validateToolCommandRegistry({ + commands: ["npm test"], + daemonServices: [], + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.issues, ["commands must be an object"]); +}); + +test("validateToolCommandRegistry_when_command_or_daemon_service_empty_reports_paths", () => { + const result = validateToolCommandRegistry({ + commands: { + test: "", + }, + daemonServices: ["postgres", ""], + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.issues, [ + "commands.test must be a non-empty string", + "daemonServices[1] must be a non-empty string", + ]); +}); + +test("normalizeToolCommandRegistry_when_droid_services_field_present_treats_it_as_legacy_daemon_services", () => { + assert.deepEqual( + normalizeToolCommandRegistry({ + commands: { + build: "npm run build:core", + }, + services: ["postgres"], + }), + { + schemaVersion: 1, + commands: { + build: "npm run build:core", + }, + daemonServices: ["postgres"], + allowedCommands: ["npm run build:core"], + allowedCommandNames: ["build"], + allowedDaemonServices: ["postgres"], + }, + ); +}); diff --git a/src/resources/extensions/sf/tests/unit-lineage.test.mjs b/src/resources/extensions/sf/tests/unit-lineage.test.mjs new file mode 100644 index 000000000..8cbbe5790 --- /dev/null +++ b/src/resources/extensions/sf/tests/unit-lineage.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + normalizeUnitLineage, + recordUnitLineageEvent, +} from "../uok/unit-lineage.js"; + +test("normalizeUnitLineage_when_worker_ids_repeat_returns_deduplicated_lineage", () => { + assert.deepEqual( + normalizeUnitLineage({ + unitType: "execute-task", + unitId: "M001/S01/T01", + workerSessionIds: ["worker-1", "worker-1"], + currentWorkerSessionId: "worker-2", + completedWorkerSessionId: "worker-3", + failedWorkerSessionIds: ["worker-4", "worker-4"], + status: "started", + }), + { + unitType: "execute-task", + unitId: "M001/S01/T01", + status: "started", + workerSessionIds: ["worker-1", "worker-2", "worker-3", "worker-4"], + currentWorkerSessionId: "worker-2", + completedWorkerSessionId: "worker-3", + failedWorkerSessionIds: ["worker-4"], + events: [], + }, + ); +}); + +test("recordUnitLineageEvent_when_worker_starts_marks_current_session", () => { + const lineage = recordUnitLineageEvent( + { unitType: "execute-task", unitId: "M001/S01/T01" }, + { + ts: "2026-05-08T17:00:00.000Z", + status: "started", + workerSessionId: "worker-1", + spawnId: "spawn-1", + }, + ); + + assert.equal(lineage.status, "started"); + assert.deepEqual(lineage.workerSessionIds, ["worker-1"]); + assert.equal(lineage.currentWorkerSessionId, "worker-1"); + assert.equal(lineage.events[0].spawnId, "spawn-1"); +}); + +test("recordUnitLineageEvent_when_worker_completes_moves_current_to_completed", () => { + const started = recordUnitLineageEvent( + {}, + { + status: "started", + workerSessionId: "worker-1", + }, + ); + const completed = recordUnitLineageEvent(started, { + status: "completed", + workerSessionId: "worker-1", + }); + + assert.equal(completed.status, "completed"); + assert.equal(completed.currentWorkerSessionId, null); + assert.equal(completed.completedWorkerSessionId, "worker-1"); + assert.deepEqual(completed.failedWorkerSessionIds, []); +}); + +test("recordUnitLineageEvent_when_worker_fails_tracks_failed_session", () => { + const lineage = recordUnitLineageEvent( + { + workerSessionIds: ["worker-1"], + currentWorkerSessionId: "worker-1", + }, + { + status: "failed", + workerSessionId: "worker-1", + note: "provider timeout", + }, + ); + + assert.equal(lineage.status, "failed"); + assert.equal(lineage.currentWorkerSessionId, null); + assert.deepEqual(lineage.failedWorkerSessionIds, ["worker-1"]); + assert.equal(lineage.events[0].note, "provider timeout"); +}); + +test("recordUnitLineageEvent_when_unit_blocks_clears_current_worker", () => { + const lineage = recordUnitLineageEvent( + { + unitType: "execute-task", + unitId: "M001/S01/T01", + workerSessionIds: ["worker-1"], + currentWorkerSessionId: "worker-1", + }, + { + status: "blocked", + workerSessionId: "worker-1", + note: "artifact verification failed", + }, + ); + + assert.equal(lineage.status, "blocked"); + assert.equal(lineage.currentWorkerSessionId, null); + assert.deepEqual(lineage.workerSessionIds, ["worker-1"]); +}); diff --git a/src/resources/extensions/sf/uok/assertion-coverage.js b/src/resources/extensions/sf/uok/assertion-coverage.js new file mode 100644 index 000000000..df646999c --- /dev/null +++ b/src/resources/extensions/sf/uok/assertion-coverage.js @@ -0,0 +1,103 @@ +/** + * assertion-coverage.js - validation assertion coverage helpers. + * + * Purpose: preserve Droid's useful `fulfills: ["VAL-*"]` accounting as + * structured SF data so units can prove which validation claims they satisfy. + * + * Consumer: autonomous unit contract projections, handoff validation, and + * future closeout gates before marking a unit complete. + */ + +function stringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function stringList(value) { + if (Array.isArray(value)) { + return value + .map((item) => stringOrNull(item)) + .filter((item) => item !== null); + } + const single = stringOrNull(value); + return single === null ? [] : [single]; +} + +function uniqueIds(...values) { + return [...new Set(values.flatMap((value) => stringList(value)))]; +} + +function objectOrEmpty(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; +} + +/** + * Normalize assertion IDs required by a unit contract. + * + * Purpose: let SF accept Droid-style `fulfills` as well as SF-style + * `assertionIds` while projecting one canonical required assertion list. + * + * Consumer: assertion coverage checks and worker context-pack builders. + */ +export function requiredAssertionIdsFromContract(contract = {}) { + const source = objectOrEmpty(contract); + const validationContract = objectOrEmpty(source.validationContract); + const unitContract = objectOrEmpty(source.unitContract); + return uniqueIds( + source.assertions, + source.assertionIds, + source.fulfills, + source.fulfillmentIds, + validationContract.assertions, + validationContract.assertionIds, + validationContract.fulfills, + unitContract.assertions, + unitContract.assertionIds, + unitContract.fulfills, + ); +} + +/** + * Normalize assertion IDs fulfilled by a unit handoff. + * + * Purpose: give closeout gates one canonical fulfilled assertion list even + * when agents use Droid-style or SF-style field names. + * + * Consumer: assertion coverage checks and autonomous closeout validation. + */ +export function fulfilledAssertionIdsFromHandoff(handoff = {}) { + const source = objectOrEmpty(handoff); + return uniqueIds( + source.fulfilledAssertions, + source.assertionsFulfilled, + source.fulfills, + source.assertionIds, + ); +} + +/** + * Compare a unit contract with a worker handoff and report assertion coverage. + * + * Purpose: prevent units from being treated as complete when their structured + * handoff did not cover every validation claim the unit contract required. + * + * Consumer: future autonomous closeout gates and tests that need deterministic + * validation coverage evidence without parsing prose. + */ +export function assessAssertionCoverage(contract = {}, handoff = {}) { + const required = requiredAssertionIdsFromContract(contract); + const fulfilled = fulfilledAssertionIdsFromHandoff(handoff); + const fulfilledSet = new Set(fulfilled); + const missing = required.filter((id) => !fulfilledSet.has(id)); + const extra = fulfilled.filter((id) => !required.includes(id)); + return { + ok: missing.length === 0, + required, + fulfilled, + missing, + extra, + }; +} diff --git a/src/resources/extensions/sf/uok/model-route-snapshot.js b/src/resources/extensions/sf/uok/model-route-snapshot.js new file mode 100644 index 000000000..ff816ac58 --- /dev/null +++ b/src/resources/extensions/sf/uok/model-route-snapshot.js @@ -0,0 +1,97 @@ +/** + * model-route-snapshot.js - secret-safe model route snapshots. + * + * Purpose: keep Droid's useful runtime model reproducibility evidence while + * guaranteeing API keys, tokens, and headers are never persisted in SF state. + * + * Consumer: UOK audit/progress projections and future run-state snapshots that + * need provider/model evidence without copying credentials. + */ + +const SECRET_KEYS = new Set([ + "apiKey", + "api_key", + "authorization", + "authHeader", + "headers", + "token", + "secret", + "password", +]); + +function stringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function maybeRedacted(value, key) { + if (SECRET_KEYS.has(key)) return "[REDACTED]"; + return value; +} + +/** + * Build a secret-safe snapshot of one resolved model route. + * + * Purpose: record what SF actually routed to while keeping durable evidence + * separate from BYOK credentials and provider secrets. + * + * Consumer: model route evidence emitters and run-state projection writers. + */ +export function sanitizeModelRouteSnapshot(route = {}) { + const source = route && typeof route === "object" ? route : {}; + return { + provider: stringOrNull(source.provider), + model: stringOrNull(source.id ?? source.model ?? source.modelId), + displayName: stringOrNull(source.displayName), + api: stringOrNull(source.api), + authMode: stringOrNull(source.authMode), + baseUrl: stringOrNull(source.baseUrl), + isCustom: Boolean(source.isCustom ?? source.custom), + byok: source.authMode === "apiKey" || Boolean(source.byok), + noImageSupport: Boolean(source.noImageSupport), + }; +} + +/** + * Redact a provider/model configuration object while preserving non-secret + * route metadata. + * + * Purpose: make tests and projections fail closed if a caller accidentally + * passes Droid-style custom model config containing raw credentials. + * + * Consumer: snapshot writers before serializing model/provider config evidence. + */ +export function redactModelConfigSecrets(config = {}) { + if (!config || typeof config !== "object" || Array.isArray(config)) return {}; + const redacted = {}; + for (const [key, value] of Object.entries(config)) { + redacted[key] = maybeRedacted(value, key); + } + return redacted; +} + +/** + * Build a secret-safe snapshot for a route plus optional routing evidence. + * + * Purpose: capture enough model evidence to debug auto routing decisions + * without making concrete model IDs durable policy or leaking BYOK secrets. + * + * Consumer: UOK model routing events and future run-state projections. + */ +export function buildModelRouteSnapshot(args = {}) { + const route = sanitizeModelRouteSnapshot(args.route ?? args.model ?? {}); + return { + schemaVersion: 1, + role: stringOrNull(args.role), + unitType: stringOrNull(args.unitType), + unitId: stringOrNull(args.unitId), + requestedPolicy: args.requestedPolicy ?? { mode: "auto", constraints: [] }, + route, + routingReason: stringOrNull(args.routingReason), + fallbacksTried: Array.isArray(args.fallbacksTried) + ? args.fallbacksTried.map(sanitizeModelRouteSnapshot) + : [], + configEvidence: redactModelConfigSecrets(args.configEvidence ?? {}), + }; +} diff --git a/src/resources/extensions/sf/uok/progress-event.js b/src/resources/extensions/sf/uok/progress-event.js new file mode 100644 index 000000000..7c891a3f4 --- /dev/null +++ b/src/resources/extensions/sf/uok/progress-event.js @@ -0,0 +1,109 @@ +/** + * progress-event.js - typed autonomous progress event helpers. + * + * Purpose: keep Droid's useful machine-readable progress vocabulary while + * preserving SF's existing journal/audit systems as the transport. + * + * Consumer: autonomous loop projections, dashboards, and future machine + * surfaces that need stable progress event names instead of parsing prose. + */ + +export const UOK_PROGRESS_EVENT_TYPES = Object.freeze([ + "unit_selected", + "model_auto_resolved", + "worker_started", + "validation_started", + "unit_handoff_recorded", + "unit_blocked", + "unit_completed", +]); + +const EVENT_TYPES = new Set(UOK_PROGRESS_EVENT_TYPES); + +function stringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function objectOrEmpty(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; +} + +/** + * Validate a typed UOK progress event. + * + * Purpose: reject unstable ad hoc event names before they become dashboard or + * machine-surface contracts. + * + * Consumer: buildUokProgressEvent and tests that need non-throwing diagnostics. + */ +export function validateUokProgressEvent(event) { + const issues = []; + if (!event || typeof event !== "object" || Array.isArray(event)) { + return { ok: false, issues: ["event must be an object"] }; + } + if (!EVENT_TYPES.has(event.eventType)) { + issues.push( + `eventType must be one of ${UOK_PROGRESS_EVENT_TYPES.join(", ")}`, + ); + } + if ( + "unitId" in event && + event.unitId !== null && + typeof event.unitId !== "string" + ) { + issues.push("unitId must be a string or null"); + } + if ( + "unitType" in event && + event.unitType !== null && + typeof event.unitType !== "string" + ) { + issues.push("unitType must be a string or null"); + } + if ( + "data" in event && + (typeof event.data !== "object" || + event.data === null || + Array.isArray(event.data)) + ) { + issues.push("data must be an object"); + } + return { ok: issues.length === 0, issues }; +} + +/** + * Build a typed UOK progress event with canonical timestamp and payload shape. + * + * Purpose: give SF the useful Droid-style progress stream shape without + * coupling event creation to a specific file, DB, or transport. + * + * Consumer: future autonomous journal/audit adapters and focused tests. + */ +export function buildUokProgressEvent(args = {}) { + const event = { + schemaVersion: 1, + ts: args.ts ?? new Date().toISOString(), + eventType: args.eventType, + unitType: stringOrNull(args.unitType), + unitId: stringOrNull(args.unitId), + role: stringOrNull(args.role), + sessionId: stringOrNull(args.sessionId), + workerSessionId: stringOrNull(args.workerSessionId), + traceId: stringOrNull(args.traceId), + data: args.data === undefined ? {} : args.data, + }; + const validation = validateUokProgressEvent(event); + if (!validation.ok) { + throw new TypeError( + `Invalid UOK progress event: ${validation.issues.join("; ")}`, + ); + } + return { + ...event, + data: objectOrEmpty(event.data), + }; +} diff --git a/src/resources/extensions/sf/uok/tool-command-registry.js b/src/resources/extensions/sf/uok/tool-command-registry.js new file mode 100644 index 000000000..79d43bd85 --- /dev/null +++ b/src/resources/extensions/sf/uok/tool-command-registry.js @@ -0,0 +1,117 @@ +/** + * tool-command-registry.js - worker tool/command registry helpers. + * + * Purpose: preserve Droid's useful command boundary as structured SF data so + * workers receive explicit repo tools/commands instead of free-form prose. + * + * Consumer: worker context pack projections and future dispatch prompts that + * need a tool-command allowlist without treating ad hoc mission files as + * canonical. + */ + +function stringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function objectOrEmpty(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; +} + +function normalizeStringList(value) { + if (Array.isArray(value)) { + return [ + ...new Set(value.map(stringOrNull).filter((item) => item !== null)), + ]; + } + const single = stringOrNull(value); + return single === null ? [] : [single]; +} + +function normalizeCommandMap(commands) { + const source = objectOrEmpty(commands); + const normalized = {}; + for (const [name, command] of Object.entries(source)) { + const normalizedName = stringOrNull(name); + const normalizedCommand = stringOrNull(command); + if (normalizedName && normalizedCommand) { + normalized[normalizedName] = normalizedCommand; + } + } + return normalized; +} + +/** + * Normalize an SF worker tool/command registry. + * + * Purpose: project Droid-style command boundaries into a predictable + * tool-first shape while leaving durable source-of-truth ownership to SF + * DB/runtime state. + * + * Consumer: context-pack builders and prompt adapters before rendering worker + * instructions. + */ +export function normalizeToolCommandRegistry(registry = {}) { + const source = objectOrEmpty(registry); + const commands = normalizeCommandMap(source.commands); + const daemonServices = normalizeStringList( + source.daemonServices ?? source.services, + ); + return { + schemaVersion: 1, + commands, + daemonServices, + allowedCommands: Object.values(commands), + allowedCommandNames: Object.keys(commands), + allowedDaemonServices: daemonServices, + }; +} + +/** + * Validate a worker tool/command registry. + * + * Purpose: reject malformed command boundaries before workers treat them as + * executable instructions. + * + * Consumer: context-pack projection validation and tests. + */ +export function validateToolCommandRegistry(registry) { + const issues = []; + if (!registry || typeof registry !== "object" || Array.isArray(registry)) { + return { ok: false, issues: ["registry must be an object"] }; + } + if ("commands" in registry) { + if ( + !registry.commands || + typeof registry.commands !== "object" || + Array.isArray(registry.commands) + ) { + issues.push("commands must be an object"); + } else { + for (const [name, command] of Object.entries(registry.commands)) { + if (!stringOrNull(name)) issues.push("command name must be non-empty"); + if (!stringOrNull(command)) { + issues.push(`commands.${name} must be a non-empty string`); + } + } + } + } + const servicesValue = registry.daemonServices ?? registry.services; + if (servicesValue !== undefined && !Array.isArray(servicesValue)) { + issues.push("daemonServices must be an array"); + } + if (Array.isArray(servicesValue)) { + servicesValue.forEach((service, index) => { + if (!stringOrNull(service)) { + issues.push(`daemonServices[${index}] must be a non-empty string`); + } + }); + } + return { ok: issues.length === 0, issues }; +} + +export const normalizeCommandRegistry = normalizeToolCommandRegistry; +export const validateCommandRegistry = validateToolCommandRegistry; diff --git a/src/resources/extensions/sf/uok/unit-lineage.js b/src/resources/extensions/sf/uok/unit-lineage.js new file mode 100644 index 000000000..0b45a214e --- /dev/null +++ b/src/resources/extensions/sf/uok/unit-lineage.js @@ -0,0 +1,122 @@ +/** + * unit-lineage.js - autonomous unit worker/session lineage helpers. + * + * Purpose: preserve Droid's useful worker-session accounting so SF can explain + * which sessions claimed, ran, completed, or failed a unit. + * + * Consumer: autonomous dispatch projections, progress events, and future + * recovery screens that need lineage without parsing logs. + */ + +const LINEAGE_STATUSES = new Set([ + "selected", + "started", + "completed", + "failed", + "cancelled", + "blocked", +]); + +function stringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function uniqueStringList(value) { + const values = Array.isArray(value) ? value : []; + return [...new Set(values.map(stringOrNull).filter((item) => item !== null))]; +} + +/** + * Normalize a unit lineage record. + * + * Purpose: keep worker-session lineage deterministic and deduplicated before + * it is projected into runtime state or machine progress events. + * + * Consumer: recordUnitLineageEvent and future dispatch projection writers. + */ +export function normalizeUnitLineage(record = {}) { + const workerSessionIds = uniqueStringList(record.workerSessionIds); + const currentWorkerSessionId = stringOrNull(record.currentWorkerSessionId); + const completedWorkerSessionId = stringOrNull( + record.completedWorkerSessionId, + ); + const failedWorkerSessionIds = uniqueStringList( + record.failedWorkerSessionIds, + ); + if ( + currentWorkerSessionId && + !workerSessionIds.includes(currentWorkerSessionId) + ) { + workerSessionIds.push(currentWorkerSessionId); + } + if ( + completedWorkerSessionId && + !workerSessionIds.includes(completedWorkerSessionId) + ) { + workerSessionIds.push(completedWorkerSessionId); + } + for (const id of failedWorkerSessionIds) { + if (!workerSessionIds.includes(id)) workerSessionIds.push(id); + } + return { + unitType: stringOrNull(record.unitType), + unitId: stringOrNull(record.unitId), + status: LINEAGE_STATUSES.has(record.status) ? record.status : "selected", + workerSessionIds, + currentWorkerSessionId, + completedWorkerSessionId, + failedWorkerSessionIds, + events: Array.isArray(record.events) ? record.events : [], + }; +} + +/** + * Append one worker/session lineage event to a unit lineage record. + * + * Purpose: model Droid-style worker lifecycle accounting as structured state + * while leaving persistence and transport to SF's existing DB/journal layers. + * + * Consumer: future autonomous dispatch hooks and tests. + */ +export function recordUnitLineageEvent(record = {}, event = {}) { + const current = normalizeUnitLineage(record); + const status = LINEAGE_STATUSES.has(event.status) ? event.status : "selected"; + const workerSessionId = stringOrNull(event.workerSessionId); + const next = { + ...current, + unitType: stringOrNull(event.unitType) ?? current.unitType, + unitId: stringOrNull(event.unitId) ?? current.unitId, + status, + events: [ + ...current.events, + { + ts: event.ts ?? new Date().toISOString(), + status, + workerSessionId, + spawnId: stringOrNull(event.spawnId), + note: stringOrNull(event.note), + }, + ], + }; + if (workerSessionId && !next.workerSessionIds.includes(workerSessionId)) { + next.workerSessionIds.push(workerSessionId); + } + if (status === "started") next.currentWorkerSessionId = workerSessionId; + if (status === "completed") { + next.completedWorkerSessionId = workerSessionId; + next.currentWorkerSessionId = null; + } + if (status === "failed" && workerSessionId) { + next.failedWorkerSessionIds = uniqueStringList([ + ...next.failedWorkerSessionIds, + workerSessionId, + ]); + next.currentWorkerSessionId = null; + } + if (status === "blocked" || status === "cancelled") { + next.currentWorkerSessionId = null; + } + return normalizeUnitLineage(next); +}