@relayflows/sdk 2.0.42 → 2.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/base.d.ts +12 -1
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/base.js +11 -1
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +8 -4
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/codex.d.ts.map +1 -1
- package/dist/adapters/codex.js +15 -6
- package/dist/adapters/codex.js.map +1 -1
- package/dist/agent-resume.d.ts +8 -0
- package/dist/agent-resume.d.ts.map +1 -0
- package/dist/agent-resume.js +37 -0
- package/dist/agent-resume.js.map +1 -0
- package/dist/agent-transcript.d.ts.map +1 -1
- package/dist/agent-transcript.js +5 -3
- package/dist/agent-transcript.js.map +1 -1
- package/dist/authored-flow-error.d.ts +10 -2
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +16 -2
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts +6 -0
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +18 -6
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-helper-effect.js +3 -2
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-human.d.ts +2 -0
- package/dist/authored-human.d.ts.map +1 -1
- package/dist/authored-human.js +4 -0
- package/dist/authored-human.js.map +1 -1
- package/dist/authored-memory.d.ts +47 -2
- package/dist/authored-memory.d.ts.map +1 -1
- package/dist/authored-memory.js +394 -15
- package/dist/authored-memory.js.map +1 -1
- package/dist/authored-node-entry.js +6 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +6 -3
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root-readback.d.ts +21 -0
- package/dist/authored-root-readback.d.ts.map +1 -0
- package/dist/authored-root-readback.js +90 -0
- package/dist/authored-root-readback.js.map +1 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +53 -74
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-output.d.ts +2 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +1 -0
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +26 -4
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-helper-body.d.ts +2 -2
- package/dist/cli/check-helper-body.d.ts.map +1 -1
- package/dist/cli/check-helper-body.js +7 -2
- package/dist/cli/check-helper-body.js.map +1 -1
- package/dist/cli/check-helper-surface.d.ts +8 -0
- package/dist/cli/check-helper-surface.d.ts.map +1 -0
- package/dist/cli/check-helper-surface.js +37 -0
- package/dist/cli/check-helper-surface.js.map +1 -0
- package/dist/cli/check-triggers.d.ts +4 -3
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +7 -3
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +7 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +35 -8
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-answer.d.ts +9 -0
- package/dist/cli/cloud-answer.d.ts.map +1 -0
- package/dist/cli/cloud-answer.js +39 -0
- package/dist/cli/cloud-answer.js.map +1 -0
- package/dist/cli/cloud-connect-cli.d.ts +2 -1
- package/dist/cli/cloud-connect-cli.d.ts.map +1 -1
- package/dist/cli/cloud-connect-cli.js +10 -2
- package/dist/cli/cloud-connect-cli.js.map +1 -1
- package/dist/cli/cloud-human.d.ts +7 -0
- package/dist/cli/cloud-human.d.ts.map +1 -0
- package/dist/cli/cloud-human.js +22 -0
- package/dist/cli/cloud-human.js.map +1 -0
- package/dist/cli/cloud-live.d.ts +2 -2
- package/dist/cli/cloud-live.d.ts.map +1 -1
- package/dist/cli/cloud-live.js +9 -2
- package/dist/cli/cloud-live.js.map +1 -1
- package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
- package/dist/cli/cloud-mirror-session.js +3 -2
- package/dist/cli/cloud-mirror-session.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +1 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +12 -0
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/detached-record.d.ts +22 -0
- package/dist/cli/detached-record.d.ts.map +1 -0
- package/dist/cli/detached-record.js +57 -0
- package/dist/cli/detached-record.js.map +1 -0
- package/dist/cli/detached-run.d.ts +28 -0
- package/dist/cli/detached-run.d.ts.map +1 -0
- package/dist/cli/detached-run.js +122 -0
- package/dist/cli/detached-run.js.map +1 -0
- package/dist/cli/diagnostic-fold.d.ts +19 -0
- package/dist/cli/diagnostic-fold.d.ts.map +1 -0
- package/dist/cli/diagnostic-fold.js +19 -0
- package/dist/cli/diagnostic-fold.js.map +1 -0
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +16 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/eval.d.ts +22 -0
- package/dist/cli/eval.d.ts.map +1 -0
- package/dist/cli/eval.js +131 -0
- package/dist/cli/eval.js.map +1 -0
- package/dist/cli/journal-timeout.d.ts +12 -0
- package/dist/cli/journal-timeout.d.ts.map +1 -0
- package/dist/cli/journal-timeout.js +42 -0
- package/dist/cli/journal-timeout.js.map +1 -0
- package/dist/cli/local-agent-remedy.d.ts.map +1 -1
- package/dist/cli/local-agent-remedy.js +5 -4
- package/dist/cli/local-agent-remedy.js.map +1 -1
- package/dist/cli/observer-session.d.ts +1 -0
- package/dist/cli/observer-session.d.ts.map +1 -1
- package/dist/cli/observer-session.js +8 -1
- package/dist/cli/observer-session.js.map +1 -1
- package/dist/cli/reuse.d.ts +8 -0
- package/dist/cli/reuse.d.ts.map +1 -1
- package/dist/cli/reuse.js +24 -0
- package/dist/cli/reuse.js.map +1 -1
- package/dist/cli/run.d.ts +8 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +72 -56
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/running-step.d.ts +9 -0
- package/dist/cli/running-step.d.ts.map +1 -0
- package/dist/cli/running-step.js +156 -0
- package/dist/cli/running-step.js.map +1 -0
- package/dist/cli-adapter.d.ts +3 -1
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +6 -2
- package/dist/cli-adapter.js.map +1 -1
- package/dist/cli-commands.d.ts +77 -11
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +41 -7
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli-watch.d.ts +6 -2
- package/dist/cli-watch.d.ts.map +1 -1
- package/dist/cli-watch.js +7 -6
- package/dist/cli-watch.js.map +1 -1
- package/dist/cli.d.ts +22 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +154 -50
- package/dist/cli.js.map +1 -1
- package/dist/cloud-answer.d.ts +35 -0
- package/dist/cloud-answer.d.ts.map +1 -0
- package/dist/cloud-answer.js +194 -0
- package/dist/cloud-answer.js.map +1 -0
- package/dist/cloud-auth-store.d.ts +46 -0
- package/dist/cloud-auth-store.d.ts.map +1 -0
- package/dist/cloud-auth-store.js +171 -0
- package/dist/cloud-auth-store.js.map +1 -0
- package/dist/cloud-http.d.ts +7 -16
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +59 -40
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-human-wait.d.ts +10 -0
- package/dist/cloud-human-wait.d.ts.map +1 -0
- package/dist/cloud-human-wait.js +17 -0
- package/dist/cloud-human-wait.js.map +1 -0
- package/dist/cloud-mirror-transport.d.ts.map +1 -1
- package/dist/cloud-mirror-transport.js +1 -0
- package/dist/cloud-mirror-transport.js.map +1 -1
- package/dist/cloud-run-record.d.ts +7 -2
- package/dist/cloud-run-record.d.ts.map +1 -1
- package/dist/cloud-run-record.js +13 -5
- package/dist/cloud-run-record.js.map +1 -1
- package/dist/cloud-run.d.ts +9 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +36 -2
- package/dist/cloud-run.js.map +1 -1
- package/dist/communication/local.d.ts.map +1 -1
- package/dist/communication/local.js +6 -1
- package/dist/communication/local.js.map +1 -1
- package/dist/communication/worker.d.ts.map +1 -1
- package/dist/communication/worker.js +12 -2
- package/dist/communication/worker.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -3
- package/dist/compile.js.map +1 -1
- package/dist/daemon-spec-validation.d.ts +31 -0
- package/dist/daemon-spec-validation.d.ts.map +1 -0
- package/dist/daemon-spec-validation.js +115 -0
- package/dist/daemon-spec-validation.js.map +1 -0
- package/dist/failure-kinds.d.ts +6 -4
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +17 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-eval-cloud.d.ts +25 -0
- package/dist/flow-eval-cloud.d.ts.map +1 -0
- package/dist/flow-eval-cloud.js +108 -0
- package/dist/flow-eval-cloud.js.map +1 -0
- package/dist/flow-eval-error.d.ts +7 -0
- package/dist/flow-eval-error.d.ts.map +1 -0
- package/dist/flow-eval-error.js +10 -0
- package/dist/flow-eval-error.js.map +1 -0
- package/dist/flow-eval-gate.d.ts +15 -0
- package/dist/flow-eval-gate.d.ts.map +1 -0
- package/dist/flow-eval-gate.js +102 -0
- package/dist/flow-eval-gate.js.map +1 -0
- package/dist/flow-eval-local.d.ts +54 -0
- package/dist/flow-eval-local.d.ts.map +1 -0
- package/dist/flow-eval-local.js +220 -0
- package/dist/flow-eval-local.js.map +1 -0
- package/dist/flow-eval-outcome.d.ts +22 -0
- package/dist/flow-eval-outcome.d.ts.map +1 -0
- package/dist/flow-eval-outcome.js +37 -0
- package/dist/flow-eval-outcome.js.map +1 -0
- package/dist/flow-eval-report.d.ts +106 -0
- package/dist/flow-eval-report.d.ts.map +1 -0
- package/dist/flow-eval-report.js +4 -0
- package/dist/flow-eval-report.js.map +1 -0
- package/dist/flow-eval-suite.d.ts +46 -0
- package/dist/flow-eval-suite.d.ts.map +1 -0
- package/dist/flow-eval-suite.js +144 -0
- package/dist/flow-eval-suite.js.map +1 -0
- package/dist/flow-eval-version.d.ts +59 -0
- package/dist/flow-eval-version.d.ts.map +1 -0
- package/dist/flow-eval-version.js +303 -0
- package/dist/flow-eval-version.js.map +1 -0
- package/dist/flow-eval.d.ts +41 -0
- package/dist/flow-eval.d.ts.map +1 -0
- package/dist/flow-eval.js +201 -0
- package/dist/flow-eval.js.map +1 -0
- package/dist/helper-operation-use.d.ts +18 -0
- package/dist/helper-operation-use.d.ts.map +1 -0
- package/dist/helper-operation-use.js +336 -0
- package/dist/helper-operation-use.js.map +1 -0
- package/dist/helper-preflight.d.ts.map +1 -1
- package/dist/helper-preflight.js +16 -2
- package/dist/helper-preflight.js.map +1 -1
- package/dist/helper-reference.d.ts +33 -0
- package/dist/helper-reference.d.ts.map +1 -1
- package/dist/helper-reference.js +5 -5
- package/dist/helper-reference.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/journal-budgeted-reads.d.ts +41 -0
- package/dist/journal-budgeted-reads.d.ts.map +1 -0
- package/dist/journal-budgeted-reads.js +142 -0
- package/dist/journal-budgeted-reads.js.map +1 -0
- package/dist/journal-client.d.ts +34 -29
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +45 -151
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-connection.d.ts +42 -0
- package/dist/journal-connection.d.ts.map +1 -0
- package/dist/journal-connection.js +209 -0
- package/dist/journal-connection.js.map +1 -0
- package/dist/journal-read-policy.d.ts +38 -0
- package/dist/journal-read-policy.d.ts.map +1 -0
- package/dist/journal-read-policy.js +126 -0
- package/dist/journal-read-policy.js.map +1 -0
- package/dist/named-gate-lowering.d.ts.map +1 -1
- package/dist/named-gate-lowering.js +2 -6
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/re2js-embedded.d.ts +3 -0
- package/dist/re2js-embedded.d.ts.map +1 -0
- package/dist/re2js-embedded.js +4 -0
- package/dist/re2js-embedded.js.map +1 -0
- package/dist/spec.d.ts +10 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-fields.d.ts +1 -1
- package/dist/step-fields.js +1 -1
- package/dist/step-fields.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +15 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +3 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +15 -5
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-lease.d.ts.map +1 -1
- package/dist/worker-lease.js +11 -3
- package/dist/worker-lease.js.map +1 -1
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/package.json +5 -3
- package/src/adapters/base.ts +16 -1
- package/src/adapters/claude.ts +8 -4
- package/src/adapters/codex.ts +15 -6
- package/src/agent-resume.ts +36 -0
- package/src/agent-transcript.ts +5 -3
- package/src/authored-flow-error.ts +9 -0
- package/src/authored-flow-executor.ts +18 -1
- package/src/authored-flow-loader.ts +27 -10
- package/src/authored-helper-effect.ts +3 -2
- package/src/authored-human.ts +5 -0
- package/src/authored-memory.ts +445 -15
- package/src/authored-node-entry.ts +6 -1
- package/src/authored-node-runner.ts +6 -3
- package/src/authored-root-readback.ts +100 -0
- package/src/authored-root.ts +58 -85
- package/src/authored-step-output.ts +3 -0
- package/src/authored-worker-step.ts +29 -4
- package/src/cli/check-helper-body.ts +9 -3
- package/src/cli/check-helper-surface.ts +43 -0
- package/src/cli/check-triggers.ts +9 -4
- package/src/cli/check.ts +37 -7
- package/src/cli/cloud-answer.ts +40 -0
- package/src/cli/cloud-connect-cli.ts +9 -2
- package/src/cli/cloud-human.ts +24 -0
- package/src/cli/cloud-live.ts +10 -5
- package/src/cli/cloud-mirror-session.ts +3 -2
- package/src/cli/cloud-run.ts +10 -1
- package/src/cli/detached-record.ts +71 -0
- package/src/cli/detached-run.ts +131 -0
- package/src/cli/diagnostic-fold.ts +28 -0
- package/src/cli/direct-run.ts +14 -1
- package/src/cli/eval.ts +141 -0
- package/src/cli/journal-timeout.ts +49 -0
- package/src/cli/local-agent-remedy.ts +6 -4
- package/src/cli/observer-session.ts +7 -1
- package/src/cli/reuse.ts +25 -0
- package/src/cli/run.ts +75 -67
- package/src/cli/running-step.ts +140 -0
- package/src/cli-adapter.ts +7 -1
- package/src/cli-commands.ts +43 -7
- package/src/cli-watch.ts +8 -5
- package/src/cli.ts +158 -51
- package/src/cloud-answer.ts +202 -0
- package/src/cloud-auth-store.ts +185 -0
- package/src/cloud-http.ts +61 -50
- package/src/cloud-human-wait.ts +23 -0
- package/src/cloud-mirror-transport.ts +1 -0
- package/src/cloud-run-record.ts +14 -5
- package/src/cloud-run.ts +46 -2
- package/src/communication/local.ts +6 -1
- package/src/communication/worker.ts +11 -3
- package/src/compile.ts +5 -3
- package/src/daemon-spec-validation.ts +126 -0
- package/src/failure-kinds.ts +17 -1
- package/src/flow-eval-cloud.ts +114 -0
- package/src/flow-eval-error.ts +10 -0
- package/src/flow-eval-gate.ts +114 -0
- package/src/flow-eval-local.ts +236 -0
- package/src/flow-eval-outcome.ts +43 -0
- package/src/flow-eval-report.ts +102 -0
- package/src/flow-eval-suite.ts +169 -0
- package/src/flow-eval-version.ts +316 -0
- package/src/flow-eval.ts +234 -0
- package/src/helper-operation-use.ts +285 -0
- package/src/helper-preflight.ts +17 -2
- package/src/helper-reference.ts +6 -6
- package/src/index.ts +15 -1
- package/src/journal-budgeted-reads.ts +134 -0
- package/src/journal-client.ts +59 -166
- package/src/journal-connection.ts +222 -0
- package/src/journal-read-policy.ts +123 -0
- package/src/named-gate-lowering.ts +2 -6
- package/src/re2js-embedded.ts +3 -0
- package/src/spec.ts +10 -0
- package/src/step-fields.ts +1 -1
- package/src/validate.ts +14 -0
- package/src/worker-cli.ts +14 -3
- package/src/worker-lease.ts +11 -3
- package/src/worker.ts +1 -1
package/src/authored-human.ts
CHANGED
|
@@ -145,3 +145,8 @@ export function resumeCommand(runId: string, dataDir?: string, localAgent = fals
|
|
|
145
145
|
const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
|
|
146
146
|
return `flows resume${dir}${localAgent ? ' --local-agent' : ''} ${runId}`;
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
/** Cloud discovers the open wait, then records the answer and resumes. */
|
|
150
|
+
export function cloudAnswerCommand(runId: string): string {
|
|
151
|
+
return `flows answer --cloud ${shellWord(runId)} yes|no`;
|
|
152
|
+
}
|
package/src/authored-memory.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { MemoryHelper } from '@relayflows/surface';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { access, stat } from 'node:fs/promises';
|
|
1
|
+
import type { HistoryEntry, MemoryFinding, MemoryHelper, MemoryRecallOptions, TrajectoryEntry } from '@relayflows/surface';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
7
|
+
import type { JournalClient } from './journal-client.js';
|
|
7
8
|
import { preflightMemory } from './preflight.js';
|
|
8
9
|
|
|
9
10
|
/** Stable across runs; disjoint from other flow files and other named flows. */
|
|
@@ -27,29 +28,458 @@ export async function assertMemoryReachable(): Promise<void> {
|
|
|
27
28
|
if (refusal) throw new AuthoredFlowExecutionError('memory_unreachable', refusal.message);
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The promise `learn` returns. Its constructor is not `Promise`, so `await`,
|
|
33
|
+
* `Promise.all` and friends reach it through `then`, which records that the
|
|
34
|
+
* body consumed the result.
|
|
35
|
+
*/
|
|
36
|
+
class LearnCall extends Promise<void> {
|
|
37
|
+
static override get [Symbol.species](): PromiseConstructor { return Promise; }
|
|
38
|
+
observed = false;
|
|
39
|
+
settled = false;
|
|
40
|
+
override then<A = void, B = never>(
|
|
41
|
+
onFulfilled?: ((value: void) => A | PromiseLike<A>) | null,
|
|
42
|
+
onRejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
43
|
+
): Promise<A | B> {
|
|
44
|
+
this.observed = true;
|
|
45
|
+
return super.then(onFulfilled, onRejected);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Lets the executor find `learn` calls whose outcome the body never consumed. */
|
|
50
|
+
export class MemoryWriteTracker {
|
|
51
|
+
private readonly calls: LearnCall[] = [];
|
|
52
|
+
track(write: Promise<void>): Promise<void> {
|
|
53
|
+
const call = new LearnCall((resolve, reject) => { void write.then(resolve, reject); });
|
|
54
|
+
// Bypass the override: marking the rejection handled must not count as the body consuming it.
|
|
55
|
+
void Promise.prototype.then.call(call, () => { call.settled = true; }, () => { call.settled = true; });
|
|
56
|
+
this.calls.push(call);
|
|
57
|
+
return call;
|
|
58
|
+
}
|
|
59
|
+
/** Wait for every call to settle, so no write outlives the execution that started it. */
|
|
60
|
+
async settle(): Promise<void> {
|
|
61
|
+
await Promise.allSettled(this.calls.map(call => Promise.prototype.then.call(call, () => {}, () => {})));
|
|
62
|
+
}
|
|
63
|
+
/** Every call the body did not await, after letting each one settle. */
|
|
64
|
+
async unawaited(): Promise<number> {
|
|
65
|
+
const unobserved = this.calls.filter(call => !call.observed || !call.settled);
|
|
66
|
+
await Promise.allSettled(unobserved.map(call => Promise.prototype.then.call(call, () => {}, () => {})));
|
|
67
|
+
return unobserved.length;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Root-run stream holding every finding `learn` committed: the source of truth the trajectory files are projected from. */
|
|
72
|
+
export const MEMORY_LEARN_STREAM = 'memory-learn';
|
|
73
|
+
|
|
74
|
+
export interface LearnRecord {
|
|
75
|
+
readonly memory: 'learn';
|
|
76
|
+
readonly id: string;
|
|
77
|
+
readonly scope: string;
|
|
78
|
+
readonly decision: Required<MemoryFinding>;
|
|
79
|
+
readonly learnedAt: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CommittedLearn {
|
|
83
|
+
readonly record: LearnRecord;
|
|
84
|
+
readonly runId: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where `learn` journals a finding before it may become recallable. */
|
|
88
|
+
export interface LearnLedger {
|
|
89
|
+
/** The durable root run this ledger journals on. */
|
|
90
|
+
readonly runId: string;
|
|
91
|
+
/** The commit already recorded for `id`, if any. */
|
|
92
|
+
recorded(id: string): Promise<CommittedLearn | undefined>;
|
|
93
|
+
/**
|
|
94
|
+
* Journal `record`, then return the commit that wins for its id: the first
|
|
95
|
+
* one in the stream. Usually that is `record`; if another attempt on this
|
|
96
|
+
* root committed the same finding first, it is theirs, so every attempt
|
|
97
|
+
* projects the same bytes.
|
|
98
|
+
*/
|
|
99
|
+
append(record: LearnRecord): Promise<CommittedLearn>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function canonicalLearnRecord(raw: unknown): LearnRecord | undefined {
|
|
103
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
104
|
+
const value = raw as Record<string, unknown>;
|
|
105
|
+
const decision = value.decision as Record<string, unknown> | undefined;
|
|
106
|
+
if (value.memory !== 'learn' || typeof value.id !== 'string' || typeof value.scope !== 'string'
|
|
107
|
+
|| typeof value.learnedAt !== 'string' || typeof decision !== 'object' || decision === null) return undefined;
|
|
108
|
+
const { question, chosen, reasoning, alternatives } = decision;
|
|
109
|
+
if (typeof question !== 'string' || typeof chosen !== 'string' || typeof reasoning !== 'string'
|
|
110
|
+
|| !Array.isArray(alternatives) || alternatives.some(item => typeof item !== 'string')) return undefined;
|
|
111
|
+
// Fixed field order, never the record as it came back: the journal stores
|
|
112
|
+
// stream messages with sorted keys, and the projected file must be
|
|
113
|
+
// byte-identical whether it is written now or republished on resume.
|
|
114
|
+
return {
|
|
115
|
+
memory: 'learn', id: value.id, scope: value.scope,
|
|
116
|
+
decision: { question, chosen, reasoning, alternatives: [...alternatives] as string[] },
|
|
117
|
+
learnedAt: value.learnedAt,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A ledger on the durable root run's `memory-learn` stream. The stream is read
|
|
123
|
+
* once per execution and re-read after each append, so a resumed or re-driven
|
|
124
|
+
* body finds what any earlier attempt committed. A failed read is not cached:
|
|
125
|
+
* the next call reads again. Journal errors propagate unchanged, so a read
|
|
126
|
+
* interruption or a lost lease keeps its resumable meaning for the root.
|
|
127
|
+
*/
|
|
128
|
+
export function journalLearnLedger(journal: Pick<JournalClient, 'streamAppend' | 'streamRead'>, rootRunId: string): LearnLedger {
|
|
129
|
+
let loaded: Promise<Map<string, CommittedLearn>> | undefined;
|
|
130
|
+
async function read(): Promise<Map<string, CommittedLearn>> {
|
|
131
|
+
const commits = new Map<string, CommittedLearn>();
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (;;) {
|
|
134
|
+
const page = await journal.streamRead(rootRunId, MEMORY_LEARN_STREAM, offset, 1000);
|
|
135
|
+
for (const message of page.messages) {
|
|
136
|
+
const record = canonicalLearnRecord((message as { message?: unknown }).message ?? message);
|
|
137
|
+
// The first commit for an id wins; it is the one its file is built from.
|
|
138
|
+
if (record !== undefined && !commits.has(record.id)) commits.set(record.id, { record, runId: rootRunId });
|
|
139
|
+
}
|
|
140
|
+
if (page.messages.length === 0 || page.next_offset <= offset) break;
|
|
141
|
+
offset = page.next_offset;
|
|
142
|
+
}
|
|
143
|
+
return commits;
|
|
144
|
+
}
|
|
145
|
+
function load(): Promise<Map<string, CommittedLearn>> {
|
|
146
|
+
loaded ??= read().catch(error => { loaded = undefined; throw error; });
|
|
147
|
+
return loaded;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
runId: rootRunId,
|
|
151
|
+
async recorded(id) {
|
|
152
|
+
return (await load()).get(id);
|
|
153
|
+
},
|
|
154
|
+
async append(record) {
|
|
155
|
+
await journal.streamAppend(rootRunId, MEMORY_LEARN_STREAM, record);
|
|
156
|
+
loaded = undefined;
|
|
157
|
+
return (await load()).get(record.id) ?? { record, runId: rootRunId };
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
30
162
|
export function authoredMemory(
|
|
31
163
|
scope: string,
|
|
32
164
|
assertOpen: () => void,
|
|
33
165
|
enabled: boolean,
|
|
166
|
+
tracker?: MemoryWriteTracker,
|
|
167
|
+
ledger?: LearnLedger,
|
|
34
168
|
): MemoryHelper {
|
|
35
|
-
|
|
36
|
-
assertOpen();
|
|
169
|
+
function assertEnabled(): void {
|
|
37
170
|
if (!enabled) throw new AuthoredFlowExecutionError('unsupported_header', 'f.memory requires script memory; memory.script is false');
|
|
171
|
+
}
|
|
172
|
+
async function read<T>(action: (reader: import('ai-hist').AiHist, learned: LearnedTrajectory[]) => T): Promise<T> {
|
|
173
|
+
assertOpen();
|
|
174
|
+
assertEnabled();
|
|
38
175
|
await assertMemoryReachable();
|
|
39
176
|
assertOpen();
|
|
40
|
-
const { openAiHist } = await import('ai-hist');
|
|
177
|
+
const { defaultDbPath, openAiHist } = await import('ai-hist');
|
|
178
|
+
const learned = await readLearned(defaultDbPath(), scope);
|
|
41
179
|
const reader = await openAiHist({ projectScope: scope, fallback: 'error' });
|
|
42
|
-
try { return action(reader); } finally { reader.close(); }
|
|
180
|
+
try { return action(reader, learned); } finally { reader.close(); }
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Journal first, then publish. The trajectory file is staged under a name
|
|
184
|
+
* no read lists, the finding is committed to the root run's `memory-learn`
|
|
185
|
+
* stream, and only then is the file renamed into place. Nothing is
|
|
186
|
+
* recallable that the journal does not record, and the failures a disk
|
|
187
|
+
* write can have (space, permissions) happen while staging, before anything
|
|
188
|
+
* is journaled. If the process dies between the commit and the rename, the
|
|
189
|
+
* resumed body re-runs `learn`, finds the commit, and publishes the same
|
|
190
|
+
* file from it without journaling again. With no durable root run there is
|
|
191
|
+
* nowhere to journal, so `learn` refuses rather than write unrecorded memory.
|
|
192
|
+
*/
|
|
193
|
+
const learning = new Map<string, Promise<void>>();
|
|
194
|
+
async function learnOnce(decision: Required<MemoryFinding>, journaled: LearnLedger): Promise<void> {
|
|
195
|
+
const { dbPath, mode } = await learnTarget();
|
|
196
|
+
const id = learnedId(scope, decision);
|
|
197
|
+
const existing = await journaled.recorded(id);
|
|
198
|
+
if (existing !== undefined) {
|
|
199
|
+
await publishLearned(dbPath, existing, mode);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const record: LearnRecord = { memory: 'learn', id, scope, decision, learnedAt: new Date().toISOString() };
|
|
203
|
+
let staged: StagedLearned;
|
|
204
|
+
try {
|
|
205
|
+
staged = await stageLearned(dbPath, { record, runId: journaled.runId }, mode);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn could not stage the finding: ${error instanceof Error ? error.message : String(error)}`);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
// The last point a body that already called done() can be refused: past
|
|
211
|
+
// here the finding is journaled and will be published.
|
|
212
|
+
assertOpen();
|
|
213
|
+
const winner = await journaled.append(record);
|
|
214
|
+
try {
|
|
215
|
+
await staged.publish(winner);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn journaled the finding (root run ${winner.runId}, stream ${MEMORY_LEARN_STREAM}, id ${id}) but could not publish it: ${error instanceof Error ? error.message : String(error)}`);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
await staged.discard();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function learn(finding: MemoryFinding): Promise<void> {
|
|
224
|
+
assertOpen();
|
|
225
|
+
assertEnabled();
|
|
226
|
+
const decision = normalizeFinding(finding);
|
|
227
|
+
if (ledger === undefined) {
|
|
228
|
+
throw new AuthoredFlowExecutionError('memory_unjournaled', 'f.memory.learn requires a durable root run to journal the finding; run the flow through flows run');
|
|
229
|
+
}
|
|
230
|
+
const id = learnedId(scope, decision);
|
|
231
|
+
// Concurrent learns of one finding in this execution share one commit.
|
|
232
|
+
let pending = learning.get(id);
|
|
233
|
+
if (pending === undefined) {
|
|
234
|
+
pending = learnOnce(decision, ledger);
|
|
235
|
+
learning.set(id, pending);
|
|
236
|
+
void pending.then(() => learning.delete(id), () => learning.delete(id));
|
|
237
|
+
}
|
|
238
|
+
await pending;
|
|
43
239
|
}
|
|
44
240
|
return {
|
|
45
|
-
recall: (query, options) => read(reader => reader
|
|
46
|
-
why: task => read(reader => {
|
|
47
|
-
const
|
|
48
|
-
|
|
241
|
+
recall: (query, options) => read((reader, learned) => recallMerged(reader, learned, scope, query, options)),
|
|
242
|
+
why: task => read((reader, learned) => {
|
|
243
|
+
const synced = reader.whyForTask(task);
|
|
244
|
+
const candidates = [
|
|
245
|
+
...(synced?.projectId === scope ? [synced] : []),
|
|
246
|
+
...learned.map(entry => entry.trajectory).filter(entry => trajectoryMatches(entry, task)),
|
|
247
|
+
];
|
|
248
|
+
const newest = candidates.sort((a, b) => b.timestampMs - a.timestampMs)[0];
|
|
249
|
+
return newest === undefined ? [] : [newest];
|
|
49
250
|
}),
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
251
|
+
learn(finding) {
|
|
252
|
+
const write = learn(finding);
|
|
253
|
+
return tracker === undefined ? write : tracker.track(write);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeFinding(finding: MemoryFinding): Required<MemoryFinding> {
|
|
259
|
+
const text = (value: unknown, field: string): string => {
|
|
260
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
261
|
+
throw new AuthoredFlowExecutionError('memory_finding_invalid', `f.memory.learn requires a non-empty string ${field}`);
|
|
262
|
+
}
|
|
263
|
+
return value;
|
|
264
|
+
};
|
|
265
|
+
const alternatives = finding?.alternatives ?? [];
|
|
266
|
+
if (!Array.isArray(alternatives) || alternatives.some(item => typeof item !== 'string')) {
|
|
267
|
+
throw new AuthoredFlowExecutionError('memory_finding_invalid', 'f.memory.learn alternatives must be an array of strings');
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
question: text(finding?.question, 'question'),
|
|
271
|
+
chosen: text(finding?.chosen, 'chosen'),
|
|
272
|
+
reasoning: text(finding?.reasoning, 'reasoning'),
|
|
273
|
+
alternatives: [...alternatives],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Learned findings live beside the ai-hist database, not beside the flow
|
|
279
|
+
* source: a flow deployed from a read-only checkout, package or image can
|
|
280
|
+
* still learn. The directory is keyed by the scope's hash, and each file
|
|
281
|
+
* still names the scope as its `projectId`. Reads here do not need
|
|
282
|
+
* `ai-hist sync`. For sync to index them too, add `<db-dir>/flows-memory` to
|
|
283
|
+
* `TRAJECTORY_ROOT` together with the roots it already scans: setting it
|
|
284
|
+
* replaces ai-hist's default `~/Projects` scan rather than adding to it.
|
|
285
|
+
*/
|
|
286
|
+
const learnedDir = (dbPath: string, scope: string): string =>
|
|
287
|
+
join(dirname(dbPath), 'flows-memory', basename(scope), '.trajectories', 'compacted');
|
|
288
|
+
const LEARNED_PREFIX = 'flows-memory-';
|
|
289
|
+
|
|
290
|
+
const learnedId = (scope: string, decision: Required<MemoryFinding>): string =>
|
|
291
|
+
`${LEARNED_PREFIX}${createHash('sha256').update(JSON.stringify([scope, decision])).digest('hex').slice(0, 32)}`;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `learn` never reads the ai-hist database, so it does not load it: it needs
|
|
295
|
+
* only the directory it lives in and its mode. A missing database is the same
|
|
296
|
+
* refusal reads give.
|
|
297
|
+
*/
|
|
298
|
+
async function learnTarget(): Promise<{ dbPath: string; mode: number }> {
|
|
299
|
+
const { defaultDbPath } = await import('ai-hist');
|
|
300
|
+
const dbPath = defaultDbPath();
|
|
301
|
+
const info = await stat(dbPath).catch(() => undefined);
|
|
302
|
+
if (info === undefined || !info.isFile()) {
|
|
303
|
+
throw new AuthoredFlowExecutionError('memory_unreachable', `memory database ${dbPath} is missing or not a file`);
|
|
304
|
+
}
|
|
305
|
+
return { dbPath, mode: info.mode & 0o777 };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function learnedContent({ record, runId }: CommittedLearn): string {
|
|
309
|
+
return `${JSON.stringify({
|
|
310
|
+
id: record.id,
|
|
311
|
+
version: 1,
|
|
312
|
+
type: 'compacted',
|
|
313
|
+
projectId: record.scope,
|
|
314
|
+
task: { title: record.decision.question, description: null },
|
|
315
|
+
status: 'completed',
|
|
316
|
+
startedAt: record.learnedAt,
|
|
317
|
+
completedAt: record.learnedAt,
|
|
318
|
+
decisions: [record.decision],
|
|
319
|
+
retrospective: { summary: null, approach: null, learnings: [], confidence: null },
|
|
320
|
+
journal: { runId, stream: MEMORY_LEARN_STREAM, id: record.id },
|
|
321
|
+
}, null, 2)}\n`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface StagedLearned {
|
|
325
|
+
/** Rename into place, rewriting first if the winning commit is not the one staged. */
|
|
326
|
+
publish(winner: CommittedLearn): Promise<void>;
|
|
327
|
+
discard(): Promise<void>;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Write a committed finding's compacted trajectory file under a temporary
|
|
332
|
+
* name. Its content is derived only from the commit (including when it was
|
|
333
|
+
* learned and which root run journaled it), so every publication of one
|
|
334
|
+
* commit writes the same bytes. The final name is a hash of scope and
|
|
335
|
+
* finding, so re-learning a finding atomically replaces one file. The file
|
|
336
|
+
* takes the database's mode, so a private history does not leak findings
|
|
337
|
+
* through a world-readable file. The ai-hist database is never modified.
|
|
338
|
+
*/
|
|
339
|
+
async function stageLearned(dbPath: string, staging: CommittedLearn, mode: number): Promise<StagedLearned> {
|
|
340
|
+
const file = join(learnedDir(dbPath, staging.record.scope), `${staging.record.id}.json`);
|
|
341
|
+
await mkdir(dirname(file), { recursive: true });
|
|
342
|
+
const staged = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
343
|
+
let content = learnedContent(staging);
|
|
344
|
+
const write = async () => {
|
|
345
|
+
await writeFile(staged, content, { mode });
|
|
346
|
+
await chmod(staged, mode);
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
await write();
|
|
350
|
+
} catch (error) {
|
|
351
|
+
await rm(staged, { force: true });
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
async publish(winner) {
|
|
356
|
+
const winning = learnedContent(winner);
|
|
357
|
+
if (winning !== content) {
|
|
358
|
+
content = winning;
|
|
359
|
+
await write();
|
|
360
|
+
}
|
|
361
|
+
await rename(staged, file);
|
|
53
362
|
},
|
|
363
|
+
discard: () => rm(staged, { force: true }),
|
|
54
364
|
};
|
|
55
365
|
}
|
|
366
|
+
|
|
367
|
+
async function publishLearned(dbPath: string, committed: CommittedLearn, mode: number): Promise<void> {
|
|
368
|
+
let staged: StagedLearned;
|
|
369
|
+
try {
|
|
370
|
+
staged = await stageLearned(dbPath, committed, mode);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn could not publish journaled finding ${committed.record.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
await staged.publish(committed);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn could not publish journaled finding ${committed.record.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
378
|
+
} finally {
|
|
379
|
+
await staged.discard();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface LearnedTrajectory {
|
|
384
|
+
trajectory: TrajectoryEntry;
|
|
385
|
+
history: HistoryEntry;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** This scope's learned findings, read straight from disk so they need no `ai-hist sync`. */
|
|
389
|
+
async function readLearned(dbPath: string, scope: string): Promise<LearnedTrajectory[]> {
|
|
390
|
+
const dir = learnedDir(dbPath, scope);
|
|
391
|
+
let names: string[];
|
|
392
|
+
try { names = await readdir(dir); } catch (error) {
|
|
393
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
const learned = await Promise.all(names
|
|
397
|
+
.filter(name => name.startsWith(LEARNED_PREFIX) && name.endsWith('.json'))
|
|
398
|
+
.map(async name => {
|
|
399
|
+
const path = join(dir, name);
|
|
400
|
+
try {
|
|
401
|
+
return learnedEntry(scope, path, await readFile(path, 'utf8'), Math.trunc((await stat(path)).mtimeMs));
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
404
|
+
// Files here are only ever published whole, so an unreadable one is
|
|
405
|
+
// real damage; recalling around it would silently drop journaled memory.
|
|
406
|
+
throw new AuthoredFlowExecutionError('memory_unreachable', `learned finding ${path} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
407
|
+
}
|
|
408
|
+
}));
|
|
409
|
+
return learned.filter((entry): entry is LearnedTrajectory => entry !== undefined);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function learnedEntry(scope: string, path: string, text: string, updatedMs: number): LearnedTrajectory {
|
|
413
|
+
const raw: unknown = JSON.parse(text);
|
|
414
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('not a trajectory object');
|
|
415
|
+
const value = raw as Record<string, unknown>;
|
|
416
|
+
const decisions = value.decisions;
|
|
417
|
+
if (typeof value.id !== 'string' || !Array.isArray(decisions)) throw new Error('missing id or decisions');
|
|
418
|
+
const parsed = decisions.map(decision => {
|
|
419
|
+
const d = decision as Record<string, unknown> | null;
|
|
420
|
+
if (typeof d !== 'object' || d === null || typeof d.question !== 'string' || typeof d.chosen !== 'string'
|
|
421
|
+
|| typeof d.reasoning !== 'string' || !Array.isArray(d.alternatives) || d.alternatives.some(a => typeof a !== 'string')) {
|
|
422
|
+
throw new Error('malformed decision');
|
|
423
|
+
}
|
|
424
|
+
return { question: d.question, chosen: d.chosen, reasoning: d.reasoning, alternatives: d.alternatives as string[] };
|
|
425
|
+
});
|
|
426
|
+
const task = (typeof value.task === 'object' && value.task !== null ? value.task : {}) as Record<string, unknown>;
|
|
427
|
+
const title = typeof task.title === 'string' ? task.title : null;
|
|
428
|
+
const description = typeof task.description === 'string' ? task.description : null;
|
|
429
|
+
const id = value.id;
|
|
430
|
+
const completedAt = typeof value.completedAt === 'string' ? value.completedAt : null;
|
|
431
|
+
const timestampMs = completedAt !== null && Number.isFinite(Date.parse(completedAt)) ? Date.parse(completedAt) : updatedMs;
|
|
432
|
+
// Same field order ai-hist's buildSearchText uses, so matching agrees with a later sync.
|
|
433
|
+
const searchText = [id, scope, 'completed', title, description,
|
|
434
|
+
...parsed.flatMap(d => [d.question, d.chosen, d.reasoning, ...d.alternatives])]
|
|
435
|
+
.filter((part): part is string => typeof part === 'string' && part.length > 0).join('\n');
|
|
436
|
+
return {
|
|
437
|
+
trajectory: {
|
|
438
|
+
id, version: 1, personaId: null, projectId: scope,
|
|
439
|
+
task: { title, description },
|
|
440
|
+
status: 'completed', startedAt: typeof value.startedAt === 'string' ? value.startedAt : null, completedAt,
|
|
441
|
+
decisions: parsed, retrospective: { summary: null, approach: null, learnings: [], confidence: null },
|
|
442
|
+
searchText, path, updatedMs, timestampMs,
|
|
443
|
+
},
|
|
444
|
+
// Not yet in the ai-hist database, so it has no row id; a negative id
|
|
445
|
+
// derived from the trajectory id marks it as read from disk.
|
|
446
|
+
history: {
|
|
447
|
+
id: -Number.parseInt(createHash('sha256').update(id).digest('hex').slice(0, 12), 16),
|
|
448
|
+
source: 'trajectory', sessionId: id, project: scope, prompt: searchText, timestampMs, gitBranch: null,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const includesFolded = (haystack: string | null | undefined, needle: string): boolean =>
|
|
454
|
+
(haystack ?? '').toLowerCase().includes(needle.toLowerCase());
|
|
455
|
+
|
|
456
|
+
function trajectoryMatches(entry: TrajectoryEntry, query: string): boolean {
|
|
457
|
+
const needle = query.trim();
|
|
458
|
+
if (needle === '') return false;
|
|
459
|
+
return [entry.searchText, entry.task.title, entry.task.description, entry.personaId, entry.projectId]
|
|
460
|
+
.some(field => includesFolded(field, needle));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** ai-hist's `search` semantics over the database plus this scope's not-yet-synced findings. */
|
|
464
|
+
function recallMerged(
|
|
465
|
+
reader: import('ai-hist').AiHist,
|
|
466
|
+
learned: LearnedTrajectory[],
|
|
467
|
+
scope: string,
|
|
468
|
+
query: string,
|
|
469
|
+
options: MemoryRecallOptions | undefined,
|
|
470
|
+
): HistoryEntry[] {
|
|
471
|
+
const limit = options?.limit ?? 50;
|
|
472
|
+
const needle = query.trim();
|
|
473
|
+
const fromDisk = learned.map(entry => entry.history).filter(entry =>
|
|
474
|
+
(needle === '' || includesFolded(entry.prompt, needle) || includesFolded(entry.project, needle))
|
|
475
|
+
&& (options?.source === undefined || options.source === 'trajectory')
|
|
476
|
+
&& options?.tag === undefined
|
|
477
|
+
&& (options?.beforeMs === undefined || entry.timestampMs < options.beforeMs));
|
|
478
|
+
// Fetching `limit + fromDisk.length` rows guarantees a synced copy of any
|
|
479
|
+
// disk entry that makes the cut is among them, so it dedupes correctly.
|
|
480
|
+
const synced = reader.search(query, { ...options, project: scope, limit: limit + fromDisk.length });
|
|
481
|
+
const syncedIds = new Set(synced.filter(entry => entry.source === 'trajectory').map(entry => entry.sessionId));
|
|
482
|
+
return [...synced, ...fromDisk.filter(entry => !syncedIds.has(entry.sessionId))]
|
|
483
|
+
.sort((a, b) => b.timestampMs - a.timestampMs)
|
|
484
|
+
.slice(0, limit);
|
|
485
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FLOW_READ_BUDGET_MS, isReadInterruptionError } from './journal-read-policy.js';
|
|
1
2
|
import { Worker } from 'node:worker_threads';
|
|
2
3
|
import { createHash, createHmac } from 'node:crypto';
|
|
3
4
|
import { readFileSync, writeSync } from 'node:fs';
|
|
@@ -74,7 +75,7 @@ try {
|
|
|
74
75
|
const loaded = await loadPinnedAuthoredSource(request.metadata, true);
|
|
75
76
|
if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
|
|
76
77
|
if (request.workerCapacity !== undefined && !isAgentCapacity(request.workerCapacity)) throw new Error('invalid authored worker capacity');
|
|
77
|
-
client = new JournalClient(request.socketPath);
|
|
78
|
+
client = new JournalClient(request.socketPath, { readBudgetMs: FLOW_READ_BUDGET_MS, budgetHandshake: true });
|
|
78
79
|
await client.connect(); await client.hello('flows-authored-node');
|
|
79
80
|
const result = await executeAuthoredFlow(loaded.handle, client,
|
|
80
81
|
request.metadata.inputPresent ? request.metadata.input : undefined, {
|
|
@@ -99,6 +100,10 @@ try {
|
|
|
99
100
|
// `parkCause` travels for the same reason: it is the difference between
|
|
100
101
|
// "attach a worker" and "a human has to recover this", and this process is
|
|
101
102
|
// the only one that saw the child's classification.
|
|
103
|
+
// Preserve a read interruption across the Node/Bun error frame so the
|
|
104
|
+
// lease-owning parent can leave the root resumable.
|
|
105
|
+
...(isReadInterruptionError(error)
|
|
106
|
+
? { code: 'daemon_unresponsive' } : {}),
|
|
102
107
|
...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
|
|
103
108
|
completionReason: error.completionReason, runId: error.runId,
|
|
104
109
|
details: error.details,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { JournalClient } from './journal-client.js';
|
|
2
|
+
import { FLOW_READ_BUDGET_MS } from './journal-read-policy.js';
|
|
2
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
4
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
5
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
@@ -171,7 +172,7 @@ export async function runAuthoredInNode(
|
|
|
171
172
|
else resolve(result);
|
|
172
173
|
});
|
|
173
174
|
});
|
|
174
|
-
await verifyAuthoredNodeResult(result, metadata, rootRunId, socketPath);
|
|
175
|
+
await verifyAuthoredNodeResult(result, metadata, rootRunId, socketPath, options.signal);
|
|
175
176
|
return result;
|
|
176
177
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
177
178
|
}
|
|
@@ -323,7 +324,7 @@ function frameCount(frame: Record<string, unknown>, key: string): number | undef
|
|
|
323
324
|
/** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
|
|
324
325
|
export async function verifyAuthoredNodeResult(
|
|
325
326
|
result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
|
|
326
|
-
rootRunId: string, socketPath: string,
|
|
327
|
+
rootRunId: string, socketPath: string, signal?: AbortSignal,
|
|
327
328
|
): Promise<void> {
|
|
328
329
|
const invalid = (why = ''): never => { throw new Error(`authored runtime result has no matching durable completion${process.env['FLOWS_VERIFIER_DEBUG'] && why ? ` (${why})` : ''}`); };
|
|
329
330
|
if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
|
|
@@ -354,7 +355,9 @@ export async function verifyAuthoredNodeResult(
|
|
|
354
355
|
if (!authoredIds.has(gate.id.slice(0, -'.gate'.length))) invalid(`orphan ${gate.id}`);
|
|
355
356
|
}
|
|
356
357
|
const runs = new Set<string>();
|
|
357
|
-
|
|
358
|
+
// The verifier reads the same journal under the same CPU load as the body.
|
|
359
|
+
const journal = new JournalClient(socketPath, { readBudgetMs: FLOW_READ_BUDGET_MS, budgetHandshake: true,
|
|
360
|
+
...(signal === undefined ? {} : { readSignal: signal }) });
|
|
358
361
|
await journal.connect();
|
|
359
362
|
try {
|
|
360
363
|
await journal.hello('flows-authored-result-verifier');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isDurableCompletionDetail, isLoweredCompletion } from './authored-completion.js';
|
|
2
|
+
import type { AuthoredFlowExecutionResult } from './authored-flow-executor.js';
|
|
3
|
+
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
4
|
+
import { isSurfaceCompletionReason } from './authored-step-output.js';
|
|
5
|
+
import { JournalFrameError, JournalProtocolError, type JournalClient } from './journal-client.js';
|
|
6
|
+
import type { RunOutcome } from './protocol.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Reading an authored root back from its journal, and what a failed read
|
|
10
|
+
* means. Only a definite answer from the daemon may change how a run is
|
|
11
|
+
* treated; an unanswered, canceled or failed read decides nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const NO_ROOT_STEP = new Error('authored root journal has no root step');
|
|
15
|
+
|
|
16
|
+
export async function rootKernelStep(journal: JournalClient, runId: string): Promise<Record<string, unknown>> {
|
|
17
|
+
const entries = (await journal.journalRead(runId, 1)).entries as Array<Record<string, unknown>>;
|
|
18
|
+
const spawned = entries.find(entry => entry.entry_type === 'run.spawned');
|
|
19
|
+
const payload = spawned?.payload as { spec?: { steps?: unknown[] } } | undefined;
|
|
20
|
+
const step = payload?.spec?.steps?.[0];
|
|
21
|
+
if (typeof step !== 'object' || step === null || Array.isArray(step)) throw NO_ROOT_STEP;
|
|
22
|
+
return step as Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Codes relayflowd uses when it failed to read its own storage (server/protocol.rs). */
|
|
26
|
+
const DAEMON_READ_FAILURES = new Set(['journal_write_failed', 'internal']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The root step of `runId`, or undefined when the daemon definitively answered
|
|
30
|
+
* that it has none: a refusal of the read (run_not_found, an unreadable early
|
|
31
|
+
* epoch, ...) or a journal without a root step. A daemon-side read failure, an
|
|
32
|
+
* interruption or a cancellation propagates instead, so a delayed, canceled or
|
|
33
|
+
* failing daemon never makes resume guess the declarative path.
|
|
34
|
+
*/
|
|
35
|
+
export async function rootStepIfPresent(journal: JournalClient, runId: string): Promise<Record<string, unknown> | undefined> {
|
|
36
|
+
return rootKernelStep(journal, runId).catch(error => {
|
|
37
|
+
if ((error instanceof JournalProtocolError && !DAEMON_READ_FAILURES.has(error.code)) || error === NO_ROOT_STEP) return undefined;
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The stored result of a root the daemon already reports completed. If reading
|
|
44
|
+
* it back is interrupted, the outcome is still known: say so, rather than
|
|
45
|
+
* recasting a finished run as resumable.
|
|
46
|
+
*/
|
|
47
|
+
export function completedResult(journal: JournalClient, outcome: RunOutcome, rootRunId: string): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
|
|
48
|
+
return completedRootResult(journal, rootRunId).catch(error => {
|
|
49
|
+
// A bad frame or a journal with no stored result is a definite fact; any
|
|
50
|
+
// other failure — interrupted, canceled, or a daemon-side read error such
|
|
51
|
+
// as journal_write_failed — left the verdict unread, not the run unfinished.
|
|
52
|
+
if (error instanceof JournalFrameError || error === NO_DURABLE_RESULT) throw error;
|
|
53
|
+
// The kernel completes the root step with success whatever the body
|
|
54
|
+
// declared; the authored verdict lives only in the unread output.
|
|
55
|
+
const unread = new AuthoredFlowExecutionError('result_unreadable',
|
|
56
|
+
`run ${rootRunId} completed, but its stored result (the flow's verdict) could not be read: ${error.message}`,
|
|
57
|
+
undefined, rootRunId);
|
|
58
|
+
unread.rootRunId = rootRunId;
|
|
59
|
+
throw unread;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const NO_DURABLE_RESULT = new Error('completed authored root has no durable result');
|
|
64
|
+
|
|
65
|
+
async function completedRootResult(
|
|
66
|
+
journal: JournalClient,
|
|
67
|
+
rootRunId: string,
|
|
68
|
+
): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
|
|
69
|
+
const entries = (await journal.journalRead(rootRunId, 1)).entries as Array<Record<string, unknown>>;
|
|
70
|
+
const completed = entries.slice().reverse().find(entry => entry.entry_type === 'step.completed'
|
|
71
|
+
&& entry.step_id === 'authored-root'
|
|
72
|
+
&& (entry.payload as { completionReason?: unknown } | undefined)?.completionReason === 'success');
|
|
73
|
+
const output = (completed?.payload as { output?: unknown } | undefined)?.output;
|
|
74
|
+
if (!isCompletedRootOutput(output)) throw NO_DURABLE_RESULT;
|
|
75
|
+
// The stored detail, never a freshly computed one: redaction reads the
|
|
76
|
+
// CURRENT environment, so recomputing here would let a completed run report
|
|
77
|
+
// something its journal does not hold.
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
name: output.name,
|
|
80
|
+
completionReason: output.completionReason,
|
|
81
|
+
...(output.completionDetail === undefined ? {} : { completionDetail: output.completionDetail }),
|
|
82
|
+
journalSteps: Object.freeze(output.journalSteps.map(step => Object.freeze({ ...step }))),
|
|
83
|
+
rootRunId,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isCompletedRootOutput(value: unknown): value is Omit<AuthoredFlowExecutionResult, 'rootRunId'> {
|
|
88
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
89
|
+
const output = value as Partial<AuthoredFlowExecutionResult>;
|
|
90
|
+
return typeof output.name === 'string'
|
|
91
|
+
&& isLoweredCompletion(output.completionReason)
|
|
92
|
+
// A malformed detail fails the whole readback rather than being dropped:
|
|
93
|
+
// silently discarding it would report the verdict without the evidence
|
|
94
|
+
// the journal says it was recorded with.
|
|
95
|
+
&& (output.completionDetail === undefined || isDurableCompletionDetail(output.completionDetail))
|
|
96
|
+
&& Array.isArray(output.journalSteps)
|
|
97
|
+
&& output.journalSteps.every(step => typeof step === 'object' && step !== null
|
|
98
|
+
&& typeof step.id === 'string' && typeof step.runId === 'string'
|
|
99
|
+
&& isSurfaceCompletionReason(step.completionReason));
|
|
100
|
+
}
|