@inflexa-ai/harness 0.20.0 → 0.21.1
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/agents/conversation-agent.d.ts +26 -14
- package/dist/agents/conversation-agent.js +12 -23
- package/dist/agents/report-session-agent.d.ts +31 -5
- package/dist/agents/report-session-agent.js +35 -9
- package/dist/app/chat-turn.js +3 -1
- package/dist/app/message-assembly.d.ts +15 -1
- package/dist/app/message-assembly.js +22 -8
- package/dist/app/report-session-runtime.d.ts +21 -4
- package/dist/app/report-session-runtime.js +53 -6
- package/dist/app/spawn-report-session.d.ts +174 -17
- package/dist/app/spawn-report-session.js +233 -21
- package/dist/contracts/chat-events.d.ts +7 -2
- package/dist/contracts/message.d.ts +10 -0
- package/dist/contracts/report-blocks.d.ts +743 -19
- package/dist/contracts/report-blocks.js +186 -11
- package/dist/contracts/report-reference.d.ts +88 -0
- package/dist/contracts/report-reference.js +48 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/lib/chrome.d.ts +26 -6
- package/dist/lib/chrome.js +99 -32
- package/dist/lib/eyes.d.ts +57 -0
- package/dist/lib/eyes.js +45 -0
- package/dist/lib/page-capture.d.ts +14 -0
- package/dist/lib/page-capture.js +56 -2
- package/dist/loop/run-agent.js +58 -23
- package/dist/loop/tool-detail.d.ts +19 -2
- package/dist/loop/tool-detail.js +35 -2
- package/dist/memory/content-to-cortex.d.ts +3 -2
- package/dist/memory/content-to-cortex.js +21 -12
- package/dist/memory/conversation-display-replay.js +14 -7
- package/dist/memory/thread-history.d.ts +60 -3
- package/dist/memory/thread-history.js +55 -16
- package/dist/memory/thread-store.d.ts +11 -1
- package/dist/memory/thread-store.js +12 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +23 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +190 -9
- package/dist/report-model/authoring-grammar.d.ts +463 -0
- package/dist/report-model/authoring-grammar.js +77 -0
- package/dist/report-model/block-walk.d.ts +57 -14
- package/dist/report-model/block-walk.js +116 -11
- package/dist/report-model/draft-finish.d.ts +22 -4
- package/dist/report-model/draft-finish.js +39 -7
- package/dist/report-model/draft-operations.d.ts +3 -0
- package/dist/report-model/draft-operations.js +19 -5
- package/dist/report-model/draft-read.d.ts +8 -5
- package/dist/report-model/draft-read.js +21 -5
- package/dist/report-model/fixture-resolver.js +15 -4
- package/dist/report-model/pin-snapshot.d.ts +24 -6
- package/dist/report-model/pin-snapshot.js +204 -6
- package/dist/report-model/production-resolver.js +15 -4
- package/dist/report-model/reference-resolver.d.ts +50 -1
- package/dist/report-model/reference-resolver.js +94 -1
- package/dist/report-model/reference-stamp.d.ts +45 -0
- package/dist/report-model/reference-stamp.js +173 -0
- package/dist/report-model/structural-validation.d.ts +4 -1
- package/dist/report-model/structural-validation.js +48 -3
- package/dist/report-model/validate.d.ts +1 -1
- package/dist/report-model/validate.js +1 -1
- package/dist/report-render/asset-lookup.d.ts +19 -0
- package/dist/report-render/asset-lookup.js +37 -0
- package/dist/report-render/assets.d.ts +45 -1
- package/dist/report-render/assets.js +65 -6
- package/dist/report-render/chart-presets.d.ts +102 -0
- package/dist/report-render/chart-presets.js +205 -0
- package/dist/report-render/chart.d.ts +112 -10
- package/dist/report-render/chart.js +1164 -41
- package/dist/report-render/design.d.ts +99 -0
- package/dist/report-render/design.js +289 -107
- package/dist/report-render/number-format.d.ts +117 -0
- package/dist/report-render/number-format.js +479 -0
- package/dist/report-render/page.d.ts +121 -5
- package/dist/report-render/page.js +689 -7
- package/dist/report-render/references.d.ts +70 -16
- package/dist/report-render/references.js +54 -22
- package/dist/report-render/render.d.ts +23 -5
- package/dist/report-render/render.js +81 -22
- package/dist/report-render/table-data.d.ts +92 -0
- package/dist/report-render/table-data.js +129 -0
- package/dist/report-render/types.d.ts +15 -0
- package/dist/report-render/value-bridge.js +6 -2
- package/dist/report-render/views/chart-view.d.ts +12 -5
- package/dist/report-render/views/chart-view.js +14 -8
- package/dist/report-render/views/page-view.d.ts +36 -15
- package/dist/report-render/views/page-view.js +45 -23
- package/dist/report-render/views/prose.d.ts +12 -2
- package/dist/report-render/views/prose.js +29 -7
- package/dist/report-render/views/references-view.d.ts +22 -8
- package/dist/report-render/views/references-view.js +101 -13
- package/dist/report-render/views/values.d.ts +128 -13
- package/dist/report-render/views/values.js +199 -18
- package/dist/runtime/assemble.d.ts +39 -5
- package/dist/runtime/assemble.js +47 -6
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +9 -4
- package/dist/sandbox/create-sandbox.js +22 -16
- package/dist/sandbox/docker-client.js +10 -7
- package/dist/sandbox/mount-plan.d.ts +45 -7
- package/dist/sandbox/mount-plan.js +43 -5
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/state/init.js +38 -9
- package/dist/state/report-session-state.d.ts +42 -1
- package/dist/state/report-session-state.js +70 -2
- package/dist/state/report-versions.d.ts +22 -17
- package/dist/state/report-versions.js +48 -35
- package/dist/state/snapshot-parse.js +16 -1
- package/dist/tools/define-tool.d.ts +32 -0
- package/dist/tools/define-tool.js +4 -0
- package/dist/tools/detail-resolver.d.ts +7 -2
- package/dist/tools/detail-resolver.js +7 -2
- package/dist/tools/iterate-report.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
- package/dist/tools/report-authoring/authoring-tools.js +99 -18
- package/dist/tools/report-session/derive-table.d.ts +162 -0
- package/dist/tools/report-session/derive-table.js +459 -0
- package/dist/tools/report-session/examine-page.d.ts +48 -18
- package/dist/tools/report-session/examine-page.js +204 -24
- package/dist/tools/report-session/index.d.ts +2 -0
- package/dist/tools/report-session/index.js +2 -0
- package/dist/tools/report-session/list-artifacts.d.ts +97 -0
- package/dist/tools/report-session/list-artifacts.js +197 -0
- package/dist/tools/report-session/preview-report.d.ts +6 -0
- package/dist/tools/report-session/preview-report.js +180 -28
- package/dist/tools/report-session/record-version.d.ts +22 -10
- package/dist/tools/report-session/record-version.js +99 -14
- package/dist/tools/start-report-session.d.ts +115 -0
- package/dist/tools/start-report-session.js +179 -0
- package/dist/workspace/paths.d.ts +50 -0
- package/dist/workspace/paths.js +63 -0
- package/package.json +2 -1
|
@@ -120,18 +120,23 @@ export interface CreateSandboxClientConfig {
|
|
|
120
120
|
*/
|
|
121
121
|
export declare function composeAwaitOptions(base: AwaitExecOptions | undefined, transport: SandboxTransport, isAlive: NonNullable<AwaitExecOptions["isAlive"]>): AwaitExecOptions;
|
|
122
122
|
/**
|
|
123
|
-
* Backend-agnostic pre-creation of the writable
|
|
123
|
+
* Backend-agnostic pre-creation of the writable tree under the analysis's
|
|
124
124
|
* resolved workspace root. On Docker that root is the host dir the container
|
|
125
125
|
* binds; on K8s it lives under the session PVC the sandbox pod mounts via
|
|
126
126
|
* subPath. `mkdir(recursive)` is idempotent, so an existing tree (replay, retry)
|
|
127
|
-
* is not an error. A read-only sandbox has no writable
|
|
127
|
+
* is not an error. A read-only sandbox has no writable mount, so there is
|
|
128
128
|
* nothing to pre-create.
|
|
129
129
|
*
|
|
130
|
-
*
|
|
130
|
+
* The writable tree is the step directory with its five artifact subdirs, or the
|
|
131
|
+
* declared write tail as one directory. `sandboxWriteTail` decides which, so the
|
|
132
|
+
* directory the host makes and the directory the sandbox mounts are the same one,
|
|
133
|
+
* and the run path keeps the tree it always had.
|
|
134
|
+
*
|
|
135
|
+
* `stepTreeAccess: "world-writable"` chmods the directory and each subdir so the
|
|
131
136
|
* uid-1000 sandbox workload can write them through engines that honor host bind
|
|
132
137
|
* ownership. The chmod is explicit, not `mkdir`'s `mode` option: the process
|
|
133
138
|
* umask masks `mkdir`'s mode, and on replay the dirs already exist so `mkdir`
|
|
134
|
-
* would not touch them either way. Scoped to the
|
|
139
|
+
* would not touch them either way. Scoped to the write tree — the
|
|
135
140
|
* read-only mount sources are never re-moded. Exported for tests.
|
|
136
141
|
*/
|
|
137
142
|
export declare function precreateStepTree(deps: {
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
import { chmod, mkdir } from "node:fs/promises";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { clampResources } from "../config/resource-limits.js";
|
|
17
|
-
import {
|
|
17
|
+
import { tailWritePrefix } from "../workspace/paths.js";
|
|
18
18
|
import { tryMutation } from "../lib/db-result.js";
|
|
19
19
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
20
20
|
import { clearSandboxRef, setSandboxRef } from "../state/index.js";
|
|
21
21
|
import { awaitExec } from "./await-exec.js";
|
|
22
22
|
import { createDockerSandboxOps } from "./docker-client.js";
|
|
23
23
|
import { createK8sSandboxOps } from "./k8s-client.js";
|
|
24
|
-
import {
|
|
24
|
+
import { sandboxWriteTail } from "./mount-plan.js";
|
|
25
25
|
import { submitExec } from "./submit-exec.js";
|
|
26
26
|
import { toPersistedRef } from "./types.js";
|
|
27
27
|
/**
|
|
@@ -35,33 +35,38 @@ export function composeAwaitOptions(base, transport, isAlive) {
|
|
|
35
35
|
return { isAlive, ...base, transport };
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
* Backend-agnostic pre-creation of the writable
|
|
38
|
+
* Backend-agnostic pre-creation of the writable tree under the analysis's
|
|
39
39
|
* resolved workspace root. On Docker that root is the host dir the container
|
|
40
40
|
* binds; on K8s it lives under the session PVC the sandbox pod mounts via
|
|
41
41
|
* subPath. `mkdir(recursive)` is idempotent, so an existing tree (replay, retry)
|
|
42
|
-
* is not an error. A read-only sandbox has no writable
|
|
42
|
+
* is not an error. A read-only sandbox has no writable mount, so there is
|
|
43
43
|
* nothing to pre-create.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
45
|
+
* The writable tree is the step directory with its five artifact subdirs, or the
|
|
46
|
+
* declared write tail as one directory. `sandboxWriteTail` decides which, so the
|
|
47
|
+
* directory the host makes and the directory the sandbox mounts are the same one,
|
|
48
|
+
* and the run path keeps the tree it always had.
|
|
49
|
+
*
|
|
50
|
+
* `stepTreeAccess: "world-writable"` chmods the directory and each subdir so the
|
|
46
51
|
* uid-1000 sandbox workload can write them through engines that honor host bind
|
|
47
52
|
* ownership. The chmod is explicit, not `mkdir`'s `mode` option: the process
|
|
48
53
|
* umask masks `mkdir`'s mode, and on replay the dirs already exist so `mkdir`
|
|
49
|
-
* would not touch them either way. Scoped to the
|
|
54
|
+
* would not touch them either way. Scoped to the write tree — the
|
|
50
55
|
* read-only mount sources are never re-moded. Exported for tests.
|
|
51
56
|
*/
|
|
52
57
|
export async function precreateStepTree(deps, meta) {
|
|
53
|
-
|
|
58
|
+
const write = sandboxWriteTail(meta);
|
|
59
|
+
if (write === undefined)
|
|
54
60
|
return;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
await mkdir(
|
|
61
|
-
await Promise.all(STEP_SUBDIRS.map((sub) => mkdir(join(stepDir, sub), { recursive: true })));
|
|
61
|
+
// `tailWritePrefix` (not a raw `join`) so every segment runs through the same
|
|
62
|
+
// id validation the mount builders apply — a crafted tail cannot escape the
|
|
63
|
+
// resolved root.
|
|
64
|
+
const writeDir = tailWritePrefix({ workspaceRoot: deps.resolveWorkspaceRoot(meta.analysisId), tail: write.tail });
|
|
65
|
+
await mkdir(writeDir, { recursive: true });
|
|
66
|
+
await Promise.all(write.subdirs.map((sub) => mkdir(join(writeDir, sub), { recursive: true })));
|
|
62
67
|
if (deps.stepTreeAccess === "world-writable") {
|
|
63
|
-
await chmod(
|
|
64
|
-
await Promise.all(
|
|
68
|
+
await chmod(writeDir, 0o777);
|
|
69
|
+
await Promise.all(write.subdirs.map((sub) => chmod(join(writeDir, sub), 0o777)));
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
export function createSandboxClient(config) {
|
|
@@ -122,6 +127,7 @@ export function createSandboxClient(config) {
|
|
|
122
127
|
// poll loop's escalation probe are the same backend inspect.
|
|
123
128
|
const isAlive = async (ref) => unwrapOrThrow(await ops.isAlive(ref));
|
|
124
129
|
return {
|
|
130
|
+
transport,
|
|
125
131
|
createSandbox: async (meta, identity) => {
|
|
126
132
|
// The config states an engine fact (binds preserve host ownership);
|
|
127
133
|
// which remediation that fact demands — today, a world-writable step
|
|
@@ -33,9 +33,9 @@ import { join } from "node:path";
|
|
|
33
33
|
import Docker from "dockerode";
|
|
34
34
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
35
35
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
36
|
-
import {
|
|
36
|
+
import { tailWritePrefix } from "../workspace/paths.js";
|
|
37
37
|
import { trySandbox } from "./sandbox-error.js";
|
|
38
|
-
import { buildMountPlan } from "./mount-plan.js";
|
|
38
|
+
import { buildMountPlan, sandboxWriteTail } from "./mount-plan.js";
|
|
39
39
|
/** Read the originating HTTP status off any `SandboxError` variant that carries one. */
|
|
40
40
|
function statusOf(e) {
|
|
41
41
|
return "status" in e ? e.status : undefined;
|
|
@@ -223,13 +223,16 @@ export function createDockerSandboxOps(config) {
|
|
|
223
223
|
refs: refsMounted,
|
|
224
224
|
});
|
|
225
225
|
const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
|
|
226
|
-
// `
|
|
227
|
-
// the same
|
|
228
|
-
// cannot escape the resolved root into the container.
|
|
229
|
-
|
|
226
|
+
// `tailWritePrefix` (not a raw `join`) so the RW bind source runs through
|
|
227
|
+
// the same validation as the pre-created write tree — a crafted stepId or
|
|
228
|
+
// a crafted tail cannot escape the resolved root into the container. The
|
|
229
|
+
// tail is the step directory, or the one the caller declared.
|
|
230
|
+
const write = sandboxWriteTail(meta);
|
|
230
231
|
const binds = [
|
|
231
232
|
`${hostTreePath}:${plan.readonlyTreePath}:ro`,
|
|
232
|
-
...(
|
|
233
|
+
...(write && plan.writableStepPath
|
|
234
|
+
? [`${tailWritePrefix({ workspaceRoot: hostTreePath, tail: write.tail })}:${plan.writableStepPath}:rw`]
|
|
235
|
+
: []),
|
|
233
236
|
...(libsMounted && config.libStorePath ? [`${config.libStorePath}:${plan.libsPath}:ro`] : []),
|
|
234
237
|
...(refsMounted && config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
|
|
235
238
|
];
|
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
* artifacts, lib store read-only at `/mnt/libs`, ref store read-only at
|
|
11
11
|
* `/mnt/refs`.
|
|
12
12
|
*
|
|
13
|
+
* A caller can declare its own `writableTail` in place of the step tail — a
|
|
14
|
+
* workspace-relative path that becomes the one read-write mount, with no step
|
|
15
|
+
* subdirectories under it. The session derivation declares one; the run path
|
|
16
|
+
* declares none and keeps the step tail. Thus a sandbox writes into the step
|
|
17
|
+
* directory or into the one declared tail, and nowhere else.
|
|
18
|
+
*
|
|
19
|
+
* The precedence is a refusal, not an order: `readOnly` and `writableTail` state
|
|
20
|
+
* opposite things about the same mount, thus a coordinate set that carries both
|
|
21
|
+
* throws here — the same as a crafted id. No caller can hold both and get a
|
|
22
|
+
* silently chosen winner.
|
|
23
|
+
*
|
|
13
24
|
* Container paths are a function of `resourceId` alone — they never carry the
|
|
14
25
|
* host location of the tree. Where that tree physically lives is the embedder's
|
|
15
26
|
* (`resolveWorkspaceRoot`); the two are reconciled per backend: Docker binds the
|
|
@@ -34,6 +45,14 @@ export interface MountPlanCoords {
|
|
|
34
45
|
* a generic read-only agent that must not mutate analysis files.
|
|
35
46
|
*/
|
|
36
47
|
readOnly?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The declared write tail: a workspace-relative path that takes the place of the
|
|
50
|
+
* step tail as the one read-write mount, with no step subdirectories under it.
|
|
51
|
+
* Each segment passes the safe-id discipline of the step builder. Absent keeps
|
|
52
|
+
* the step tail, thus the run path is unchanged. A tail beside `readOnly` is a
|
|
53
|
+
* contradiction, and the builders refuse it.
|
|
54
|
+
*/
|
|
55
|
+
writableTail?: string;
|
|
37
56
|
}
|
|
38
57
|
export interface MountPlanStores {
|
|
39
58
|
/** Lib store is mounted at `/mnt/libs` and its env is emitted. */
|
|
@@ -45,20 +64,21 @@ export interface MountPlan {
|
|
|
45
64
|
/** Flat read-only mount of the whole analysis tree. */
|
|
46
65
|
readonlyTreePath: string;
|
|
47
66
|
/**
|
|
48
|
-
* Nested read-write mount
|
|
49
|
-
* Undefined for a read-only sandbox —
|
|
50
|
-
* WorkingDir falls back to the read-only tree
|
|
67
|
+
* Nested read-write mount, also the WorkingDir: the step's artifact directory,
|
|
68
|
+
* or the declared write tail in its place. Undefined for a read-only sandbox —
|
|
69
|
+
* no writable mount exists; the WorkingDir falls back to the read-only tree
|
|
70
|
+
* root.
|
|
51
71
|
*/
|
|
52
72
|
writableStepPath?: string;
|
|
53
|
-
/** Container WorkingDir: the writable
|
|
73
|
+
/** Container WorkingDir: the writable mount, or the RO tree root in
|
|
54
74
|
* read-only mode. */
|
|
55
75
|
workingDir: string;
|
|
56
76
|
/** Container path of the lib store, present only when `libs`. */
|
|
57
77
|
libsPath?: string;
|
|
58
78
|
/** Container path of the ref store, present only when `refs`. */
|
|
59
79
|
refsPath?: string;
|
|
60
|
-
/** Pre-created subdirectories under the writable
|
|
61
|
-
*
|
|
80
|
+
/** Pre-created subdirectories under the writable mount. Empty when read-only
|
|
81
|
+
* (nothing to pre-create) and when a declared tail replaces the step tail. */
|
|
62
82
|
stepSubdirs: readonly string[];
|
|
63
83
|
/** Env merged into the sandbox container: provenance + lib-store vars. */
|
|
64
84
|
env: Record<string, string>;
|
|
@@ -67,9 +87,27 @@ export interface MountPlan {
|
|
|
67
87
|
export interface SessionSubPaths {
|
|
68
88
|
/** Read-only mount of the whole analysis tree. */
|
|
69
89
|
readonly ro: string;
|
|
70
|
-
/** Read-write mount of the step's artifact dir; absent when read-only. */
|
|
90
|
+
/** Read-write mount of the step's artifact dir, or of the declared tail; absent when read-only. */
|
|
71
91
|
readonly rw?: string;
|
|
72
92
|
}
|
|
93
|
+
/** The one writable mount of a sandbox: its workspace-relative tail, and what the host pre-makes under it. */
|
|
94
|
+
export interface SandboxWriteTail {
|
|
95
|
+
/** The tail beneath the workspace root, in both container and PVC-relative space. */
|
|
96
|
+
readonly tail: string;
|
|
97
|
+
/** The subdirectories the host pre-creates under the tail. A declared tail is one directory, thus none. */
|
|
98
|
+
readonly subdirs: readonly string[];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The write tail of a sandbox, or `undefined` when the sandbox holds no write mount.
|
|
102
|
+
*
|
|
103
|
+
* The one owner of the rule, read by the container path, the PVC subPath, and the
|
|
104
|
+
* host-side preparation alike. Thus the directory the harness makes and the
|
|
105
|
+
* directory the sandbox mounts are provably the same one.
|
|
106
|
+
*
|
|
107
|
+
* `readOnly` beside a declared tail states two opposite things about one mount, thus
|
|
108
|
+
* it throws rather than picking a winner.
|
|
109
|
+
*/
|
|
110
|
+
export declare function sandboxWriteTail(coords: MountPlanCoords): SandboxWriteTail | undefined;
|
|
73
111
|
/**
|
|
74
112
|
* K8s `subPath`s for the session-PVC mounts.
|
|
75
113
|
*
|
|
@@ -10,13 +10,24 @@
|
|
|
10
10
|
* artifacts, lib store read-only at `/mnt/libs`, ref store read-only at
|
|
11
11
|
* `/mnt/refs`.
|
|
12
12
|
*
|
|
13
|
+
* A caller can declare its own `writableTail` in place of the step tail — a
|
|
14
|
+
* workspace-relative path that becomes the one read-write mount, with no step
|
|
15
|
+
* subdirectories under it. The session derivation declares one; the run path
|
|
16
|
+
* declares none and keeps the step tail. Thus a sandbox writes into the step
|
|
17
|
+
* directory or into the one declared tail, and nowhere else.
|
|
18
|
+
*
|
|
19
|
+
* The precedence is a refusal, not an order: `readOnly` and `writableTail` state
|
|
20
|
+
* opposite things about the same mount, thus a coordinate set that carries both
|
|
21
|
+
* throws here — the same as a crafted id. No caller can hold both and get a
|
|
22
|
+
* silently chosen winner.
|
|
23
|
+
*
|
|
13
24
|
* Container paths are a function of `resourceId` alone — they never carry the
|
|
14
25
|
* host location of the tree. Where that tree physically lives is the embedder's
|
|
15
26
|
* (`resolveWorkspaceRoot`); the two are reconciled per backend: Docker binds the
|
|
16
27
|
* resolved root directly, K8s addresses it as a `subPath` into the session PVC
|
|
17
28
|
* (see {@link buildSessionSubPaths}).
|
|
18
29
|
*/
|
|
19
|
-
import { assertSafeId } from "../workspace/paths.js";
|
|
30
|
+
import { assertSafeId, assertSafeTail } from "../workspace/paths.js";
|
|
20
31
|
export const STEP_SUBDIRS = ["output", "scripts", "figures", "logs", "notebooks"];
|
|
21
32
|
/**
|
|
22
33
|
* Container mountpoints of the two read-only stores. Exported because they are
|
|
@@ -37,6 +48,31 @@ function stepTail(runId, stepId) {
|
|
|
37
48
|
assertSafeId(stepId, "stepId");
|
|
38
49
|
return `runs/${runId}/${stepId}`;
|
|
39
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The write tail of a sandbox, or `undefined` when the sandbox holds no write mount.
|
|
53
|
+
*
|
|
54
|
+
* The one owner of the rule, read by the container path, the PVC subPath, and the
|
|
55
|
+
* host-side preparation alike. Thus the directory the harness makes and the
|
|
56
|
+
* directory the sandbox mounts are provably the same one.
|
|
57
|
+
*
|
|
58
|
+
* `readOnly` beside a declared tail states two opposite things about one mount, thus
|
|
59
|
+
* it throws rather than picking a winner.
|
|
60
|
+
*/
|
|
61
|
+
export function sandboxWriteTail(coords) {
|
|
62
|
+
// The two ids reach the sandbox name, the ownership labels, and the registry row on every path, not the
|
|
63
|
+
// step tail alone. Thus they take their validation before any branch, and a crafted id is refused even
|
|
64
|
+
// where no step directory is built.
|
|
65
|
+
assertSafeId(coords.runId, "runId");
|
|
66
|
+
assertSafeId(coords.stepId, "stepId");
|
|
67
|
+
if (coords.writableTail === undefined) {
|
|
68
|
+
return coords.readOnly ? undefined : { tail: stepTail(coords.runId, coords.stepId), subdirs: STEP_SUBDIRS };
|
|
69
|
+
}
|
|
70
|
+
if (coords.readOnly) {
|
|
71
|
+
throw new Error(`sandbox mount: a read-only sandbox cannot declare a write tail (got ${coords.writableTail})`);
|
|
72
|
+
}
|
|
73
|
+
// The segments come back validated, thus the join below re-splits nothing.
|
|
74
|
+
return { tail: assertSafeTail(coords.writableTail).join("/"), subdirs: [] };
|
|
75
|
+
}
|
|
40
76
|
/**
|
|
41
77
|
* K8s `subPath`s for the session-PVC mounts.
|
|
42
78
|
*
|
|
@@ -51,9 +87,10 @@ export function buildSessionSubPaths(coords, workspaceSubPath) {
|
|
|
51
87
|
if (workspaceSubPath.length === 0 || workspaceSubPath.startsWith("/") || workspaceSubPath.split("/").includes("..")) {
|
|
52
88
|
throw new Error(`buildSessionSubPaths: workspaceSubPath must be a non-empty PVC-root-relative path without '..' (got ${JSON.stringify(workspaceSubPath)})`);
|
|
53
89
|
}
|
|
90
|
+
const write = sandboxWriteTail(coords);
|
|
54
91
|
return {
|
|
55
92
|
ro: workspaceSubPath,
|
|
56
|
-
|
|
93
|
+
...(write === undefined ? {} : { rw: `${workspaceSubPath}/${write.tail}` }),
|
|
57
94
|
};
|
|
58
95
|
}
|
|
59
96
|
/**
|
|
@@ -68,19 +105,20 @@ function libStoreEnv() {
|
|
|
68
105
|
};
|
|
69
106
|
}
|
|
70
107
|
export function buildMountPlan(coords, stores) {
|
|
71
|
-
const { analysisId
|
|
108
|
+
const { analysisId } = coords;
|
|
72
109
|
// `analysisId` becomes the RO mount point `/{analysisId}` even in read-only
|
|
73
110
|
// mode (where `stepTail` — which validates runId/stepId — is not reached).
|
|
74
111
|
assertSafeId(analysisId, "analysisId");
|
|
75
112
|
const readonlyTreePath = `/${analysisId}`;
|
|
76
|
-
const
|
|
113
|
+
const write = sandboxWriteTail(coords);
|
|
114
|
+
const writableStepPath = write === undefined ? undefined : `${readonlyTreePath}/${write.tail}`;
|
|
77
115
|
return {
|
|
78
116
|
readonlyTreePath,
|
|
79
117
|
writableStepPath,
|
|
80
118
|
workingDir: writableStepPath ?? readonlyTreePath,
|
|
81
119
|
libsPath: stores.libs ? LIBS_CONTAINER_PATH : undefined,
|
|
82
120
|
refsPath: stores.refs ? REFS_CONTAINER_PATH : undefined,
|
|
83
|
-
stepSubdirs:
|
|
121
|
+
stepSubdirs: write?.subdirs ?? [],
|
|
84
122
|
env: {
|
|
85
123
|
PROVENANCE_WATCH_DIRS: readonlyTreePath,
|
|
86
124
|
...(stores.libs ? libStoreEnv() : {}),
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -243,6 +243,12 @@ export interface CreateSandboxMeta {
|
|
|
243
243
|
/** Enforced read-only: provision with no read-write step mount, only the
|
|
244
244
|
* read-only analysis tree for generic read-only agents. */
|
|
245
245
|
readOnly?: boolean;
|
|
246
|
+
/** Workspace-relative path that becomes the one read-write mount, in place of
|
|
247
|
+
* the step directory and with no step subdirectories under it. Each segment
|
|
248
|
+
* passes the safe-id discipline of the step builder. Absent keeps the step
|
|
249
|
+
* mount, thus a run provisions exactly as before. A tail beside `readOnly` is
|
|
250
|
+
* a contradiction, and the mount builders refuse it. */
|
|
251
|
+
writableTail?: string;
|
|
246
252
|
/** Host-supplied labels stamped onto the sandbox pod, verbatim. Opaque here:
|
|
247
253
|
* the harness reads no key and no value, it only sanitizes each value into a
|
|
248
254
|
* valid label. Absent ⇒ the pod carries the harness's own labels only. */
|
package/dist/state/init.js
CHANGED
|
@@ -129,15 +129,15 @@ CREATE TABLE IF NOT EXISTS cortex_plans (
|
|
|
129
129
|
CREATE INDEX IF NOT EXISTS idx_cortex_plans_analysis
|
|
130
130
|
ON cortex_plans(analysis_id, created_at DESC);
|
|
131
131
|
|
|
132
|
-
-- Report versions — one
|
|
133
|
-
--
|
|
134
|
-
--
|
|
135
|
-
--
|
|
136
|
-
--
|
|
137
|
-
--
|
|
138
|
-
--
|
|
139
|
-
--
|
|
140
|
-
--
|
|
132
|
+
-- Report versions — one row for each recorded report version. A row holds the
|
|
133
|
+
-- block document, the pinned snapshot, and the anchor. A thread holds at most one
|
|
134
|
+
-- version. The named unique constraint on thread_id enforces the rule, because
|
|
135
|
+
-- the report policy binds one version to each report session. A record on a
|
|
136
|
+
-- thread that holds a version replaces that row whole, under the same version_id
|
|
137
|
+
-- and the same created_at. The row outlives its thread. Thus the table has no
|
|
138
|
+
-- foreign key to cortex_analysis_threads, and the row carries the anchor columns
|
|
139
|
+
-- (thread_id, parent_thread_id, parent_seq) directly. A purge of the analysis
|
|
140
|
+
-- removes the row through the analysis_id cascade.
|
|
141
141
|
CREATE TABLE IF NOT EXISTS cortex_report_versions (
|
|
142
142
|
version_id TEXT PRIMARY KEY,
|
|
143
143
|
analysis_id TEXT NOT NULL
|
|
@@ -176,6 +176,13 @@ CREATE INDEX IF NOT EXISTS idx_cortex_report_versions_analysis
|
|
|
176
176
|
-- holds the hash that the last eyes capture saw, copied from the rendered hash.
|
|
177
177
|
-- Each is nullable, because a fresh session has run no preview and no eyes. The
|
|
178
178
|
-- record refuses unless the seen hash equals the hash of the current draft.
|
|
179
|
+
--
|
|
180
|
+
-- The derivations column holds the derivation list of the session. One record
|
|
181
|
+
-- carries the derived output path, the output hash, the source paths with their
|
|
182
|
+
-- hashes, the script hash, and the script text. The column is nullable, because a
|
|
183
|
+
-- session that derived nothing holds no list, and the read gives an empty list for
|
|
184
|
+
-- such a row. The served membership merges the records onto the pin, and the pin
|
|
185
|
+
-- itself never changes.
|
|
179
186
|
CREATE TABLE IF NOT EXISTS cortex_report_session_state (
|
|
180
187
|
thread_id TEXT PRIMARY KEY,
|
|
181
188
|
analysis_id TEXT NOT NULL
|
|
@@ -183,6 +190,7 @@ CREATE TABLE IF NOT EXISTS cortex_report_session_state (
|
|
|
183
190
|
ON DELETE CASCADE,
|
|
184
191
|
document JSONB,
|
|
185
192
|
snapshot JSONB,
|
|
193
|
+
derivations JSONB,
|
|
186
194
|
rendered_document_hash TEXT,
|
|
187
195
|
seen_document_hash TEXT,
|
|
188
196
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
@@ -310,6 +318,17 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
|
|
|
310
318
|
-- forces JSON on the envelope is about model content that is re-sent to the
|
|
311
319
|
-- provider. This record is harness-generated, has a fixed field set, and never
|
|
312
320
|
-- reaches a prompt, so it does not share that requirement.
|
|
321
|
+
--
|
|
322
|
+
-- turn_duration_ms holds the time that a whole turn took, in milliseconds, on
|
|
323
|
+
-- the same row as reported_usage. It is a column of its own, and not a member of
|
|
324
|
+
-- the rollup. The rollup is absent when a provider reported no quantity, and the
|
|
325
|
+
-- host measures the duration apart from the token counts. Thus a member of the
|
|
326
|
+
-- rollup would disappear with it. BIGINT, as cortex_step_executions.duration_ms
|
|
327
|
+
-- is, because an int4 duration overflowed there.
|
|
328
|
+
--
|
|
329
|
+
-- Nullable with no default: absent means that nobody measured the turn, and a
|
|
330
|
+
-- zero would claim that a turn took no time. A row written before this column
|
|
331
|
+
-- existed is absent for the same reason, thus the migration needs no backfill.
|
|
313
332
|
CREATE TABLE IF NOT EXISTS messages (
|
|
314
333
|
thread_id TEXT NOT NULL,
|
|
315
334
|
seq BIGINT NOT NULL,
|
|
@@ -321,6 +340,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
321
340
|
display_envelope JSONB,
|
|
322
341
|
tokens INTEGER NOT NULL,
|
|
323
342
|
reported_usage JSONB,
|
|
343
|
+
turn_duration_ms BIGINT,
|
|
324
344
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
325
345
|
PRIMARY KEY (thread_id, seq)
|
|
326
346
|
);
|
|
@@ -565,6 +585,10 @@ export async function initCortexState(pool, injected) {
|
|
|
565
585
|
// which is the honest state, thus an existing row reads back as never-seen.
|
|
566
586
|
"ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS rendered_document_hash TEXT",
|
|
567
587
|
"ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS seen_document_hash TEXT",
|
|
588
|
+
// The derivation list of a report session. Nullable and unbackfilled: a
|
|
589
|
+
// session that derived nothing holds no list, and the read gives an empty
|
|
590
|
+
// list for such a row.
|
|
591
|
+
"ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS derivations JSONB",
|
|
568
592
|
// Databases created before `data_profile_status` became nullable still
|
|
569
593
|
// carry the NOT NULL floor, which would reject a clear. Idempotent:
|
|
570
594
|
// dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
|
|
@@ -575,6 +599,11 @@ export async function initCortexState(pool, injected) {
|
|
|
575
599
|
// row and every pre-existing message reads back with no rollup — which
|
|
576
600
|
// is the honest value, the figures having never been recorded.
|
|
577
601
|
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS reported_usage JSONB",
|
|
602
|
+
// The turn's measured duration (see the CREATE TABLE comment). Purely
|
|
603
|
+
// additive: nullable with no default, thus the column rewrites no row,
|
|
604
|
+
// and every earlier message reads back with no duration — which is the
|
|
605
|
+
// honest value, because nobody measured those turns.
|
|
606
|
+
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS turn_duration_ms BIGINT",
|
|
578
607
|
// Demote an existing JSONB envelope column to JSON (see the CREATE
|
|
579
608
|
// TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
|
|
580
609
|
// whole table and this runs at every boot; scoped to
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* document can be incomplete. A column that fails a parse reads as a typed error, and
|
|
22
22
|
* it does not crash. An absent row reads as a normal absence.
|
|
23
23
|
*
|
|
24
|
+
* The row also holds the derivation list of the session. One record chains a derived
|
|
25
|
+
* table to the evidence that made it: the output path, the output hash, the source
|
|
26
|
+
* paths with their hashes, the script hash, and the script text. A record is
|
|
27
|
+
* immutable, and the append refuses a repeated output path. A row that holds no list
|
|
28
|
+
* reads as an empty list.
|
|
29
|
+
*
|
|
24
30
|
* A persist is a compare-and-swap. The load reads the prior document, and the persist
|
|
25
31
|
* lands only when the row still holds it. Thus two concurrent turns cannot both land,
|
|
26
32
|
* and the loser reads the state again.
|
|
@@ -34,6 +40,33 @@ import type { Logger } from "../lib/logger.js";
|
|
|
34
40
|
import { type DraftDocument } from "../report-model/draft.js";
|
|
35
41
|
import type { ReportSnapshot } from "../report-model/reference-resolver.js";
|
|
36
42
|
import { type SchemaIssue } from "./snapshot-parse.js";
|
|
43
|
+
/** One source of a derivation: the pinned path that the script read, and the hash of that path. */
|
|
44
|
+
export interface DerivationSource {
|
|
45
|
+
readonly path: string;
|
|
46
|
+
readonly hash: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One derivation of a session.
|
|
50
|
+
*
|
|
51
|
+
* The record chains the derived table to the evidence that made it, thus a verifier mounts the same
|
|
52
|
+
* sources, runs the same script, and compares the output hash. The script text rides the record, thus the
|
|
53
|
+
* verifier needs no second store.
|
|
54
|
+
*
|
|
55
|
+
* `outputPath` is the workspace-root-relative path of the derived file, and it is the key that the served
|
|
56
|
+
* membership holds. A record is immutable, thus a new derivation takes a new output path.
|
|
57
|
+
*/
|
|
58
|
+
export interface DerivationRecord {
|
|
59
|
+
readonly outputPath: string;
|
|
60
|
+
readonly outputHash: string;
|
|
61
|
+
readonly sources: readonly DerivationSource[];
|
|
62
|
+
readonly scriptHash: string;
|
|
63
|
+
readonly script: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The outcome of an append. `appended` landed the record. `duplicate` means that a record already holds the
|
|
67
|
+
* output path, thus the list stays as it is. `absent` means that no row holds the thread.
|
|
68
|
+
*/
|
|
69
|
+
export type AppendDerivationOutcome = "appended" | "duplicate" | "absent";
|
|
37
70
|
/** The state of one report session, as the store gives it back. */
|
|
38
71
|
export interface ReportSessionState {
|
|
39
72
|
readonly threadId: string;
|
|
@@ -42,6 +75,8 @@ export interface ReportSessionState {
|
|
|
42
75
|
readonly document: DraftDocument | null;
|
|
43
76
|
/** The pinned snapshot, or `null` before the pin writes it. */
|
|
44
77
|
readonly snapshot: ReportSnapshot | null;
|
|
78
|
+
/** The derivations of the session, in the order that they landed. A row with no list reads as empty. */
|
|
79
|
+
readonly derivations: readonly DerivationRecord[];
|
|
45
80
|
/** The hash of the draft that the last preview rendered, or `null` before the first preview. */
|
|
46
81
|
readonly renderedDocumentHash: string | null;
|
|
47
82
|
/** The hash that the last eyes capture saw, or `null` before the first look. */
|
|
@@ -92,7 +127,7 @@ export type SessionStateReadError = {
|
|
|
92
127
|
readonly type: "corrupt_session_state";
|
|
93
128
|
readonly op: string;
|
|
94
129
|
readonly threadId: string;
|
|
95
|
-
readonly part: "document" | "snapshot";
|
|
130
|
+
readonly part: "document" | "snapshot" | "derivations";
|
|
96
131
|
readonly issues: readonly SchemaIssue[];
|
|
97
132
|
};
|
|
98
133
|
export interface ReportSessionStateStore {
|
|
@@ -127,6 +162,12 @@ export interface ReportSessionStateStore {
|
|
|
127
162
|
* one. `absent` means that no row holds the thread.
|
|
128
163
|
*/
|
|
129
164
|
stampSeen(threadId: string): ResultAsync<SeenStampOutcome, DbError>;
|
|
165
|
+
/**
|
|
166
|
+
* Append one derivation record to the list of a thread. The output path is unique across the list,
|
|
167
|
+
* thus a repeated path refuses with `duplicate` and the stored list stays as it is. `absent` means
|
|
168
|
+
* that no row holds the thread.
|
|
169
|
+
*/
|
|
170
|
+
appendDerivation(threadId: string, record: DerivationRecord): ResultAsync<AppendDerivationOutcome, DbError>;
|
|
130
171
|
}
|
|
131
172
|
export interface ReportSessionStateStoreDeps {
|
|
132
173
|
readonly pool: Pool;
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* document can be incomplete. A column that fails a parse reads as a typed error, and
|
|
22
22
|
* it does not crash. An absent row reads as a normal absence.
|
|
23
23
|
*
|
|
24
|
+
* The row also holds the derivation list of the session. One record chains a derived
|
|
25
|
+
* table to the evidence that made it: the output path, the output hash, the source
|
|
26
|
+
* paths with their hashes, the script hash, and the script text. A record is
|
|
27
|
+
* immutable, and the append refuses a repeated output path. A row that holds no list
|
|
28
|
+
* reads as an empty list.
|
|
29
|
+
*
|
|
24
30
|
* A persist is a compare-and-swap. The load reads the prior document, and the persist
|
|
25
31
|
* lands only when the row still holds it. Thus two concurrent turns cannot both land,
|
|
26
32
|
* and the loser reads the state again.
|
|
@@ -28,12 +34,22 @@
|
|
|
28
34
|
* A purge of the analysis removes the row through the analysis-id cascade.
|
|
29
35
|
*/
|
|
30
36
|
import { err, ok } from "neverthrow";
|
|
37
|
+
import { z } from "zod";
|
|
31
38
|
import { tryMutation, tryQuery } from "../lib/db-result.js";
|
|
32
39
|
import { DraftDocumentSchema } from "../report-model/draft.js";
|
|
33
40
|
import { parseSnapshot, reduceIssues } from "./snapshot-parse.js";
|
|
41
|
+
const DerivationSourceSchema = z.object({ path: z.string(), hash: z.string() });
|
|
42
|
+
const DerivationRecordSchema = z.object({
|
|
43
|
+
outputPath: z.string(),
|
|
44
|
+
outputHash: z.string(),
|
|
45
|
+
sources: z.array(DerivationSourceSchema),
|
|
46
|
+
scriptHash: z.string(),
|
|
47
|
+
script: z.string(),
|
|
48
|
+
});
|
|
49
|
+
const DerivationListSchema = z.array(DerivationRecordSchema);
|
|
34
50
|
const _assertReadError = true;
|
|
35
51
|
/** The full row projection every read shares. */
|
|
36
|
-
const STATE_COLUMNS = "thread_id, analysis_id, document, snapshot, rendered_document_hash, seen_document_hash, created_at";
|
|
52
|
+
const STATE_COLUMNS = "thread_id, analysis_id, document, snapshot, derivations, rendered_document_hash, seen_document_hash, created_at";
|
|
37
53
|
/**
|
|
38
54
|
* The insert. The row carries the snapshot at creation, and the document lands later
|
|
39
55
|
* through `persistDocument`. ON CONFLICT DO NOTHING keeps the first row, thus a
|
|
@@ -72,11 +88,28 @@ function rowToState(row) {
|
|
|
72
88
|
}
|
|
73
89
|
snapshot = parsed.value;
|
|
74
90
|
}
|
|
91
|
+
// A row that predates the list carries SQL NULL, and a session that derived nothing carries none
|
|
92
|
+
// either. Both read as an empty list, because a session with no derivation has none.
|
|
93
|
+
let derivations = [];
|
|
94
|
+
if (row.derivations !== null && row.derivations !== undefined) {
|
|
95
|
+
const parsed = DerivationListSchema.safeParse(row.derivations);
|
|
96
|
+
if (!parsed.success) {
|
|
97
|
+
return err({
|
|
98
|
+
type: "corrupt_session_state",
|
|
99
|
+
op: "report-session-state.read",
|
|
100
|
+
threadId: row.thread_id,
|
|
101
|
+
part: "derivations",
|
|
102
|
+
issues: reduceIssues(parsed.error),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
derivations = parsed.data;
|
|
106
|
+
}
|
|
75
107
|
return ok({
|
|
76
108
|
threadId: row.thread_id,
|
|
77
109
|
analysisId: row.analysis_id,
|
|
78
110
|
document,
|
|
79
111
|
snapshot,
|
|
112
|
+
derivations,
|
|
80
113
|
renderedDocumentHash: row.rendered_document_hash,
|
|
81
114
|
seenDocumentHash: row.seen_document_hash,
|
|
82
115
|
createdAt: row.created_at,
|
|
@@ -173,5 +206,40 @@ export function createReportSessionStateStore({ pool }) {
|
|
|
173
206
|
return row.copied === null ? "no-rendered" : "stamped";
|
|
174
207
|
});
|
|
175
208
|
}
|
|
176
|
-
|
|
209
|
+
function appendDerivation(threadId, record) {
|
|
210
|
+
return tryMutation("report-session-state.appendDerivation", async () => {
|
|
211
|
+
// One statement tells a duplicate from an absence, the same way the persist does. `existed`
|
|
212
|
+
// counts the row, and `appended` counts the update.
|
|
213
|
+
//
|
|
214
|
+
// The name rule sits in the qualification of the UPDATE, over the row that the statement locks.
|
|
215
|
+
// A read of the CTE would test the list as it stood at the start of the statement: two
|
|
216
|
+
// concurrent appends of one name would each pass that test, and both would land. Postgres
|
|
217
|
+
// re-evaluates the qualification of an UPDATE against the row that it waited for, thus the
|
|
218
|
+
// loser of the race sees the list of the winner and it appends nothing.
|
|
219
|
+
const { rows } = await pool.query({
|
|
220
|
+
text: `WITH target AS (
|
|
221
|
+
SELECT thread_id
|
|
222
|
+
FROM cortex_report_session_state
|
|
223
|
+
WHERE thread_id = $1
|
|
224
|
+
), appended AS (
|
|
225
|
+
UPDATE cortex_report_session_state AS s
|
|
226
|
+
SET derivations = COALESCE(s.derivations, '[]'::jsonb) || $2::jsonb
|
|
227
|
+
WHERE s.thread_id = $1
|
|
228
|
+
AND NOT EXISTS (
|
|
229
|
+
SELECT 1 FROM jsonb_array_elements(COALESCE(s.derivations, '[]'::jsonb)) AS held WHERE held->>'outputPath' = $3
|
|
230
|
+
)
|
|
231
|
+
RETURNING 1
|
|
232
|
+
)
|
|
233
|
+
SELECT (SELECT COUNT(*) FROM target)::int AS existed, (SELECT COUNT(*) FROM appended)::int AS appended`,
|
|
234
|
+
// The record rides as a one-element array, thus the `||` operator appends one element and
|
|
235
|
+
// never merges the fields of an object into the list.
|
|
236
|
+
values: [threadId, JSON.stringify([record]), record.outputPath],
|
|
237
|
+
});
|
|
238
|
+
const row = rows[0];
|
|
239
|
+
if (row === undefined || row.existed === 0)
|
|
240
|
+
return "absent";
|
|
241
|
+
return row.appended > 0 ? "appended" : "duplicate";
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return { readState, writeSnapshot, persistDocument, stampRendered, stampSeen, appendDerivation };
|
|
177
245
|
}
|