@awak-app/simy-cli 0.7.50 → 0.7.52
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/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +14 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +23 -1
- package/src/codex-plugins/simy/hooks/loop-stop-record.mjs +66 -0
- package/src/delivery-hooks/supervisor.js +9 -0
- package/src/desktop-host.js +3 -1
- package/src/session-supervision/action-association-host.js +4 -2
- package/src/session-supervision/association-store.js +41 -10
- package/src/sqm/claude-plugin.js +7 -2
- package/src/sqm/command.js +14 -1
- package/src/sqm/index.js +3 -3
- package/src/sqm/loop-conversation.js +54 -0
- package/src/sqm/loop-outcome-command.js +74 -0
- package/src/sqm/loop-outcome-outbox.js +221 -0
- package/src/sqm/loop-outcome.js +141 -0
- package/src/sqm/proof.js +5 -2
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
|
|
2
2
|
import { flushObservability, observeHeartbeat, syncObservabilityConsent } from './cli-observability.js';
|
|
3
|
+
import { syncLoopOutcomes } from './sqm/loop-outcome-outbox.js';
|
|
3
4
|
import { readLocalPhotoSource } from "./local-photos.js";
|
|
4
5
|
import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
5
6
|
import { observePairedNativeIdentities } from './native-provider-identity.js';
|
|
@@ -964,6 +965,14 @@ export async function startAgent({
|
|
|
964
965
|
apiBaseUrl: requestedSession.api_base_url,
|
|
965
966
|
token: requestedSession.token,
|
|
966
967
|
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
968
|
+
})).catch(() => {}).then(() => syncLoopOutcomes({
|
|
969
|
+
getSession: () => session,
|
|
970
|
+
// Content-free Delivery Loop outcomes; queued on failure, never blocks the heartbeat.
|
|
971
|
+
root: sessionRoot,
|
|
972
|
+
apiOrigin,
|
|
973
|
+
apiBaseUrl: requestedSession.api_base_url,
|
|
974
|
+
token: requestedSession.token,
|
|
975
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
967
976
|
})).catch(() => {});
|
|
968
977
|
}
|
|
969
978
|
// Register the device immediately; provider subprocesses must not delay
|
package/src/cli-contract.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./durable-local-task-steps-contract.js";
|
|
15
15
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
16
16
|
|
|
17
|
-
export const CLI_VERSION = "0.7.
|
|
17
|
+
export const CLI_VERSION = "0.7.52";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
package/src/cli-observability.js
CHANGED
|
@@ -104,6 +104,20 @@ async function readStore(root) {
|
|
|
104
104
|
async function saveStore(root, state) { await saveJson(file(root, STORE), state); }
|
|
105
105
|
function status(state) { return { enabled: state.config.enabled === true, conversations: Object.values(state.config.conversations).map(({ provider, session, sequence }) => ({ provider, session, sequence })), queued: state.outbox.events.length }; }
|
|
106
106
|
export async function readObservability(root) { return (await readStore(root)).config; }
|
|
107
|
+
// Bound only when a NEW Loop run is persisted. A later sweep must never assign
|
|
108
|
+
// the currently logged-in organization to historical conversations.
|
|
109
|
+
export async function captureLoopOutcomeScope(root) {
|
|
110
|
+
const c = await readObservability(root), a = c.actor;
|
|
111
|
+
if (c.enabled !== true || !validGeneration(c.consentGeneration) || !environmentFromOrigin(a?.origin) ||
|
|
112
|
+
![a?.accountId, a?.organizationId, a?.deviceId].every(v => typeof v === 'string' && v.length > 0)) return null;
|
|
113
|
+
const { readSession, isSessionValid } = await import('./session-store.js');
|
|
114
|
+
const session = await readSession(a.origin, root);
|
|
115
|
+
if (!isSessionValid(session, Date.now(), a.origin) || session.account_id !== a.accountId ||
|
|
116
|
+
session.organization_id !== a.organizationId || session.device_id !== a.deviceId) return null;
|
|
117
|
+
return { origin: a.origin, accountId: a.accountId, organizationId: a.organizationId,
|
|
118
|
+
deviceId: a.deviceId, consentGeneration: c.consentGeneration };
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
export async function observabilityStatus(root) { return status(await readStore(root)); }
|
|
108
122
|
function stateFromRecord(record, now = Date.now()) {
|
|
109
123
|
const raw = record?.run?.state;
|
|
@@ -5,6 +5,7 @@ import { completionContractIdentity } from './purpose-completion-contract.mjs';
|
|
|
5
5
|
import { captureDispatchProvenance } from './update-dispatch-identity.mjs';
|
|
6
6
|
import { MAX_STATE_BYTES } from './state-limits.mjs';
|
|
7
7
|
import { continueResultCycle, resultEvent, resultFollowThroughInstruction, correctiveFollowupInstruction } from './result-cycle.mjs';
|
|
8
|
+
import { loopStopBefore, recordLoopStop } from './loop-stop-record.mjs';
|
|
8
9
|
import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from './recovery-incident.mjs';
|
|
9
10
|
import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
|
|
10
11
|
import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
|
|
@@ -36,7 +37,7 @@ import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
|
|
|
36
37
|
import { continuationMode } from './continuation-policy.mjs';
|
|
37
38
|
import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
|
|
38
39
|
import { collectMeasurementTransition, retainMeasurementHookInstruction } from './measurement-collector.mjs';
|
|
39
|
-
import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled } from './measurement-store.mjs';
|
|
40
|
+
import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled, measurementPolicyPath } from './measurement-store.mjs';
|
|
40
41
|
const measurementEnabled = async (...args) => {
|
|
41
42
|
try { const binding = await measurementBinding(...args); return Boolean(binding && measurementCollectionEnabled(...args)); }
|
|
42
43
|
catch { console.error('Adoption measurement configuration is unreadable; collection is incomplete.'); return false; }
|
|
@@ -292,11 +293,27 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
292
293
|
const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
|
|
293
294
|
const previousBytes = stored === null ? null : JSON.stringify(stored);
|
|
294
295
|
const measurementPriorRunId = previous?.run?.id;
|
|
296
|
+
const priorOutcomeScope = previous?.run?.outcomeScope ? structuredClone(previous.run.outcomeScope) : null;
|
|
295
297
|
let priorMeasurement, savedMeasurement;
|
|
296
298
|
try { savedMeasurement = await measurementBinding(root, provider, session); priorMeasurement = savedMeasurement && measurementCollectionEnabled(root, provider, session) ? savedMeasurement : null; } catch { /* Collection never blocks task state. */ }
|
|
297
299
|
const measurementPrevious = priorMeasurement ? structuredClone(previous) : null;
|
|
298
300
|
const next = await mutate(previous);
|
|
299
301
|
if (next) {
|
|
302
|
+
if (next.run?.id && next.run.id !== measurementPriorRunId) {
|
|
303
|
+
// Fixed at enrollment, under the same record lock; missing provenance
|
|
304
|
+
// remains null forever for this run, including explicit backfill.
|
|
305
|
+
const scope = await import('../../../cli-observability.js')
|
|
306
|
+
.then(({ captureLoopOutcomeScope }) => captureLoopOutcomeScope(dirname(root))).catch(() => null);
|
|
307
|
+
let policy;
|
|
308
|
+
try { policy = readState(measurementPolicyPath(root, provider, next.cwd)); } catch { /* Invalid optional consent never blocks task persistence. */ }
|
|
309
|
+
const enrolled = policy?.enabled === true && policy.source === 'field' && policy.loop === 'on' &&
|
|
310
|
+
policy.scope?.origin === scope?.origin && policy.scope?.account === scope?.accountId &&
|
|
311
|
+
policy.scope?.organization === scope?.organizationId && policy.scope?.device === scope?.deviceId;
|
|
312
|
+
if (scope && enrolled) next.run.outcomeScope = { ...scope, policyKey: basename(measurementPolicyPath(root, provider, next.cwd), '.json'),
|
|
313
|
+
policyDigest: createHash('sha256').update(JSON.stringify(policy)).digest('hex') }; else delete next.run.outcomeScope;
|
|
314
|
+
} else if (next.run) {
|
|
315
|
+
if (priorOutcomeScope) next.run.outcomeScope = priorOutcomeScope; else delete next.run.outcomeScope;
|
|
316
|
+
}
|
|
300
317
|
// Older file_equals drafts used `path`, which an older verifier could
|
|
301
318
|
// mistake for file_exists. Persist only the distinct exactPath field.
|
|
302
319
|
for (const criterion of next.run?.managed?.acceptanceCriteria || []) {
|
|
@@ -2991,10 +3008,12 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2991
3008
|
run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
|
|
2992
3009
|
}
|
|
2993
3010
|
} else if (event === 'StopFailure') {
|
|
3011
|
+
const stopBefore = loopStopBefore(run);
|
|
2994
3012
|
run.failures += 1;
|
|
2995
3013
|
run.state = failureLimitReached(run) ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
|
|
2996
3014
|
run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
|
|
2997
3015
|
run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
|
|
3016
|
+
recordLoopStop(run, { before: stopBefore, failed: true, source: input.turn_id ? `failure:${input.turn_id}` : null, now });
|
|
2998
3017
|
} else if (event === 'Stop' && !suppressDispatch) {
|
|
2999
3018
|
// OFF suppresses future Stop blocking; it never interrupts the provider
|
|
3000
3019
|
// turn. Lease settlement and runtime observation above still run.
|
|
@@ -3008,6 +3027,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
3008
3027
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
3009
3028
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
3010
3029
|
if (key && run.lastStopKey === key) return record;
|
|
3030
|
+
const stopBefore = loopStopBefore(run);
|
|
3011
3031
|
if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
|
|
3012
3032
|
await verifyAutomaticCriteria(record, now);
|
|
3013
3033
|
const pending = pendingAcceptance(run);
|
|
@@ -3024,6 +3044,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
3024
3044
|
run.managed.remainingWork = run.nextStep;
|
|
3025
3045
|
} else if (pending.length) {
|
|
3026
3046
|
run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
|
|
3047
|
+
recordLoopStop(run, { before: stopBefore, source: key, now });
|
|
3027
3048
|
run.lastStopKey = key; notifyState(run, now); return record;
|
|
3028
3049
|
}
|
|
3029
3050
|
}
|
|
@@ -3074,6 +3095,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
3074
3095
|
}
|
|
3075
3096
|
decision.run.resultCycle = run.resultCycle;
|
|
3076
3097
|
record.run = decision.run; output = decision.output;
|
|
3098
|
+
recordLoopStop(record.run, { before: stopBefore, source: key, now });
|
|
3077
3099
|
if (record.runtime && output.decision === 'block') record.runtime.isRunning = true;
|
|
3078
3100
|
record.run.lastStopKey = key;
|
|
3079
3101
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// One bounded, content-free entry per stop the Delivery Loop observes. It is
|
|
4
|
+
// measurement only: it never decides continuation, completion or authority.
|
|
5
|
+
// The kind reads the Loop's own records (fresh checkpoint, wait state, budget
|
|
6
|
+
// transition), never the provider's prose; there is no STATUS: line in a
|
|
7
|
+
// normal conversation.
|
|
8
|
+
export const LOOP_STOP_KINDS = Object.freeze(['claimed_done', 'plan_only', 'question', 'error', 'limit']);
|
|
9
|
+
export const MAX_LOOP_STOPS = 200;
|
|
10
|
+
|
|
11
|
+
const STOP_LIKE = new Set(['stop', 'final_response', 'failure']);
|
|
12
|
+
|
|
13
|
+
// Snapshot the run BEFORE the Loop decides on this stop. A checkpoint counts
|
|
14
|
+
// only when the provider submitted it during the turn that just ended.
|
|
15
|
+
export function loopStopBefore(run, { checkpointFresh = Boolean(run?.checkpoint && run.revision > run.consumedRevision) } = {}) {
|
|
16
|
+
if (!run) return null;
|
|
17
|
+
const ledger = run.resultCycle;
|
|
18
|
+
return {
|
|
19
|
+
state: run.state,
|
|
20
|
+
checkpoint: checkpointFresh && run.checkpoint ? {
|
|
21
|
+
status: run.checkpoint.status, reportedStatus: run.checkpoint.reportedStatus, acceptanceMet: run.checkpoint.acceptanceMet,
|
|
22
|
+
} : null,
|
|
23
|
+
// A run whose earlier stops were observed before this recorder existed
|
|
24
|
+
// must not present its next stop as stop #1.
|
|
25
|
+
priorStops: Boolean(run.lastStopKey || run.continuations > 0 || run.managed?.attempts?.length ||
|
|
26
|
+
(ledger?.events || []).some(row => STOP_LIKE.has(row.kind)) || (ledger?.stops?.entries || []).length),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The Loop's own review: every current acceptance criterion verified. No
|
|
31
|
+
// criteria means it has not judged the stop (null), not that it passed.
|
|
32
|
+
export function loopCriteriaMet(run) {
|
|
33
|
+
const criteria = run?.managed?.acceptanceCriteria;
|
|
34
|
+
if (!Array.isArray(criteria) || !criteria.length) return null;
|
|
35
|
+
const verified = run.managed.verified || {};
|
|
36
|
+
return criteria.every(item => Object.hasOwn(verified, item?.id));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classifyLoopStop({ before, after, failed = false, question = false }) {
|
|
40
|
+
if (after?.state === 'budget_exhausted' || before?.state === 'active' && after?.state === 'paused') return 'limit';
|
|
41
|
+
if (failed) return 'error';
|
|
42
|
+
const checkpoint = before?.checkpoint;
|
|
43
|
+
if (checkpoint?.status === 'error') return 'error';
|
|
44
|
+
if (checkpoint?.status === 'completed' || checkpoint?.reportedStatus === 'completed' || checkpoint?.acceptanceMet === true) return 'claimed_done';
|
|
45
|
+
if (question || checkpoint?.status === 'blocked' || after?.managed?.userQuestion || after?.managed?.plan?.userQuestion ||
|
|
46
|
+
after?.providerRequest?.kind === 'question') return 'question';
|
|
47
|
+
return 'plan_only';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Append one stop to run.resultCycle.stops. `source` (turn id, attempt id)
|
|
51
|
+
// coalesces a re-delivered observation; it is stored only as a short hash.
|
|
52
|
+
export function recordLoopStop(run, { before, failed = false, question = false, source = null, now = Date.now() }) {
|
|
53
|
+
if (!run || !before) return null;
|
|
54
|
+
const cycle = run.resultCycle ||= { version: 1, events: [], omitted: 0 };
|
|
55
|
+
if (cycle.version !== 1) return null;
|
|
56
|
+
const stops = cycle.stops ||= { version: 1, complete: !before.priorStops, entries: [], omitted: 0 };
|
|
57
|
+
if (stops.version !== 1 || !Array.isArray(stops.entries)) return null;
|
|
58
|
+
const ref = source ? createHash('sha256').update(String(source)).digest('hex').slice(0, 16) : null;
|
|
59
|
+
if (ref && stops.entries.some(entry => entry.ref === ref)) return null;
|
|
60
|
+
const index = stops.entries.length + stops.omitted + 1;
|
|
61
|
+
if (stops.entries.length >= MAX_LOOP_STOPS) { stops.omitted++; return null; }
|
|
62
|
+
const entry = { index, kind: classifyLoopStop({ before, after: run, failed, question }),
|
|
63
|
+
criteria_met: loopCriteriaMet(run), at: new Date(now).toISOString(), ...(ref ? { ref } : {}) };
|
|
64
|
+
stops.entries.push(entry);
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
@@ -5,6 +5,7 @@ import { bindCompletionArtifacts } from '../session-supervision/completion-artif
|
|
|
5
5
|
import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/recovery-frontier.js';
|
|
6
6
|
import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
|
|
7
7
|
import { continueResultCycle, resultEvent, executeResultCycle, acceptResultEvent } from '../codex-plugins/simy/hooks/result-cycle.mjs';
|
|
8
|
+
import { loopStopBefore, recordLoopStop } from '../codex-plugins/simy/hooks/loop-stop-record.mjs';
|
|
8
9
|
import { finalResponseRecoveryInstruction } from '../session-supervision/final-response-recovery.js';
|
|
9
10
|
import { measurementBinding, measurementCollectionEnabled } from '../codex-plugins/simy/hooks/measurement-store.mjs';
|
|
10
11
|
import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
|
|
@@ -596,6 +597,12 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
596
597
|
if (live.state !== 'auto_resuming') {
|
|
597
598
|
live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
|
|
598
599
|
}
|
|
600
|
+
// Measurement only: one content-free stop entry per dispatched turn.
|
|
601
|
+
const stopBefore = !result.busy && dispatched ? loopStopBefore({ ...claimed.run, state: 'active',
|
|
602
|
+
checkpoint: live.checkpoint, revision: live.revision, consumedRevision: claimed.run.revision }) : null;
|
|
603
|
+
const recordStop = target => recordLoopStop(target, { before: stopBefore, source: `attempt:${leaseId}`,
|
|
604
|
+
question: ['provider_question', 'provider_approval'].includes(result.errorCode),
|
|
605
|
+
failed: (result.code !== 0 || result.cancelled) && !['provider_question', 'provider_approval'].includes(result.errorCode) });
|
|
599
606
|
if (result.busy) {
|
|
600
607
|
if (result.accessDenied && dispatched) {
|
|
601
608
|
// The final guard rejected before the provider call. Keep its one-shot approval unused.
|
|
@@ -656,6 +663,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
656
663
|
const pending = live.managed.acceptanceCriteria.filter(item => !Object.hasOwn(live.managed.verified, item.id));
|
|
657
664
|
if (pending.length) {
|
|
658
665
|
live.state = 'verification_pending'; live.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`;
|
|
666
|
+
recordStop(live);
|
|
659
667
|
live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
|
|
660
668
|
}
|
|
661
669
|
}
|
|
@@ -669,6 +677,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
669
677
|
if (result.code !== 0) await continueResultCycle(current, resultEvent(current, {
|
|
670
678
|
kind: 'failure', origin: 'executor_result', sourceId: result.providerTurnId || leaseId,
|
|
671
679
|
attemptId: leaseId, resultRef: `attempt:${leaseId}` }));
|
|
680
|
+
recordStop(current.run);
|
|
672
681
|
current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
|
|
673
682
|
});
|
|
674
683
|
// Settling uncertainty must precede unlocking: otherwise another session
|
package/src/desktop-host.js
CHANGED
|
@@ -1267,7 +1267,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1267
1267
|
status.controllerCompletions = retainedCompletions;
|
|
1268
1268
|
status.controllerEventsVersion = 1;
|
|
1269
1269
|
status.controllerEvents = preferenceScope ? await notificationJournal.snapshot(preferenceScope) : [];
|
|
1270
|
-
const projection = await actionAssociations.beginProjection?.({ records
|
|
1270
|
+
const projection = await actionAssociations.beginProjection?.({ records,
|
|
1271
|
+
prioritySessionKeys: statisticsSelection.key ? [statisticsSelection.key] : [],
|
|
1272
|
+
scope: { organizationId: status.access?.organizationId, userId: status.auth?.userId } });
|
|
1271
1273
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
1272
1274
|
// A full record projection owns this receipt. Connection-only heartbeats
|
|
1273
1275
|
// replay the cached receipt; they must never mint evidence of a fresh read.
|
|
@@ -205,12 +205,14 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
205
205
|
return {
|
|
206
206
|
// A projection is private to one desktop frame. Mutations and deadline
|
|
207
207
|
// validation still reread the exact record and current authentication.
|
|
208
|
-
async beginProjection({ records: suppliedRecords } = {}) {
|
|
208
|
+
async beginProjection({ records: suppliedRecords, prioritySessionKeys = [], scope } = {}) {
|
|
209
209
|
const authRevision = await current();
|
|
210
210
|
const records = suppliedRecords || await list(root);
|
|
211
211
|
const taskIds = new Set(records.map(record => record.run?.managed?.taskId).filter(Boolean));
|
|
212
212
|
for (const taskId of views.keys()) if (!taskIds.has(taskId)) views.delete(taskId);
|
|
213
|
-
|
|
213
|
+
const associationNamespace = scope?.organizationId && scope?.userId ? namespaceFor(webOrigin, scope) : null;
|
|
214
|
+
return { records, authRevision, prioritySessionKeys, associationNamespace,
|
|
215
|
+
byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
|
|
214
216
|
},
|
|
215
217
|
async validateProjection(projection) {
|
|
216
218
|
return !closed && await current() === projection.authRevision;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { associationRevision } from './action-association.js';
|
|
9
9
|
import { ActionAssociationApiError } from './action-association-api.js';
|
|
10
10
|
import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
|
|
11
|
+
import { liveSessionWindow, supervisedSession } from './discovery-window.js';
|
|
11
12
|
|
|
12
13
|
const activeConversation = (candidate, record) => !TERMINAL.has(record.run?.state) &&
|
|
13
14
|
(candidate.isRunning !== false || candidate.autoContinue === true || candidate.analysis?.state === 'continue');
|
|
@@ -222,16 +223,37 @@ export function createOrdinaryAssociationStore({
|
|
|
222
223
|
]),
|
|
223
224
|
);
|
|
224
225
|
const auth = snapshot && Object.hasOwn(snapshot, 'authRevision') ? snapshot.authRevision : await api.authRevision();
|
|
226
|
+
const selected = new Set(snapshot?.prioritySessionKeys || []);
|
|
227
|
+
const keyFor = candidate => `${candidate.provider}:${candidate.session}`;
|
|
228
|
+
const observed = new Set(liveSessionWindow(sessions.filter(candidate => candidate.archived !== true || selected.has(keyFor(candidate))), {
|
|
229
|
+
priority: candidate => {
|
|
230
|
+
const record = records.get(keyFor(candidate));
|
|
231
|
+
// Token renewal changes authRevision without changing the account /
|
|
232
|
+
// organization namespace. Retain those bound reminders for a fresh
|
|
233
|
+
// authorized read; another organization's binding is not a watch.
|
|
234
|
+
const association = record?.actionAssociation;
|
|
235
|
+
const namespace = snapshot?.associationNamespace;
|
|
236
|
+
const scopedBinding = auth && association?.binding?.action && (namespace
|
|
237
|
+
? association.namespace === namespace : association.authRevision === auth);
|
|
238
|
+
return selected.has(keyFor(candidate)) || candidate.isRunning === true || candidate.autoContinue === true ||
|
|
239
|
+
supervisedSession(record) || Boolean(scopedBinding);
|
|
240
|
+
},
|
|
241
|
+
}).map(keyFor));
|
|
225
242
|
const taskIds = new Set([...records.values()].map(record => record.run?.managed?.taskId || record.associationTaskId).filter(Boolean));
|
|
226
243
|
for (const taskId of views.keys()) if (!taskIds.has(taskId) && !inFlight.has(taskId)) views.delete(taskId);
|
|
227
244
|
const pending = [];
|
|
228
245
|
for (const candidate of sessions) {
|
|
229
|
-
|
|
246
|
+
const observe = observed.has(keyFor(candidate));
|
|
247
|
+
let record = records.get(keyFor(candidate));
|
|
248
|
+
// Complete history is navigation, not a subscription. Do not create
|
|
249
|
+
// identities or start network discovery for untouched historical rows.
|
|
250
|
+
if (!observe && !record?.associationTaskId && !record?.run?.managed?.taskId) {
|
|
251
|
+
candidate.actionAssociation = { status: 'unavailable', binding: null, candidates: [], canCorrect: false, canReadBinding: false };
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
230
254
|
try {
|
|
231
|
-
record
|
|
232
|
-
|
|
233
|
-
records.get(`${candidate.provider}:${candidate.session}`) || null,
|
|
234
|
-
);
|
|
255
|
+
if (record && record.cwd !== candidate.cwd) throw new ActionAssociationApiError('conflict');
|
|
256
|
+
if (observe) record = await ensure(candidate, record || null);
|
|
235
257
|
} catch (error) {
|
|
236
258
|
// A stale workspace or unreadable local record affects only this
|
|
237
259
|
// optional association. Never rewrite its identity or stop the host.
|
|
@@ -244,9 +266,10 @@ export function createOrdinaryAssociationStore({
|
|
|
244
266
|
candidate.binding = { ...candidate.binding, taskId };
|
|
245
267
|
if (record.run?.managed) continue;
|
|
246
268
|
const cached = views.get(taskId),
|
|
247
|
-
context = sessionAssociationContext(candidate),
|
|
269
|
+
context = observe ? sessionAssociationContext(candidate) : null,
|
|
248
270
|
active = activeConversation(candidate, record);
|
|
249
271
|
if (
|
|
272
|
+
observe &&
|
|
250
273
|
auth &&
|
|
251
274
|
!closed &&
|
|
252
275
|
!inFlight.has(taskId) &&
|
|
@@ -263,7 +286,7 @@ export function createOrdinaryAssociationStore({
|
|
|
263
286
|
}
|
|
264
287
|
const view = views.get(taskId);
|
|
265
288
|
const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
|
|
266
|
-
const snapshot = bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
|
|
289
|
+
const snapshot = context && bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
|
|
267
290
|
const local = record.actionAssociation;
|
|
268
291
|
const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
|
|
269
292
|
const newer = scoped &&
|
|
@@ -337,17 +360,25 @@ export function createOrdinaryAssociationStore({
|
|
|
337
360
|
},
|
|
338
361
|
correct(command, candidate) {
|
|
339
362
|
const work = serial.then(async () => {
|
|
340
|
-
|
|
363
|
+
let before = await find(candidate);
|
|
364
|
+
const bootstrap = command.action === 'session_action_refresh' && !command.taskId;
|
|
365
|
+
// A user can explicitly inspect history outside the live window. Only
|
|
366
|
+
// that read-only refresh may create its previously unreserved identity;
|
|
367
|
+
// select/unlink still require the exact existing task and revisions.
|
|
368
|
+
if (bootstrap && !before?.run?.managed) {
|
|
369
|
+
if (!await api.authRevision()) throw new ActionAssociationApiError('signed_out');
|
|
370
|
+
before = await ensure(candidate, before || null);
|
|
371
|
+
}
|
|
341
372
|
if (
|
|
342
373
|
!before ||
|
|
343
|
-
before.associationTaskId !== command.taskId ||
|
|
374
|
+
(!bootstrap && before.associationTaskId !== command.taskId) ||
|
|
344
375
|
before.run?.managed
|
|
345
376
|
)
|
|
346
377
|
throw new ActionAssociationApiError('invalid_request');
|
|
347
378
|
const auth = await api.authRevision();
|
|
348
379
|
if (!auth) throw new ActionAssociationApiError('signed_out');
|
|
349
380
|
await sync(candidate, true);
|
|
350
|
-
const view = views.get(command.taskId);
|
|
381
|
+
const view = views.get(bootstrap ? before.associationTaskId : command.taskId);
|
|
351
382
|
if (
|
|
352
383
|
!view?.snapshot ||
|
|
353
384
|
view.auth !== auth ||
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -40,13 +40,17 @@ export async function claudePluginFiles() {
|
|
|
40
40
|
// Standalone hooks do not ship the CLI capability dependency graph. Freeze the
|
|
41
41
|
// maintained package version into their generated collector instead.
|
|
42
42
|
files.set("hooks/cli-observability.mjs", collector.replace("./web-api.js", "./web-api.mjs")
|
|
43
|
-
.replace("import { CLI_VERSION } from './cli-contract.js';", `const CLI_VERSION = ${JSON.stringify(packageInfo.version)};`)
|
|
43
|
+
.replace("import { CLI_VERSION } from './cli-contract.js';", `const CLI_VERSION = ${JSON.stringify(packageInfo.version)};`)
|
|
44
|
+
.replace("'./session-store.js'", "'./session-store.mjs'"));
|
|
45
|
+
files.set("hooks/session-store.mjs", (await readFile(new URL("../session-store.js", import.meta.url), "utf8"))
|
|
46
|
+
.replace('"./web-origin.js"', '"./web-origin.mjs"'));
|
|
47
|
+
files.set("hooks/web-origin.mjs", await readFile(new URL("../web-origin.js", import.meta.url), "utf8"));
|
|
44
48
|
files.set("hooks/web-api.mjs", await readFile(new URL("../web-api.js", import.meta.url), "utf8"));
|
|
45
49
|
const deliveryLoop = await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8");
|
|
46
50
|
files.set("hooks/delivery-loop.mjs", deliveryLoop.replace(
|
|
47
51
|
"const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')\n .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);",
|
|
48
52
|
"import { observeDeliveryRecord } from './cli-observability.mjs';",
|
|
49
|
-
));
|
|
53
|
+
).replaceAll("'../../../cli-observability.js'", "'./cli-observability.mjs'"));
|
|
50
54
|
files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
|
|
51
55
|
files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
|
|
52
56
|
files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
|
|
@@ -63,6 +67,7 @@ export async function claudePluginFiles() {
|
|
|
63
67
|
files.set("hooks/purpose-evidence.mjs", await readFile(path.join(SOURCE, "hooks/purpose-evidence.mjs"), "utf8"));
|
|
64
68
|
files.set("hooks/user-experience.mjs", await readFile(path.join(SOURCE, "hooks/user-experience.mjs"), "utf8"));
|
|
65
69
|
files.set("hooks/result-cycle.mjs", await readFile(path.join(SOURCE, "hooks/result-cycle.mjs"), "utf8"));
|
|
70
|
+
files.set("hooks/loop-stop-record.mjs", await readFile(path.join(SOURCE, "hooks/loop-stop-record.mjs"), "utf8"));
|
|
66
71
|
for (const name of ['measurement-contract', 'measurement-store', 'measurement-snapshot', 'measurement-judge', 'measurement-collector'])
|
|
67
72
|
files.set(`hooks/${name}.mjs`, await readFile(path.join(SOURCE, `hooks/${name}.mjs`), 'utf8'));
|
|
68
73
|
files.set("hooks/scene-reasoning.mjs", await readFile(path.join(SOURCE, "hooks/scene-reasoning.mjs"), "utf8"));
|
package/src/sqm/command.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadHypothesisKnowledge } from './hypothesis-knowledge.js';
|
|
2
2
|
import { verifyLocalTestArtifacts } from './local-tests.js';
|
|
3
3
|
import { sqmConversationIdentity } from './conversation-identity.js';
|
|
4
|
+
import { recordLoopSqmProof, resolveLoopConversationBinding } from './loop-conversation.js';
|
|
4
5
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
5
6
|
import { createSqmHistoryStore, createSqmCheckRecorder } from "./history-store.js";
|
|
6
7
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
@@ -20,6 +21,7 @@ import { ensureSqmSession, sqmSessionScope } from "./session.js";
|
|
|
20
21
|
import { formatSqmCoverage } from "./report.js";
|
|
21
22
|
import { sqmDiagnostic } from "./request-recovery.js";
|
|
22
23
|
import { withSemanticCancellation } from "./reasoned-assessment.js";
|
|
24
|
+
import { runSqmLoopOutcomeCommand } from "./loop-outcome-command.js";
|
|
23
25
|
|
|
24
26
|
export async function runSqmCommand(argv, options) {
|
|
25
27
|
try {
|
|
@@ -32,6 +34,7 @@ export async function runSqmCommand(argv, options) {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoader = loadHypothesisKnowledge }) {
|
|
37
|
+
if (argv[0] === "effect" && argv[1] === "backfill") return await runSqmLoopOutcomeCommand(argv.slice(1));
|
|
35
38
|
if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
|
|
36
39
|
printHelp();
|
|
37
40
|
return 0;
|
|
@@ -203,7 +206,10 @@ async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoad
|
|
|
203
206
|
}
|
|
204
207
|
if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
|
|
205
208
|
stage = "full_check";
|
|
206
|
-
|
|
209
|
+
// Bound only while this exact conversation has an open Delivery Loop run.
|
|
210
|
+
const loopBinding = await resolveLoopConversationBinding({ provider: conversationProvider, session: providerSession });
|
|
211
|
+
const fullOptions = { ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined,
|
|
212
|
+
conversationRef: loopBinding?.conversationRef ?? null };
|
|
207
213
|
const full = semanticProvider || common.localTests
|
|
208
214
|
? await withSemanticCancellation(signal => runSqmCheck({ ...fullOptions, signal }), { signal: common.signal })
|
|
209
215
|
: await runSqmCheck(fullOptions);
|
|
@@ -227,6 +233,10 @@ async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoad
|
|
|
227
233
|
matcher_diagnostics: result.matcher_diagnostics,
|
|
228
234
|
}),
|
|
229
235
|
]);
|
|
236
|
+
if (loopBinding) {
|
|
237
|
+
try { result.loop_conversation = { recorded: await recordLoopSqmProof(loopBinding, result.proof.proof_id) }; }
|
|
238
|
+
catch { result.loop_conversation = { recorded: false, warning: "loop_record_update_failed" }; }
|
|
239
|
+
}
|
|
230
240
|
let upload = { uploaded: false, reason: "disabled" };
|
|
231
241
|
if (!argv.includes("--no-upload")) {
|
|
232
242
|
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
@@ -290,6 +300,7 @@ function printHelp() {
|
|
|
290
300
|
simy sqm lint --bundle <path>
|
|
291
301
|
simy sqm replay --bundle <path> --dataset <path>
|
|
292
302
|
simy sqm knowledge --query <mechanism> [--offset <number>] [--offline] [--host <origin>] --json
|
|
303
|
+
simy sqm effect backfill [--dry-run] [--json] [--host <origin>]
|
|
293
304
|
|
|
294
305
|
knowledge searches the verified scoped catalog using existing authorization only;
|
|
295
306
|
it never opens sign-in, runs checks or certifies the candidate. Use nextOffset
|
|
@@ -304,6 +315,8 @@ Two-stage SQM flow:
|
|
|
304
315
|
checks. Saves the storage acknowledgement beside the proof.
|
|
305
316
|
lint Report broad matchers and missing evaluation gates before publish.
|
|
306
317
|
replay Evaluate a bundle against labelled historical repository snapshots.
|
|
318
|
+
effect backfill sends content-free Delivery Loop outcomes for the Quality
|
|
319
|
+
Monitor "Adoption effect" page (see "simy sqm effect backfill --help").
|
|
307
320
|
|
|
308
321
|
Options:
|
|
309
322
|
--base <branch> Compare against this base branch (default: dev)
|
package/src/sqm/index.js
CHANGED
|
@@ -182,7 +182,7 @@ export async function runSqmCheck(options) {
|
|
|
182
182
|
throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
|
|
183
183
|
}
|
|
184
184
|
options.signal?.throwIfAborted();
|
|
185
|
-
const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath, createSigningKey: options.createSigningKey });
|
|
185
|
+
const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath, createSigningKey: options.createSigningKey, conversationRef: options.conversationRef ?? null });
|
|
186
186
|
options.signal?.throwIfAborted();
|
|
187
187
|
if (minCheck !== options.minCheck) result.recovery = { code: "knowledge_bundle_changed", previous_min_digest: options.minCheck.result_digest, current_min_digest: minCheck.result_digest };
|
|
188
188
|
return result;
|
|
@@ -237,8 +237,8 @@ async function buildMinCheck({ repository, bundle, evaluation, organizationId, c
|
|
|
237
237
|
});
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath, createSigningKey }) {
|
|
241
|
-
const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion });
|
|
240
|
+
async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath, createSigningKey, conversationRef = null }) {
|
|
241
|
+
const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion, conversationRef });
|
|
242
242
|
const signedEvidence = await signProof(proof, { deviceId, accountId, keyPath: signingKeyPath, createSigningKey });
|
|
243
243
|
const verification = verifySignedEvidence(proof, signedEvidence);
|
|
244
244
|
return {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { readRecord, rootDirectory, updateRecord } from "../codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
4
|
+
|
|
5
|
+
// Binds an SQM full check to the Delivery Loop conversation it ran in, without
|
|
6
|
+
// sending conversation content: only `sha256(provider + ":" + session)` leaves
|
|
7
|
+
// the device (contract sqm_loop_outcome.v1 / proof.provenance.conversation_ref).
|
|
8
|
+
export const SQM_LOOP_PROOF_ID_LIMIT = 50;
|
|
9
|
+
export const CONVERSATION_REF = /^sha256:[0-9a-f]{64}$/;
|
|
10
|
+
const PROOF_ID = /^SQM-PROOF-[0-9a-f-]{36}$/i;
|
|
11
|
+
const OPEN_STATES = new Set(["active", "paused", "blocked", "recovery_required", "auto_resuming", "verification_pending"]);
|
|
12
|
+
const PROVIDERS = new Set(["codex", "claude"]);
|
|
13
|
+
|
|
14
|
+
export function sqmConversationRef(provider, session) {
|
|
15
|
+
if (!PROVIDERS.has(provider) || typeof session !== "string" || !session) throw new Error("A provider and exact session ID are required.");
|
|
16
|
+
return `sha256:${createHash("sha256").update(`${provider}:${session}`, "utf8").digest("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only a conversation whose Delivery Loop run is still open is Loop-managed.
|
|
20
|
+
// Standalone checks, unknown conversations and finished runs get no binding.
|
|
21
|
+
export function loopConversationBinding(record) {
|
|
22
|
+
const run = record?.run;
|
|
23
|
+
if (!run || typeof run.id !== "string" || !run.id || !OPEN_STATES.has(run.state)) return null;
|
|
24
|
+
if (!PROVIDERS.has(record.provider) || typeof record.session !== "string") return null;
|
|
25
|
+
return { provider: record.provider, session: record.session, runId: run.id, conversationRef: sqmConversationRef(record.provider, record.session) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveLoopConversationBinding({ provider, session, root = rootDirectory(), readRecordImpl = readRecord } = {}) {
|
|
29
|
+
if (!PROVIDERS.has(provider) || typeof session !== "string" || !session) return null;
|
|
30
|
+
try { return loopConversationBinding(await readRecordImpl(root, provider, session)); }
|
|
31
|
+
catch { return null; } // An unreadable loop record never blocks or tags a check.
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Appends a signed proof ID to the same Loop run that was bound when the proof
|
|
35
|
+
// was built. A run that ended or was replaced meanwhile is left unchanged.
|
|
36
|
+
export async function recordLoopSqmProof(binding, proofId, { root = rootDirectory(), updateRecordImpl = updateRecord } = {}) {
|
|
37
|
+
if (!binding?.runId || !PROOF_ID.test(proofId || "")) return false;
|
|
38
|
+
let recorded = false;
|
|
39
|
+
await updateRecordImpl(root, binding.provider, binding.session, record => {
|
|
40
|
+
if (!record?.run || record.run.id !== binding.runId || !OPEN_STATES.has(record.run.state)) return record;
|
|
41
|
+
const current = loopSqmProofIds(record);
|
|
42
|
+
if (!current.includes(proofId)) record.run.sqmProofIds = [...current, proofId].slice(-SQM_LOOP_PROOF_ID_LIMIT);
|
|
43
|
+
recorded = true;
|
|
44
|
+
return record;
|
|
45
|
+
});
|
|
46
|
+
return recorded;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read side for sqm_loop_outcome.v1.sqm_proof_ids: valid, unique, at most 50,
|
|
50
|
+
// oldest first. Returns [] for records without Loop-bound SQM proofs.
|
|
51
|
+
export function loopSqmProofIds(record) {
|
|
52
|
+
const ids = Array.isArray(record?.run?.sqmProofIds) ? record.run.sqmProofIds : [];
|
|
53
|
+
return [...new Set(ids.filter(id => typeof id === "string" && PROOF_ID.test(id)))].slice(-SQM_LOOP_PROOF_ID_LIMIT);
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// `simy sqm effect backfill`: send one content-free `sqm_loop_outcome.v1`
|
|
2
|
+
// record for every local Delivery Loop conversation. Past runs have no stop
|
|
3
|
+
// record, so they go as stops_recorded:false with no stops.
|
|
4
|
+
import { defaultSessionRoot, isSessionValid, readSession } from "../session-store.js";
|
|
5
|
+
import { resolveWebOrigin } from "../web-origin.js";
|
|
6
|
+
import { buildLoopOutcomeRecord, summarizeLoopOutcomes } from "./loop-outcome.js";
|
|
7
|
+
import { syncLoopOutcomes } from "./loop-outcome-outbox.js";
|
|
8
|
+
|
|
9
|
+
export function printLoopOutcomeHelp(logger = console) {
|
|
10
|
+
logger.log(`Usage:
|
|
11
|
+
simy sqm effect backfill [--dry-run] [--json] [--host <origin>]
|
|
12
|
+
|
|
13
|
+
backfill reads this machine's Delivery Loop records and sends one record per
|
|
14
|
+
conversation the Loop ran in: provider, outcome, Loop-added turns, tokens and
|
|
15
|
+
timestamps. Conversation text, titles, folders and objectives are never sent;
|
|
16
|
+
the conversation is identified only by a one-way hash. Conversations from
|
|
17
|
+
before stop recording send no stop list. --dry-run prints the counts and sends
|
|
18
|
+
nothing. Sending needs this device's CLI observability consent; without it the
|
|
19
|
+
client sends nothing. Unbound historical runs are never assigned to the current
|
|
20
|
+
organization. Failed sends stay
|
|
21
|
+
queued and the running SIMY agent retries them.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function listEntries(root) {
|
|
25
|
+
const { listRecords } = await import("../codex-plugins/simy/hooks/delivery-loop.mjs");
|
|
26
|
+
return listRecords(`${root}/delivery-hooks`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runSqmLoopOutcomeCommand(argv, { fetchImpl = globalThis.fetch, logger = console, root = defaultSessionRoot(),
|
|
30
|
+
now = Date.now(), entries, session: providedSession } = {}) {
|
|
31
|
+
const [action, ...rest] = argv;
|
|
32
|
+
if (action !== "backfill" || rest.includes("--help")) { printLoopOutcomeHelp(logger); return action === "backfill" ? 0 : 2; }
|
|
33
|
+
const json = rest.includes("--json"), dryRun = rest.includes("--dry-run");
|
|
34
|
+
const host = rest.includes("--host") ? rest[rest.indexOf("--host") + 1] : undefined;
|
|
35
|
+
const all = entries || await listEntries(root);
|
|
36
|
+
const records = [], sources = [], skipped = {};
|
|
37
|
+
for (const entry of all) {
|
|
38
|
+
const built = buildLoopOutcomeRecord(entry, { now });
|
|
39
|
+
if (built.record) { records.push(built.record); sources.push(entry); }
|
|
40
|
+
else if (built.skipped !== "no_run") skipped[built.skipped] = (skipped[built.skipped] || 0) + 1;
|
|
41
|
+
}
|
|
42
|
+
const summary = { dry_run: dryRun, delivery_records: all.length, ...summarizeLoopOutcomes(records, skipped) };
|
|
43
|
+
if (dryRun) {
|
|
44
|
+
logger.log(json ? JSON.stringify(summary, null, 2) : formatSummary(summary, "Read"));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const webOrigin = resolveWebOrigin(host);
|
|
48
|
+
let session = providedSession ?? await readSession(webOrigin, root);
|
|
49
|
+
if (!providedSession && !isSessionValid(session, now, webOrigin)) session = null;
|
|
50
|
+
if (!session?.token) throw new Error(`Sending needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
51
|
+
const result = await syncLoopOutcomes({ root, apiOrigin: webOrigin, apiBaseUrl: session.api_base_url,
|
|
52
|
+
token: session.token, fetchImpl, now, entries: sources, minIntervalMs: 0,
|
|
53
|
+
...(providedSession ? { getSession: () => providedSession } : {}) });
|
|
54
|
+
const failed = Boolean(result.failure);
|
|
55
|
+
const output = { ...summary, sent: result };
|
|
56
|
+
if (json) logger.log(JSON.stringify(output, null, 2));
|
|
57
|
+
else {
|
|
58
|
+
logger.log(formatSummary(summary, "Read"));
|
|
59
|
+
logger.log(result.skipped ? `Not sent: ${result.skipped}.`
|
|
60
|
+
: `Stored: ${result.sent} proven-scope record(s); ${result.pending} queued; ${result.unproven} lack matching enrollment provenance${result.failure ? ` (${result.failure})` : ""}.`);
|
|
61
|
+
}
|
|
62
|
+
return failed ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatSummary(summary, verb) {
|
|
66
|
+
const states = Object.entries(summary.final_states).filter(([, n]) => n).map(([state, n]) => `${state} ${n}`).join(", ") || "none";
|
|
67
|
+
const skipped = Object.entries(summary.skipped).map(([reason, n]) => `${reason} ${n}`).join(", ");
|
|
68
|
+
return [`${verb} ${summary.conversations} Delivery Loop conversation(s) from ${summary.delivery_records} local record(s)${summary.dry_run ? "; nothing was sent (--dry-run)" : ""}.`,
|
|
69
|
+
` provider: codex ${summary.providers.codex}, claude ${summary.providers.claude}`,
|
|
70
|
+
` outcome: ${states}`,
|
|
71
|
+
` Loop-added turns: ${summary.continuations_total}; tokens known for ${summary.tokens_known} (total ${summary.tokens_total})`,
|
|
72
|
+
` with stop record: ${summary.stops_recorded}`,
|
|
73
|
+
...(skipped ? [` not sent: ${skipped}`] : [])].join("\n");
|
|
74
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Local queue for `sqm_loop_outcome.v1` records (contract 8.1/8.2). The agent
|
|
2
|
+
// sweeps delivery records on its authenticated heartbeat, queues one record
|
|
3
|
+
// per conversation when it ends or reaches a milestone, and drains the queue
|
|
4
|
+
// with backoff. Network I/O never runs inside a delivery-state update, and a
|
|
5
|
+
// missing route (404 before the web relay ships) only leaves records queued.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { isSessionValid } from "../session-store.js";
|
|
8
|
+
import { captureLoopOutcomeScope } from "../cli-observability.js";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { acquireLock, atomicSave, readState } from "../codex-plugins/simy/hooks/update-state-store.mjs";
|
|
13
|
+
import { webApiHeaders, webApiUrl } from "../web-api.js";
|
|
14
|
+
import { buildLoopOutcomeRecord, loopOutcomeDigest, loopOutcomeReceiptProblem, loopOutcomeRecordProblem } from "./loop-outcome.js";
|
|
15
|
+
|
|
16
|
+
export const LOOP_OUTCOME_OUTBOX = "sqm-loop-outcome-outbox.json";
|
|
17
|
+
export const LOOP_OUTCOME_ROUTE = "sqm/loop-outcome";
|
|
18
|
+
export const ACTIVE_RECORD_INTERVAL_MS = 10 * 60_000;
|
|
19
|
+
export const CONSENT_RECHECK_MS = 6 * 60 * 60_000;
|
|
20
|
+
// The heartbeat runs every 20s; reading every delivery record is not free.
|
|
21
|
+
export const SWEEP_INTERVAL_MS = 5 * 60_000;
|
|
22
|
+
const MAX_FLUSH = 25;
|
|
23
|
+
const MAX_PENDING = 1000;
|
|
24
|
+
const MAX_BACKOFF_MS = 60 * 60_000;
|
|
25
|
+
|
|
26
|
+
// Keep legacy queues untouched. Each proven enrollment scope has an isolated
|
|
27
|
+
// retry ledger; switching organization or consent generation cannot drain it.
|
|
28
|
+
export const sameLoopOutcomeScope = (a, b) => a && b &&
|
|
29
|
+
["origin", "accountId", "organizationId", "deviceId", "consentGeneration"].every(k =>
|
|
30
|
+
typeof a[k] === "string" && a[k].length > 0 && a[k] === b[k]);
|
|
31
|
+
export function loopOutcomeScopeRoot(root, scope) {
|
|
32
|
+
if (!sameLoopOutcomeScope(scope, scope)) throw new Error("Loop outcome scope is unproven.");
|
|
33
|
+
const key = [scope.origin, scope.accountId, scope.organizationId, scope.deviceId, scope.consentGeneration];
|
|
34
|
+
return path.join(root, "sqm-loop-outcomes", createHash("sha256").update(JSON.stringify(key)).digest("hex"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loopOutcomeCollectionCurrent(root, binding) {
|
|
38
|
+
if (!/^[a-f0-9]{64}$/.test(binding?.policyKey || "") || !/^[a-f0-9]{64}$/.test(binding?.policyDigest || "")) return false;
|
|
39
|
+
try {
|
|
40
|
+
const policy = readState(path.join(root, "delivery-hooks", "measurement-policies", binding.policyKey + ".json"));
|
|
41
|
+
return policy?.enabled === true && policy.source === "field" && policy.loop === "on" &&
|
|
42
|
+
createHash("sha256").update(JSON.stringify(policy)).digest("hex") === binding.policyDigest;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const blank = () => ({ schemaVersion: 1, conversations: {}, pending: {}, retry: { attempts: 0, at: 0 }, consentBlockedUntil: 0 });
|
|
47
|
+
|
|
48
|
+
export async function readLoopOutcomeOutbox(root) {
|
|
49
|
+
try {
|
|
50
|
+
const state = JSON.parse(await readFile(path.join(root, LOOP_OUTCOME_OUTBOX), "utf8"));
|
|
51
|
+
if (state?.schemaVersion === 1 && state.conversations && state.pending && state.retry) return state;
|
|
52
|
+
} catch { /* missing or unreadable: start over; records are re-derived from delivery state */ }
|
|
53
|
+
return blank();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function saveOutbox(root, state) {
|
|
57
|
+
atomicSave(path.join(root, LOOP_OUTCOME_OUTBOX), state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Reuse the existing exclusive-owner/atomic-save protocol. Retry policy stays
|
|
61
|
+
// local to this async queue; crashed locks are never stolen by elapsed time.
|
|
62
|
+
async function withOutboxLock(root, operation) {
|
|
63
|
+
let release;
|
|
64
|
+
for (let attempt = 0; ; attempt++) {
|
|
65
|
+
try { release = acquireLock(path.join(root, LOOP_OUTCOME_OUTBOX)); break; }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== "state_busy" || attempt >= 120) throw error;
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try { return await operation(); }
|
|
72
|
+
finally { release(); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function defaultEntries(root) {
|
|
76
|
+
const { listRecords } = await import("../codex-plugins/simy/hooks/delivery-loop.mjs");
|
|
77
|
+
return listRecords(path.join(root, "delivery-hooks"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const milestone = (record, entry) =>
|
|
81
|
+
`${record.final_state}:${entry.run?.resultCycle?.stops?.entries?.length || 0}:${record.continuations}`;
|
|
82
|
+
|
|
83
|
+
// Queue changed conversations. An active run is queued at a milestone (new
|
|
84
|
+
// stop, state or turn) at most once per ACTIVE_RECORD_INTERVAL_MS; an ended
|
|
85
|
+
// run is queued whenever its outcome changes.
|
|
86
|
+
export async function collectLoopOutcomes(options = {}) {
|
|
87
|
+
if (!sameLoopOutcomeScope(options.scope, options.scope)) return { queued: 0, pending: 0, skipped: "unproven_scope" };
|
|
88
|
+
const storageRoot = loopOutcomeScopeRoot(options.root, options.scope);
|
|
89
|
+
return withOutboxLock(storageRoot, () => collectLoopOutcomesLocked({ ...options, storageRoot }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function collectLoopOutcomesLocked({ root, storageRoot, scope, now = Date.now(), entries, minIntervalMs = 0 }) {
|
|
93
|
+
const state = await readLoopOutcomeOutbox(storageRoot);
|
|
94
|
+
if (state.consentBlockedUntil > now) return { queued: 0, pending: Object.keys(state.pending).length, consentRequired: true };
|
|
95
|
+
if (minIntervalMs && Number.isFinite(state.sweptAt) && now - state.sweptAt < minIntervalMs) return { queued: 0, pending: Object.keys(state.pending).length, deferred: true };
|
|
96
|
+
let queued = 0, unproven = 0;
|
|
97
|
+
for (const entry of entries || await defaultEntries(root)) {
|
|
98
|
+
if (!sameLoopOutcomeScope(entry.run?.outcomeScope, scope) || !loopOutcomeCollectionCurrent(root, entry.run?.outcomeScope)) { unproven++; continue; }
|
|
99
|
+
const { record } = buildLoopOutcomeRecord(entry, { now });
|
|
100
|
+
if (!record) continue;
|
|
101
|
+
const ref = record.conversation_ref, previous = state.conversations[ref];
|
|
102
|
+
const digest = loopOutcomeDigest(record), mark = milestone(record, entry);
|
|
103
|
+
if (previous?.digest === digest) continue;
|
|
104
|
+
if (record.final_state === "active" && previous &&
|
|
105
|
+
(previous.milestone === mark || now - previous.enqueuedAt < ACTIVE_RECORD_INTERVAL_MS)) continue;
|
|
106
|
+
state.conversations[ref] = { collection: { policyKey: entry.run.outcomeScope.policyKey, policyDigest: entry.run.outcomeScope.policyDigest }, digest, milestone: mark, enqueuedAt: now, sentAt: previous?.sentAt || null };
|
|
107
|
+
state.pending[ref] = record;
|
|
108
|
+
queued++;
|
|
109
|
+
}
|
|
110
|
+
const refs = Object.keys(state.pending);
|
|
111
|
+
if (refs.length > MAX_PENDING) {
|
|
112
|
+
refs.sort((a, b) => Date.parse(state.pending[a].recorded_at) - Date.parse(state.pending[b].recorded_at));
|
|
113
|
+
for (const ref of refs.slice(0, refs.length - MAX_PENDING)) { delete state.pending[ref]; delete state.conversations[ref]; }
|
|
114
|
+
}
|
|
115
|
+
state.sweptAt = now;
|
|
116
|
+
if (queued || minIntervalMs) await saveOutbox(storageRoot, state);
|
|
117
|
+
return { queued, pending: Object.keys(state.pending).length, unproven };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One POST. Returns stored | consent_required | rejected | failed; never throws.
|
|
121
|
+
export async function sendLoopOutcome({ endpoint, token, record, consentGeneration, fetchImpl = globalThis.fetch }) {
|
|
122
|
+
const problem = loopOutcomeRecordProblem(record);
|
|
123
|
+
if (problem) return { status: "rejected", problem };
|
|
124
|
+
let response;
|
|
125
|
+
try {
|
|
126
|
+
response = await fetchImpl(endpoint, { method: "POST",
|
|
127
|
+
headers: webApiHeaders({ token }, { accept: "application/json", "content-type": "application/json", ...(consentGeneration ? { "x-simy-consent-generation": consentGeneration } : {}) }),
|
|
128
|
+
body: JSON.stringify(record), signal: AbortSignal.timeout(10_000) });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { status: "failed", problem: "network", detail: error?.name || "error" };
|
|
131
|
+
}
|
|
132
|
+
const body = await response.json().catch(() => null);
|
|
133
|
+
if (response.status === 403 && (body?.error === "consent_required" || body?.error?.code === "consent_required" || body?.code === "consent_required")) {
|
|
134
|
+
return { status: "consent_required" };
|
|
135
|
+
}
|
|
136
|
+
if ([400, 409, 422].includes(response.status)) return { status: "rejected", problem: `http_${response.status}` };
|
|
137
|
+
if (!response.ok) return { status: "failed", problem: `http_${response.status}` };
|
|
138
|
+
const receiptProblem = loopOutcomeReceiptProblem(response.status, body);
|
|
139
|
+
if (receiptProblem) return { status: "failed", problem: `receipt_${receiptProblem}` };
|
|
140
|
+
return { status: "stored", created: body.created, outcome_id: body.outcome_id };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function flushLoopOutcomes({ root, scope, authorize, fetchImpl = globalThis.fetch, now = Date.now() }) {
|
|
144
|
+
if (!sameLoopOutcomeScope(scope, scope) || typeof authorize !== "function") return { sent: 0, skipped: "unproven_scope" };
|
|
145
|
+
const storageRoot = loopOutcomeScopeRoot(root, scope);
|
|
146
|
+
const initial = await readLoopOutcomeOutbox(storageRoot);
|
|
147
|
+
if (initial.retry.at > now || initial.consentBlockedUntil > now) return { sent: 0, pending: Object.keys(initial.pending).length, deferred: true };
|
|
148
|
+
const batch = Object.values(initial.pending)
|
|
149
|
+
.sort((a, b) => Date.parse(a.recorded_at) - Date.parse(b.recorded_at)).slice(0, MAX_FLUSH);
|
|
150
|
+
if (!batch.length) return { sent: 0, pending: 0 };
|
|
151
|
+
const results = [];
|
|
152
|
+
for (const record of batch) {
|
|
153
|
+
// Resolve current credentials and consent immediately before EACH send.
|
|
154
|
+
// A changed login/organization/generation leaves this partition untouched.
|
|
155
|
+
if (!loopOutcomeCollectionCurrent(root, initial.conversations[record.conversation_ref]?.collection)) continue;
|
|
156
|
+
const auth = await authorize();
|
|
157
|
+
if (!auth?.token || !sameLoopOutcomeScope(auth.scope, scope)) break;
|
|
158
|
+
const result = await sendLoopOutcome({ endpoint: auth.endpoint, token: auth.token, record, consentGeneration: scope.consentGeneration, fetchImpl });
|
|
159
|
+
results.push([record, result]);
|
|
160
|
+
if (result.status === "failed" || result.status === "consent_required") break;
|
|
161
|
+
}
|
|
162
|
+
// Keep network I/O outside the lock. Reconcile under the same lock as sweeps
|
|
163
|
+
// so another collector cannot overwrite the freshly read state before save.
|
|
164
|
+
return withOutboxLock(storageRoot, () => applyFlushResults(storageRoot, results, now));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function applyFlushResults(root, results, now) {
|
|
168
|
+
const state = await readLoopOutcomeOutbox(root);
|
|
169
|
+
let sent = 0, failure = null;
|
|
170
|
+
for (const [record, result] of results) {
|
|
171
|
+
const ref = record.conversation_ref, current = state.pending[ref];
|
|
172
|
+
const same = current && loopOutcomeDigest(current) === loopOutcomeDigest(record);
|
|
173
|
+
if (result.status === "stored") {
|
|
174
|
+
sent++;
|
|
175
|
+
if (same) delete state.pending[ref];
|
|
176
|
+
if (state.conversations[ref]) state.conversations[ref].sentAt = now;
|
|
177
|
+
} else if (result.status === "rejected") {
|
|
178
|
+
if (same) delete state.pending[ref];
|
|
179
|
+
} else if (result.status === "consent_required") {
|
|
180
|
+
// Quietly stop without erasing the original scope's pending evidence.
|
|
181
|
+
// A new consent generation uses a different partition and cannot replay it.
|
|
182
|
+
state.consentBlockedUntil = now + CONSENT_RECHECK_MS;
|
|
183
|
+
failure = "consent_required";
|
|
184
|
+
} else {
|
|
185
|
+
failure = result.problem;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (failure && failure !== "consent_required") {
|
|
189
|
+
state.retry = { attempts: state.retry.attempts + 1, at: now + Math.min(MAX_BACKOFF_MS, 30_000 * 2 ** state.retry.attempts) };
|
|
190
|
+
} else if (!failure) {
|
|
191
|
+
state.retry = { attempts: 0, at: 0 };
|
|
192
|
+
}
|
|
193
|
+
await saveOutbox(root, state);
|
|
194
|
+
return { sent, pending: Object.keys(state.pending).length, ...(failure ? { failure } : {}) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function loopOutcomeEndpoint({ apiOrigin, apiBaseUrl }) {
|
|
198
|
+
return webApiUrl(LOOP_OUTCOME_ROUTE, { webOrigin: apiOrigin, apiBaseUrl }).href;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Agent heartbeat entry. Same consent as CLI observability: nothing is
|
|
202
|
+
// collected or sent unless this device's observability consent is active.
|
|
203
|
+
export async function syncLoopOutcomes({ root, apiOrigin, apiBaseUrl, token, fetchImpl = globalThis.fetch, now = Date.now(), entries,
|
|
204
|
+
getSession, minIntervalMs = SWEEP_INTERVAL_MS } = {}) {
|
|
205
|
+
if (!token) return { sent: 0, skipped: "no_token" };
|
|
206
|
+
const authorize = async () => {
|
|
207
|
+
const scope = await captureLoopOutcomeScope(root).catch(() => null);
|
|
208
|
+
const session = getSession ? await getSession() : await import("../session-store.js")
|
|
209
|
+
.then(({ readSession }) => readSession(apiOrigin, root)).catch(() => null);
|
|
210
|
+
if (!scope || !session?.token || !isSessionValid(session, now, apiOrigin) || scope.origin !== apiOrigin ||
|
|
211
|
+
scope.accountId !== session.account_id || scope.organizationId !== session.organization_id ||
|
|
212
|
+
scope.deviceId !== session.device_id) return null;
|
|
213
|
+
const endpoint = loopOutcomeEndpoint({ apiOrigin, apiBaseUrl: session.api_base_url || apiBaseUrl });
|
|
214
|
+
if (new URL(endpoint).origin !== scope.origin) return null;
|
|
215
|
+
return { scope, token: session.token, endpoint };
|
|
216
|
+
};
|
|
217
|
+
const auth = await authorize();
|
|
218
|
+
if (!auth) return { sent: 0, skipped: "no_consent" };
|
|
219
|
+
const collected = await collectLoopOutcomes({ root, scope: auth.scope, now, entries, minIntervalMs });
|
|
220
|
+
return { ...await flushLoopOutcomes({ root, scope: auth.scope, authorize, fetchImpl, now }), unproven: collected.unproven || 0 };
|
|
221
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `sqm_loop_outcome.v1`: one content-free record per Delivery Loop
|
|
2
|
+
// conversation for the Quality Monitor "Adoption effect" page. Built only from
|
|
3
|
+
// counts, states and timestamps in the local delivery record; the
|
|
4
|
+
// conversation's text, title, cwd and objective never enter it.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import { LOOP_STOP_KINDS, MAX_LOOP_STOPS } from "../codex-plugins/simy/hooks/loop-stop-record.mjs";
|
|
8
|
+
import { loopSqmProofIds, sqmConversationRef, SQM_LOOP_PROOF_ID_LIMIT } from "./loop-conversation.js";
|
|
9
|
+
|
|
10
|
+
export const LOOP_OUTCOME_CONTRACT = "sqm_loop_outcome.v1";
|
|
11
|
+
export const LOOP_OUTCOME_KEYS = Object.freeze(["contract_version", "conversation_ref", "provider", "model", "loop_started_at",
|
|
12
|
+
"ended_at", "final_state", "continuations", "tokens_used", "stops_recorded", "stops", "sqm_proof_ids", "recorded_at"]);
|
|
13
|
+
export const LOOP_OUTCOME_STOP_KEYS = Object.freeze(["index", "kind", "criteria_met", "at"]);
|
|
14
|
+
export const LOOP_OUTCOME_FINAL_STATES = Object.freeze(["active", "completed", "reported_done", "returned_to_human", "budget_exhausted", "failed"]);
|
|
15
|
+
const PROVIDERS = new Set(["codex", "claude"]);
|
|
16
|
+
const MAX_PROOF_IDS = SQM_LOOP_PROOF_ID_LIMIT;
|
|
17
|
+
|
|
18
|
+
// Contract 8.1. `auto_resuming` and `verification_pending` are the Loop still
|
|
19
|
+
// working on the conversation (not in 8.1's table); any other unknown state is
|
|
20
|
+
// not sent rather than guessed.
|
|
21
|
+
const FINAL_STATE = Object.freeze({
|
|
22
|
+
completed: "completed", reported_done: "reported_done",
|
|
23
|
+
stopped: "returned_to_human", paused: "returned_to_human", blocked: "returned_to_human", recovery_required: "returned_to_human",
|
|
24
|
+
budget_exhausted: "budget_exhausted", failed: "failed",
|
|
25
|
+
active: "active", auto_resuming: "active", verification_pending: "active",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function loopOutcomeFinalState(runState) {
|
|
29
|
+
return Object.hasOwn(FINAL_STATE, runState) ? FINAL_STATE[runState] : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const iso = value => Number.isFinite(value) && value > 0 ? new Date(value).toISOString() : null;
|
|
33
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
34
|
+
|
|
35
|
+
// Returns { record } or { skipped: <reason> }. Stops are sent only when the
|
|
36
|
+
// recorder observed the run from its first stop (otherwise #1 would not be the
|
|
37
|
+
// first stop); older runs, including every backfilled one, send
|
|
38
|
+
// stops_recorded:false with no stops. Backfill uses this same builder so it
|
|
39
|
+
// never overwrites a conversation's recorded stops with an empty list.
|
|
40
|
+
export function buildLoopOutcomeRecord(entry, { now = Date.now() } = {}) {
|
|
41
|
+
const run = entry?.run;
|
|
42
|
+
if (!run || typeof run !== "object") return { skipped: "no_run" };
|
|
43
|
+
if (!PROVIDERS.has(entry.provider) || typeof entry.session !== "string" || !entry.session) return { skipped: "unsupported_provider" };
|
|
44
|
+
const finalState = loopOutcomeFinalState(run.state);
|
|
45
|
+
if (!finalState) return { skipped: "unknown_state" };
|
|
46
|
+
const startedAt = iso(run.startedAt);
|
|
47
|
+
if (!startedAt) return { skipped: "missing_start" };
|
|
48
|
+
const ledger = run.resultCycle?.stops;
|
|
49
|
+
const stopsRecorded = ledger?.version === 1 && ledger.complete === true && Array.isArray(ledger.entries);
|
|
50
|
+
const stops = stopsRecorded ? ledger.entries.slice(0, MAX_LOOP_STOPS).map(stop => ({
|
|
51
|
+
index: stop.index, kind: stop.kind, criteria_met: stop.criteria_met ?? null, at: stop.at })) : [];
|
|
52
|
+
const model = typeof run.managed?.resumeModel === "string" && run.managed.resumeModel.trim() ? run.managed.resumeModel.trim().slice(0, 100) : null;
|
|
53
|
+
const endedAt = finalState === "active" ? null : iso(run.updatedAt) || new Date(now).toISOString();
|
|
54
|
+
const record = {
|
|
55
|
+
contract_version: LOOP_OUTCOME_CONTRACT,
|
|
56
|
+
conversation_ref: sqmConversationRef(entry.provider, entry.session),
|
|
57
|
+
provider: entry.provider,
|
|
58
|
+
model,
|
|
59
|
+
loop_started_at: startedAt,
|
|
60
|
+
ended_at: endedAt,
|
|
61
|
+
final_state: finalState,
|
|
62
|
+
continuations: count(run.continuations) ?? 0,
|
|
63
|
+
tokens_used: count(run.managed?.tokensUsed),
|
|
64
|
+
stops_recorded: stopsRecorded,
|
|
65
|
+
stops,
|
|
66
|
+
sqm_proof_ids: loopSqmProofIds(entry),
|
|
67
|
+
recorded_at: new Date(now).toISOString(),
|
|
68
|
+
};
|
|
69
|
+
const problem = loopOutcomeRecordProblem(record);
|
|
70
|
+
return problem ? { skipped: `invalid:${problem}` } : { record };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isIso = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/u.test(value) && Number.isFinite(Date.parse(value));
|
|
74
|
+
const sameKeys = (value, keys) => value && typeof value === "object" && !Array.isArray(value) &&
|
|
75
|
+
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
76
|
+
|
|
77
|
+
// Mirrors 8.1 (unknown keys are rejected, value ranges enforced) so a record
|
|
78
|
+
// the backend would refuse never enters the queue.
|
|
79
|
+
export function loopOutcomeRecordProblem(record) {
|
|
80
|
+
if (!sameKeys(record, LOOP_OUTCOME_KEYS)) return "keys";
|
|
81
|
+
if (record.contract_version !== LOOP_OUTCOME_CONTRACT) return "contract_version";
|
|
82
|
+
if (typeof record.conversation_ref !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(record.conversation_ref)) return "conversation_ref";
|
|
83
|
+
if (!PROVIDERS.has(record.provider)) return "provider";
|
|
84
|
+
if (record.model !== null && (typeof record.model !== "string" || !record.model || record.model.length > 100)) return "model";
|
|
85
|
+
if (!isIso(record.loop_started_at)) return "loop_started_at";
|
|
86
|
+
if (record.ended_at !== null && !isIso(record.ended_at)) return "ended_at";
|
|
87
|
+
if (!LOOP_OUTCOME_FINAL_STATES.includes(record.final_state)) return "final_state";
|
|
88
|
+
if ((record.final_state === "active") !== (record.ended_at === null)) return "ended_at";
|
|
89
|
+
if (!Number.isSafeInteger(record.continuations) || record.continuations < 0) return "continuations";
|
|
90
|
+
if (record.tokens_used !== null && (!Number.isSafeInteger(record.tokens_used) || record.tokens_used < 0)) return "tokens_used";
|
|
91
|
+
if (typeof record.stops_recorded !== "boolean") return "stops_recorded";
|
|
92
|
+
if (!Array.isArray(record.stops) || record.stops.length > MAX_LOOP_STOPS) return "stops";
|
|
93
|
+
if (!record.stops_recorded && record.stops.length) return "stops";
|
|
94
|
+
let previous = 0;
|
|
95
|
+
for (const stop of record.stops) {
|
|
96
|
+
if (!sameKeys(stop, LOOP_OUTCOME_STOP_KEYS)) return "stops";
|
|
97
|
+
if (!Number.isSafeInteger(stop.index) || stop.index <= previous) return "stops";
|
|
98
|
+
if (!LOOP_STOP_KINDS.includes(stop.kind)) return "stops";
|
|
99
|
+
if (![true, false, null].includes(stop.criteria_met)) return "stops";
|
|
100
|
+
if (!isIso(stop.at)) return "stops";
|
|
101
|
+
previous = stop.index;
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(record.sqm_proof_ids) || record.sqm_proof_ids.length > MAX_PROOF_IDS ||
|
|
104
|
+
new Set(record.sqm_proof_ids).size !== record.sqm_proof_ids.length ||
|
|
105
|
+
record.sqm_proof_ids.some(id => typeof id !== "string" || !/^SQM-PROOF-[0-9a-f-]{36}$/iu.test(id))) return "sqm_proof_ids";
|
|
106
|
+
if (!isIso(record.recorded_at)) return "recorded_at";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 8.2: 201 { outcome_id, created: true, recorded_at } or 200 { ..., created: false }.
|
|
111
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
112
|
+
export function loopOutcomeReceiptProblem(status, body) {
|
|
113
|
+
if (![200, 201].includes(status)) return "status";
|
|
114
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return "body";
|
|
115
|
+
if (typeof body.outcome_id !== "string" || !UUID.test(body.outcome_id)) return "outcome_id";
|
|
116
|
+
if (typeof body.created !== "boolean" || body.created !== (status === 201)) return "created";
|
|
117
|
+
if (!isIso(body.recorded_at)) return "recorded_at";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Change detection for the queue: everything but the creation time.
|
|
122
|
+
export function loopOutcomeDigest(record) {
|
|
123
|
+
const { recorded_at: _recordedAt, ...rest } = record;
|
|
124
|
+
return createHash("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Content-free tallies for `backfill --dry-run`.
|
|
128
|
+
export function summarizeLoopOutcomes(records, skipped = {}) {
|
|
129
|
+
const finalStates = Object.fromEntries(LOOP_OUTCOME_FINAL_STATES.map(state => [state, 0]));
|
|
130
|
+
const providers = { codex: 0, claude: 0 };
|
|
131
|
+
let continuations = 0, tokensKnown = 0, tokens = 0, stopsRecorded = 0;
|
|
132
|
+
for (const record of records) {
|
|
133
|
+
finalStates[record.final_state]++; providers[record.provider]++;
|
|
134
|
+
continuations += record.continuations;
|
|
135
|
+
if (record.tokens_used !== null) { tokensKnown++; tokens += record.tokens_used; }
|
|
136
|
+
if (record.stops_recorded) stopsRecorded++;
|
|
137
|
+
}
|
|
138
|
+
return { conversations: records.length, providers, final_states: finalStates,
|
|
139
|
+
continuations_total: continuations, tokens_known: tokensKnown, tokens_total: tokens,
|
|
140
|
+
stops_recorded: stopsRecorded, skipped };
|
|
141
|
+
}
|
package/src/sqm/proof.js
CHANGED
|
@@ -37,7 +37,8 @@ export function verifyMinCheck(value) {
|
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString() }) {
|
|
40
|
+
export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString(), conversationRef = null }) {
|
|
41
|
+
if (conversationRef !== null && !/^sha256:[0-9a-f]{64}$/.test(conversationRef)) throw new Error("SQM conversation reference is invalid.");
|
|
41
42
|
const enforcingModules = evaluation.modules.filter((module) => module.enforcement === "blocking");
|
|
42
43
|
const enforcingFindings = findings.filter((finding) => finding.enforcement === "blocking");
|
|
43
44
|
const statuses = enforcingModules.flatMap((module) => module.scenarios.map((scenario) => scenario.status));
|
|
@@ -91,7 +92,9 @@ export function buildProof({ organizationId, repository, bundle, minCheck, evalu
|
|
|
91
92
|
...(requiresPostDeploy ? ["post_deploy_evidence_required"] : []),
|
|
92
93
|
],
|
|
93
94
|
},
|
|
94
|
-
|
|
95
|
+
// conversation_ref is set only for checks inside a Delivery Loop conversation
|
|
96
|
+
// and is covered by the proof digest and per-run signature.
|
|
97
|
+
provenance: { cli_version: cliVersion, executed_at: executedAt, executor: "simy-cli", ...(conversationRef ? { conversation_ref: conversationRef } : {}) },
|
|
95
98
|
};
|
|
96
99
|
return { ...proof, integrity: { algorithm: "sha256", digest: taggedDigest(proof) } };
|
|
97
100
|
}
|