@mobileaidev/ai-app-bridge 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -39
- package/bin/ai-app-bridge.js +56 -17
- package/bin/command-discovery.js +19 -4
- package/bin/command-registry.js +15 -4
- package/bin/command-request.js +68 -4
- package/bin/execution-host.js +31 -5
- package/bin/execution-runtime.js +17 -10
- package/bin/executors/preparation.js +19 -5
- package/bin/extraction/json-value.js +26 -0
- package/bin/extraction/prepare.js +57 -0
- package/bin/extraction/regex.js +30 -0
- package/bin/extraction/runner.js +77 -0
- package/bin/mcp-server.js +15 -20
- package/bin/public-reply.js +184 -0
- package/bin/response-store.js +60 -0
- package/bin/runtime-client.js +32 -15
- package/bin/runtime-directory.js +37 -8
- package/bin/script/node-runtime-adapter.js +139 -123
- package/bin/script/python-runtime-adapter.js +1 -1
- package/bin/script/script-diagnostics.js +21 -0
- package/bin/script/script-durable-restore.js +1 -0
- package/bin/script/script-sdk.js +39 -4
- package/bin/script/script-sdk.py +79 -7
- package/bin/script/script-session-channel.js +27 -10
- package/bin/script/script-supervisor.js +8 -0
- package/bin/shared-kernel/argument-schema.js +44 -12
- package/bin/shared-kernel/evidence-archive.js +2 -2
- package/bin/shared-kernel/evidence-schema.js +16 -1
- package/bin/shared-kernel/evidence-store.js +3 -3
- package/bin/shared-kernel/execution-contracts.js +13 -5
- package/docs/COMMAND_CONTRACT.md +91 -19
- package/docs/EVIDENCE_ARCHIVE.md +14 -1
- package/docs/INSTALLATION.md +73 -0
- package/docs/INTENT_FOREGROUND.md +4 -1
- package/docs/OPTIONAL_EXECUTORS.md +14 -14
- package/docs/RELEASE.md +60 -122
- package/docs/RESPONSE_EXTRACTION.md +122 -0
- package/docs/SCRIPT_AUTHORING.md +125 -6
- package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
- package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
- package/package.json +12 -5
- package/runtime/executors/android/prepare.init.gradle +22 -0
- package/runtime/executors/playwright/package-lock.json +2 -2
- package/runtime/executors/playwright/package.json +1 -1
- package/skills/ai-app-bridge-use/SKILL.md +19 -4
package/bin/script/script-sdk.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
3
|
import sys
|
|
4
|
+
import math
|
|
5
|
+
import traceback
|
|
6
|
+
import time
|
|
7
|
+
from types import SimpleNamespace
|
|
4
8
|
|
|
5
9
|
|
|
6
10
|
_real_stdout = sys.stdout
|
|
11
|
+
_input_parse_ms = 0
|
|
7
12
|
HELD = {
|
|
8
13
|
"pause_requested",
|
|
9
14
|
"paused_manual",
|
|
@@ -20,10 +25,14 @@ def send(message):
|
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def read_message():
|
|
28
|
+
global _input_parse_ms
|
|
23
29
|
line = sys.stdin.readline()
|
|
24
30
|
if not line:
|
|
25
31
|
return None
|
|
26
|
-
|
|
32
|
+
started = time.perf_counter()
|
|
33
|
+
message = json.loads(line)
|
|
34
|
+
_input_parse_ms = (time.perf_counter() - started) * 1000
|
|
35
|
+
return message
|
|
27
36
|
|
|
28
37
|
|
|
29
38
|
class HostContext:
|
|
@@ -120,22 +129,74 @@ class HostContext:
|
|
|
120
129
|
return checkpoint["state"]
|
|
121
130
|
|
|
122
131
|
|
|
123
|
-
def load_namespace(path):
|
|
132
|
+
def load_namespace(path, source_name=None):
|
|
124
133
|
namespace = {}
|
|
125
134
|
with open(path, "r", encoding="utf-8") as handle:
|
|
126
|
-
exec(handle.read(), namespace, namespace)
|
|
135
|
+
exec(compile(handle.read(), source_name or path, "exec"), namespace, namespace)
|
|
127
136
|
return namespace
|
|
128
137
|
|
|
129
138
|
|
|
139
|
+
def validate_json(value, location="", parents=None):
|
|
140
|
+
parents = set() if parents is None else parents
|
|
141
|
+
|
|
142
|
+
def invalid(reason):
|
|
143
|
+
error = TypeError(f"{location or '/'}: {reason}")
|
|
144
|
+
error.code = "extraction_type_error"
|
|
145
|
+
error.value_path = location
|
|
146
|
+
raise error
|
|
147
|
+
|
|
148
|
+
if value is None or isinstance(value, (str, bool)):
|
|
149
|
+
return
|
|
150
|
+
if isinstance(value, (int, float)):
|
|
151
|
+
if (isinstance(value, int) and abs(value) > 9007199254740991) or (isinstance(value, float) and
|
|
152
|
+
(not math.isfinite(value) or value.is_integer() and abs(value) > 9007199254740991)):
|
|
153
|
+
invalid("expected a finite number in the safe integer range")
|
|
154
|
+
return
|
|
155
|
+
if type(value) not in (list, dict):
|
|
156
|
+
invalid(f"unsupported JSON type: {type(value).__name__}")
|
|
157
|
+
if id(value) in parents:
|
|
158
|
+
invalid("cyclic value")
|
|
159
|
+
parents.add(id(value))
|
|
160
|
+
entries = enumerate(value) if isinstance(value, list) else value.items()
|
|
161
|
+
for key, item in entries:
|
|
162
|
+
if isinstance(value, dict) and not isinstance(key, str):
|
|
163
|
+
invalid("object keys must be strings")
|
|
164
|
+
suffix = str(key).replace("~", "~0").replace("/", "~1")
|
|
165
|
+
validate_json(item, f"{location}/{suffix}", parents)
|
|
166
|
+
parents.remove(id(value))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def bounded(value, size):
|
|
170
|
+
return str(value).encode("utf-8", errors="replace")[:size].decode("utf-8", errors="ignore")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def diagnostic(error):
|
|
174
|
+
frames = traceback.extract_tb(error.__traceback__)
|
|
175
|
+
stack = "Traceback (most recent call last):\n" + "".join(traceback.format_list(frames[-20:])) + "".join(traceback.format_exception_only(type(error), error))
|
|
176
|
+
result = {"type": type(error).__name__, "message": bounded(error, 2048),
|
|
177
|
+
"stack": bounded(stack, 8192), "stackTruncated": len(frames) > 20 or len(stack.encode("utf-8")) > 8192}
|
|
178
|
+
if isinstance(error, SyntaxError) and error.filename:
|
|
179
|
+
result["location"] = {"file": bounded(error.filename, 1024), "line": error.lineno, "column": error.offset}
|
|
180
|
+
elif frames:
|
|
181
|
+
frame = frames[-1]
|
|
182
|
+
result["location"] = {"file": bounded(frame.filename, 1024), "line": frame.lineno}
|
|
183
|
+
if hasattr(error, "value_path"):
|
|
184
|
+
result["valuePath"] = bounded(error.value_path, 1024)
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
130
188
|
def main():
|
|
131
189
|
artifact_path = sys.argv[1]
|
|
132
190
|
send({"type": "ready"})
|
|
133
191
|
start = read_message()
|
|
134
192
|
sys.stdout = sys.stderr
|
|
135
|
-
|
|
193
|
+
extraction = start.get("extraction") is not None
|
|
194
|
+
ctx = SimpleNamespace(inputs=start["inputs"]) if extraction else HostContext(start.get("inputs") or {}, start.get("control") or {"status": "running", "pauseReason": None})
|
|
136
195
|
entry = start.get("entrypoint") or "main"
|
|
137
196
|
try:
|
|
138
|
-
|
|
197
|
+
execution_started = time.perf_counter()
|
|
198
|
+
input_parse_ms = _input_parse_ms
|
|
199
|
+
namespace = load_namespace(artifact_path, start.get("sourceName"))
|
|
139
200
|
loaded = namespace.get(entry)
|
|
140
201
|
if not callable(loaded):
|
|
141
202
|
send({"type": "fail", "error": "unsupported_entrypoint"})
|
|
@@ -143,9 +204,20 @@ def main():
|
|
|
143
204
|
result = loaded(ctx)
|
|
144
205
|
if asyncio.iscoroutine(result):
|
|
145
206
|
result = asyncio.run(result)
|
|
146
|
-
|
|
207
|
+
if extraction:
|
|
208
|
+
validate_json(result)
|
|
209
|
+
if len(json.dumps(result, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")) > 256 * 1024:
|
|
210
|
+
error = ValueError("Extraction result exceeds 256 KiB.")
|
|
211
|
+
error.code = "extraction_output_too_large"
|
|
212
|
+
raise error
|
|
213
|
+
frame = {"type": "return", "result": result}
|
|
214
|
+
if extraction:
|
|
215
|
+
frame["timings"] = {"inputParseMs": input_parse_ms, "executionMs": (time.perf_counter() - execution_started) * 1000}
|
|
216
|
+
send(frame)
|
|
147
217
|
except Exception as error:
|
|
148
|
-
|
|
218
|
+
detail = diagnostic(error)
|
|
219
|
+
send({"type": "fail", "error": getattr(error, "code", "extraction_failed") if extraction else detail["message"],
|
|
220
|
+
"message": detail["message"], "diagnostic": detail})
|
|
149
221
|
sys.exit(1)
|
|
150
222
|
|
|
151
223
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
4
4
|
|
|
5
|
-
function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES
|
|
5
|
+
function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES,
|
|
6
|
+
maxInputFrameBytes = maxFrameBytes, maxOutputFrameBytes = maxFrameBytes, maxDiagnosticBytes = 8192 } = {}) {
|
|
6
7
|
let buffer = '';
|
|
7
8
|
let closed = false;
|
|
8
9
|
let failure = null;
|
|
@@ -12,10 +13,18 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
12
13
|
const pending = [];
|
|
13
14
|
const replies = new Map();
|
|
14
15
|
const maxPending = 32;
|
|
16
|
+
let stderr = Buffer.alloc(0);
|
|
17
|
+
let stderrTotalBytes = 0;
|
|
15
18
|
|
|
16
19
|
if (child.stderr) {
|
|
17
|
-
child.stderr.on('data',
|
|
20
|
+
child.stderr.on('data', chunk => {
|
|
21
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
22
|
+
stderrTotalBytes += bytes.length;
|
|
23
|
+
stderr = bytes.length >= maxDiagnosticBytes ? Buffer.from(bytes.subarray(-maxDiagnosticBytes))
|
|
24
|
+
: Buffer.concat([stderr.subarray(Math.max(0, stderr.length + bytes.length - maxDiagnosticBytes)), bytes]);
|
|
25
|
+
});
|
|
18
26
|
}
|
|
27
|
+
child.stdin.on('error', error => fail(error));
|
|
19
28
|
child.stdout.setEncoding('utf8');
|
|
20
29
|
child.stdout.on('data', (chunk) => {
|
|
21
30
|
if (closed) return;
|
|
@@ -25,7 +34,7 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
25
34
|
const line = buffer.slice(0, index);
|
|
26
35
|
buffer = buffer.slice(index + 1);
|
|
27
36
|
index = buffer.indexOf('\n');
|
|
28
|
-
if (Buffer.byteLength(line, 'utf8') >
|
|
37
|
+
if (Buffer.byteLength(line, 'utf8') + 1 > maxOutputFrameBytes) {
|
|
29
38
|
fail(new Error('frame_too_large'));
|
|
30
39
|
return;
|
|
31
40
|
}
|
|
@@ -57,7 +66,7 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
57
66
|
}
|
|
58
67
|
pending.push(message);
|
|
59
68
|
}
|
|
60
|
-
if (Buffer.byteLength(buffer, 'utf8') >
|
|
69
|
+
if (Buffer.byteLength(buffer, 'utf8') > maxOutputFrameBytes) {
|
|
61
70
|
fail(new Error('frame_too_large'));
|
|
62
71
|
}
|
|
63
72
|
});
|
|
@@ -67,7 +76,7 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
67
76
|
function send(message) {
|
|
68
77
|
if (closed || !child.stdin.writable) return;
|
|
69
78
|
const line = `${JSON.stringify(message)}\n`;
|
|
70
|
-
if (Buffer.byteLength(line, 'utf8') >
|
|
79
|
+
if (Buffer.byteLength(line, 'utf8') > maxInputFrameBytes) {
|
|
71
80
|
fail(new Error('frame_too_large'));
|
|
72
81
|
return;
|
|
73
82
|
}
|
|
@@ -76,14 +85,11 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
76
85
|
|
|
77
86
|
function nextMessage() {
|
|
78
87
|
return new Promise((resolve) => {
|
|
79
|
-
if (closed) {
|
|
80
|
-
resolve(failure);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
88
|
if (pending.length > 0) {
|
|
84
89
|
resolve(pending.shift());
|
|
85
90
|
return;
|
|
86
91
|
}
|
|
92
|
+
if (closed) { resolve(failure); return; }
|
|
87
93
|
waiters.push(resolve);
|
|
88
94
|
});
|
|
89
95
|
}
|
|
@@ -108,7 +114,9 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
108
114
|
closed = true;
|
|
109
115
|
const frame = { type: 'fail', error: error.message || String(error) };
|
|
110
116
|
failure = frame;
|
|
117
|
+
const terminal = error.message === 'channel_closed' ? pending.find(message => ['return', 'fail'].includes(message.type)) : null;
|
|
111
118
|
pending.length = 0;
|
|
119
|
+
if (terminal) pending.push(terminal);
|
|
112
120
|
buffer = '';
|
|
113
121
|
while (waiters.length > 0) waiters.shift()(frame);
|
|
114
122
|
for (const resolve of replies.values()) resolve(frame);
|
|
@@ -121,7 +129,16 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
|
|
|
121
129
|
if (child.kill('SIGTERM') && !killTimer) killTimer = setTimeout(() => child.kill('SIGKILL'), 250);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
|
|
132
|
+
function diagnostics() {
|
|
133
|
+
if (!stderrTotalBytes) return {};
|
|
134
|
+
// Invalid bytes expand to replacement characters. Bound delivered UTF-8
|
|
135
|
+
// text too, starting at a complete character in the retained tail.
|
|
136
|
+
const text = Buffer.from(stderr.toString('utf8'));
|
|
137
|
+
let start = Math.max(0, text.length - maxDiagnosticBytes);
|
|
138
|
+
while (start < text.length && (text[start] & 0xc0) === 0x80) start++;
|
|
139
|
+
return { stderr: text.subarray(start).toString('utf8'), stderrTruncated: stderrTotalBytes > stderr.length || start > 0 };
|
|
140
|
+
}
|
|
141
|
+
return { send, nextMessage, waitReply, stop, diagnostics };
|
|
125
142
|
}
|
|
126
143
|
|
|
127
144
|
module.exports = { createScriptSessionChannel };
|
|
@@ -174,6 +174,7 @@ function createScriptSupervisor({
|
|
|
174
174
|
mutationUnknown: false,
|
|
175
175
|
decisionRevision: 0,
|
|
176
176
|
currentRequestId: null,
|
|
177
|
+
pendingQuestion: null,
|
|
177
178
|
questionRevisions: new Map(),
|
|
178
179
|
decisions: new Map(),
|
|
179
180
|
ledger: scriptLedger,
|
|
@@ -431,8 +432,10 @@ function createScriptSupervisor({
|
|
|
431
432
|
operationId: record.operationId,
|
|
432
433
|
status: record.status,
|
|
433
434
|
pauseReason: record.pauseReason,
|
|
435
|
+
pendingQuestion: record.status === 'waiting_for_agent' ? record.pendingQuestion : null,
|
|
434
436
|
error: record.error,
|
|
435
437
|
hash: record.hash,
|
|
438
|
+
...(record.diagnostics ? { diagnostics: record.diagnostics } : {}),
|
|
436
439
|
resultRef: record.resultRef ?? null,
|
|
437
440
|
eventSequence: record.events.sequence,
|
|
438
441
|
events: record.events.after(args.afterSequence, args.eventLimit ?? args.limit),
|
|
@@ -517,6 +520,7 @@ function persistRecord(record, kind, body) {
|
|
|
517
520
|
}
|
|
518
521
|
|
|
519
522
|
async function commitTerminal(record, status, error, now, result) {
|
|
523
|
+
record.diagnostics = status === 'failed' ? result?.diagnostics : undefined;
|
|
520
524
|
let revision = record.checkpointRevision + 1;
|
|
521
525
|
let resultRef = null;
|
|
522
526
|
if (status === 'completed' && record.store) {
|
|
@@ -539,6 +543,7 @@ async function commitTerminal(record, status, error, now, result) {
|
|
|
539
543
|
status,
|
|
540
544
|
error,
|
|
541
545
|
resultRef,
|
|
546
|
+
...(record.diagnostics ? { diagnostics: record.diagnostics } : {}),
|
|
542
547
|
...(record.recording ? { recording: record.recording.info() } : {}),
|
|
543
548
|
};
|
|
544
549
|
let persisted = await persistRecord(record, 'checkpoint', checkpoint);
|
|
@@ -777,6 +782,9 @@ function trackAgent(agent, record, now) {
|
|
|
777
782
|
const requestId = `ask-${record.decisionRevision}`;
|
|
778
783
|
record.questionRevisions.set(requestId, record.decisionRevision);
|
|
779
784
|
record.currentRequestId = requestId;
|
|
785
|
+
// The unanswered question stays on the record so a filtered event page
|
|
786
|
+
// cannot hide it from status/wait.
|
|
787
|
+
record.pendingQuestion = { requestId, revision: record.decisionRevision, request };
|
|
780
788
|
record.status = 'waiting_for_agent';
|
|
781
789
|
emitRecord(record, 'agent_question_created', {
|
|
782
790
|
request,
|
|
@@ -14,19 +14,16 @@ function validateValue(value, schema, field = '') {
|
|
|
14
14
|
if (Object.hasOwn(schema, 'const') && value !== schema.const) throw invalid(field, `${label} must be ${JSON.stringify(schema.const)}.`);
|
|
15
15
|
if (schema.enum && !schema.enum.includes(value)) throw invalid(field, `${label} must be one of: ${schema.enum.join(', ')}.`);
|
|
16
16
|
const type = schema.type;
|
|
17
|
-
|
|
18
|
-
: type === 'null' ? value === null
|
|
19
|
-
: type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
20
|
-
: type === 'integer' ? Number.isSafeInteger(value)
|
|
21
|
-
: type === 'number' ? typeof value === 'number' && Number.isFinite(value)
|
|
22
|
-
: typeof value === type);
|
|
23
|
-
if (!matches) throw invalid(field, `${label} must be ${type}; no implicit type conversion is performed.`);
|
|
17
|
+
if (!matchesType(value, type)) throw invalid(field, `${label} must be ${type}; no implicit type conversion is performed.`);
|
|
24
18
|
if (schema.minLength !== undefined && value.length < schema.minLength) throw invalid(field, `${label} must not be empty.`);
|
|
25
19
|
if (schema.maxLength !== undefined && value.length > schema.maxLength) throw invalid(field, `${label} must contain at most ${schema.maxLength} characters.`);
|
|
26
20
|
if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) throw invalid(field, `${label} must match ${schema.pattern}.`);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
// A range violation quotes the field's documented meaning, so the caller can
|
|
22
|
+
// correct the call without reading the nested schema.
|
|
23
|
+
const explained = message => schema.description ? `${message} ${schema.description}` : message;
|
|
24
|
+
if (schema.minimum !== undefined && value < schema.minimum) throw invalid(field, explained(`${label} must be >= ${schema.minimum}.`));
|
|
25
|
+
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) throw invalid(field, explained(`${label} must be > ${schema.exclusiveMinimum}.`));
|
|
26
|
+
if (schema.maximum !== undefined && value > schema.maximum) throw invalid(field, explained(`${label} must be <= ${schema.maximum}.`));
|
|
30
27
|
if (type === 'array') {
|
|
31
28
|
if (schema.minItems !== undefined && value.length < schema.minItems) throw invalid(field, `${label} requires at least ${schema.minItems} item(s).`);
|
|
32
29
|
if (schema.maxItems !== undefined && value.length > schema.maxItems) throw invalid(field, `${label} accepts at most ${schema.maxItems} item(s).`);
|
|
@@ -68,6 +65,15 @@ function validateValue(value, schema, field = '') {
|
|
|
68
65
|
if (Object.keys(schema).every(key => ['description', 'default'].includes(key))) validateJson(value, field);
|
|
69
66
|
}
|
|
70
67
|
|
|
68
|
+
function matchesType(value, type) {
|
|
69
|
+
return type === undefined || (type === 'array' ? Array.isArray(value)
|
|
70
|
+
: type === 'null' ? value === null
|
|
71
|
+
: type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
72
|
+
: type === 'integer' ? Number.isSafeInteger(value)
|
|
73
|
+
: type === 'number' ? typeof value === 'number' && Number.isFinite(value)
|
|
74
|
+
: typeof value === type);
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
function accepts(value, schema, field) {
|
|
72
78
|
try { validateValue(value, schema, field); return true; }
|
|
73
79
|
catch (error) { if (!(error instanceof CommandError)) throw error; return false; }
|
|
@@ -82,22 +88,48 @@ function validateUnion(value, branches, field, exactlyOne) {
|
|
|
82
88
|
}
|
|
83
89
|
if (matched && (!exactlyOne || matched === 1)) return;
|
|
84
90
|
if (!matched) {
|
|
91
|
+
// A branch for another JSON type cannot explain this value, e.g. the null
|
|
92
|
+
// branch of a nullable target never explains an object missing platform.
|
|
93
|
+
const explained = failures.filter(({ branch }) => matchesType(value, branch.type));
|
|
94
|
+
if (!explained.length) {
|
|
95
|
+
throw invalid(field, `${field || 'arguments'} must be ${[...new Set(failures.map(({ branch }) => branch.type))].join(' or ')}; no implicit type conversion is performed.`);
|
|
96
|
+
}
|
|
85
97
|
// A tagged branch gives a useful precise error instead of a generic union
|
|
86
98
|
// failure, e.g. decision.action.durationMs for an observed long press.
|
|
87
|
-
const tagged =
|
|
99
|
+
const tagged = explained.filter(({ branch }) => {
|
|
88
100
|
const tags = Object.entries(branch.properties || {}).filter(([, rule]) => Object.hasOwn(rule, 'const'));
|
|
89
101
|
return tags.some(([key, rule]) => value?.[key] === rule.const)
|
|
90
102
|
&& tags.every(([key, rule]) => Object.hasOwn(value || {}, key) ? value[key] === rule.const : !branch.required?.includes(key));
|
|
91
103
|
});
|
|
92
104
|
if (tagged.length === 1) throw tagged[0].error;
|
|
93
|
-
const candidates = tagged.length ? tagged :
|
|
105
|
+
const candidates = tagged.length ? tagged : explained;
|
|
94
106
|
if (candidates.every(({ error }) => error.code === candidates[0].error.code && error.field === candidates[0].error.field && error.message === candidates[0].error.message)) throw candidates[0].error;
|
|
107
|
+
// No branch accepts every supplied tag: name the tag the intended variants
|
|
108
|
+
// reject and its accepted values instead of listing every variant.
|
|
109
|
+
const rejected = tagged.length ? null : rejectedDiscriminator(value || {}, explained.map(({ branch }) => branch));
|
|
110
|
+
if (rejected) {
|
|
111
|
+
const child = fieldAt(field, rejected.key);
|
|
112
|
+
throw invalid(child, `${child} must be one of: ${rejected.values.map(item => JSON.stringify(item)).join(', ')}.`);
|
|
113
|
+
}
|
|
95
114
|
if (branches.length === 1) throw failures[0].error;
|
|
96
115
|
}
|
|
97
116
|
throw new CommandError('invalid_argument', `${field || 'arguments'} must match ${exactlyOne ? 'exactly one' : 'one'} of the documented variants.`,
|
|
98
117
|
{ field: field || 'arguments', details: { variants: failures.map(({ error }) => ({ field: error.field, error: error.code, message: error.message })) } });
|
|
99
118
|
}
|
|
100
119
|
|
|
120
|
+
// The branches agreeing with the most supplied tags are the intended variants,
|
|
121
|
+
// e.g. the two start variants for {operation:"start", mode:"wrong"}. A supplied
|
|
122
|
+
// tag they all reject is the field to correct; its accepted values are theirs.
|
|
123
|
+
function rejectedDiscriminator(value, branches) {
|
|
124
|
+
const suppliedTags = branch => Object.entries(branch.properties || {}).filter(([key, rule]) => Object.hasOwn(rule, 'const') && Object.hasOwn(value, key));
|
|
125
|
+
const agreement = branch => suppliedTags(branch).filter(([key, rule]) => value[key] === rule.const).length;
|
|
126
|
+
const best = Math.max(...branches.map(agreement));
|
|
127
|
+
const closest = branches.filter(branch => agreement(branch) === best);
|
|
128
|
+
const key = suppliedTags(closest[0]).map(([name]) => name)
|
|
129
|
+
.find(name => closest.every(branch => Object.hasOwn(branch.properties[name] || {}, 'const') && value[name] !== branch.properties[name].const));
|
|
130
|
+
return key === undefined ? null : { key, values: [...new Set(closest.map(branch => branch.properties[key].const))] };
|
|
131
|
+
}
|
|
132
|
+
|
|
101
133
|
function validateJson(value, field = '', ancestors = new Set(), depth = 0) {
|
|
102
134
|
if (depth > 64) throw invalid(field, 'JSON nesting must not exceed 64 levels.');
|
|
103
135
|
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
|
-
const { checksumOf, canonicalJson, validateArchivedRecord, verifyChecksum } = require('./evidence-schema');
|
|
6
|
+
const { checksumOf, canonicalJson, validateArchivedRecord, verifyChecksum, NAMESPACES } = require('./evidence-schema');
|
|
7
7
|
const { analyzeRecordedPayloads } = require('./recorded-payload-archive');
|
|
8
8
|
const { readRegularFile: readRecordedFile } = require('./evidence-recording');
|
|
9
9
|
|
|
@@ -23,7 +23,7 @@ function textArgument(value, field) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function namespaceArgument(namespace) {
|
|
26
|
-
requireValue(namespace
|
|
26
|
+
requireValue(NAMESPACES.includes(namespace), 'invalid_argument', 'namespace');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function handle(args = {}, { getFactStore } = {}) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const crypto = require('node:crypto');
|
|
4
4
|
const { sanitizePersistentValue } = require('../fact-codec');
|
|
5
5
|
|
|
6
|
-
const NAMESPACES = Object.freeze(['script', 'intent']);
|
|
6
|
+
const NAMESPACES = Object.freeze(['script', 'intent', 'response']);
|
|
7
7
|
const PROVIDERS = Object.freeze(['native', 'uia', 'flutter', 'h5']);
|
|
8
8
|
const RECORD_SCHEMA = 'aab.execution-evidence/v1';
|
|
9
9
|
const AGENT_DECISIONS = Object.freeze(['act', 'complete', 'fail', 'inconclusive']);
|
|
@@ -17,6 +17,7 @@ const KINDS = Object.freeze([
|
|
|
17
17
|
'checkpoint',
|
|
18
18
|
'result',
|
|
19
19
|
'attachment',
|
|
20
|
+
'response',
|
|
20
21
|
]);
|
|
21
22
|
|
|
22
23
|
function checksumOf(value) {
|
|
@@ -57,6 +58,7 @@ function verifyChecksum(record) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function requiredFields(kind) {
|
|
61
|
+
if (kind === 'response') return ['operationId', 'revision', 'snapshotBase64'];
|
|
60
62
|
if (kind === 'observation') {
|
|
61
63
|
return ['operationId', 'revision', 'target', 'provider', 'capturedAtMs'];
|
|
62
64
|
}
|
|
@@ -97,6 +99,7 @@ function validateRecord(namespace, kind, record, { archivedUnversioned = false }
|
|
|
97
99
|
if (!record || typeof record !== 'object') {
|
|
98
100
|
return { ok: false, error: 'invalid_record' };
|
|
99
101
|
}
|
|
102
|
+
if ((namespace === 'response') !== (kind === 'response')) return { ok: false, error: 'invalid_kind' };
|
|
100
103
|
if (!archivedUnversioned && record.schemaVersion !== undefined && record.schemaVersion !== RECORD_SCHEMA) {
|
|
101
104
|
return { ok: false, error: 'unsupported_record_schema', field: 'schemaVersion' };
|
|
102
105
|
}
|
|
@@ -107,6 +110,18 @@ function validateRecord(namespace, kind, record, { archivedUnversioned = false }
|
|
|
107
110
|
return { ok: false, error: 'invalid_record', field };
|
|
108
111
|
}
|
|
109
112
|
}
|
|
113
|
+
if (kind === 'response') {
|
|
114
|
+
const encoded = record.snapshotBase64;
|
|
115
|
+
if (typeof encoded !== 'string' || Buffer.from(encoded, 'base64').toString('base64') !== encoded
|
|
116
|
+
|| record.revision !== 1) return { ok: false, error: 'invalid_record', field: 'snapshotBase64' };
|
|
117
|
+
try {
|
|
118
|
+
const snapshot = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
|
|
119
|
+
if (!['json', 'text'].includes(snapshot.kind) || !Object.hasOwn(snapshot, 'value')
|
|
120
|
+
|| !snapshot.execution || !snapshot.control || snapshot.control.source !== undefined
|
|
121
|
+
|| snapshot.identity?.responseId !== record.operationId || typeof snapshot.identity.command !== 'string'
|
|
122
|
+
|| !Number.isSafeInteger(snapshot.identity.capturedAtMs)) throw new Error('invalid_snapshot');
|
|
123
|
+
} catch { return { ok: false, error: 'invalid_record', field: 'snapshotBase64' }; }
|
|
124
|
+
}
|
|
110
125
|
if (kind === 'result' && (namespace !== 'script' || !Object.hasOwn(record, 'result')
|
|
111
126
|
|| !Number.isSafeInteger(record.bytes) || record.bytes < 1
|
|
112
127
|
|| !/^[a-f0-9]{64}$/.test(record.sha256) || !/^[a-f0-9]{64}$/.test(record.originalSha256)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { buildEnvelope, verifyChecksum } = require('./evidence-schema');
|
|
3
|
+
const { buildEnvelope, verifyChecksum, NAMESPACES } = require('./evidence-schema');
|
|
4
4
|
|
|
5
5
|
function createEvidenceStore({ namespace, adapter, now = Date.now, maxBytes = 8 * 1024 * 1024 } = {}) {
|
|
6
|
-
if (namespace
|
|
7
|
-
throw new TypeError('namespace must be script or
|
|
6
|
+
if (!NAMESPACES.includes(namespace)) {
|
|
7
|
+
throw new TypeError('namespace must be script, intent or response');
|
|
8
8
|
}
|
|
9
9
|
if (!adapter || typeof adapter.record !== 'function' || typeof adapter.readById !== 'function') {
|
|
10
10
|
throw new TypeError('adapter with record/readById is required');
|
|
@@ -133,8 +133,15 @@ function scriptSpecSchema(permissionNames) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function executionCommandSchema(command, permissionNames) {
|
|
136
|
-
|
|
137
|
-
const
|
|
136
|
+
// Script and Intent page with different cursors; the schema states which one.
|
|
137
|
+
const historyRead = {
|
|
138
|
+
afterSequence: { ...afterSequence, description: 'History page cursor: the previous response\'s history.lastSequence. Not an eventSequence.' },
|
|
139
|
+
limit: { ...pageLimit, description: 'History entries per page; it bounds entries, not response bytes.' },
|
|
140
|
+
};
|
|
141
|
+
const scriptRead = {
|
|
142
|
+
afterSequence: { ...afterSequence, description: 'Continuation cursor: the previous status/wait response\'s eventSequence; events and history after it are returned. Not history.lastSequence.' },
|
|
143
|
+
limit: pageLimit, eventLimit: pageLimit, includeCatalog: boolean,
|
|
144
|
+
};
|
|
138
145
|
const operation = (name, properties, required = []) => object({ operation: { const: name }, ...properties }, ['operation', ...required]);
|
|
139
146
|
const control = (name, properties = {}) => operation(name, { operationId: text, ...properties }, ['operationId']);
|
|
140
147
|
if (command === 'script') return variants('operation', [
|
|
@@ -144,7 +151,8 @@ function executionCommandSchema(command, permissionNames) {
|
|
|
144
151
|
{ if: { required: ['recordingDir'] }, then: { properties: { script: { properties: { policy: { properties: { restartPolicy: { const: 'none' } } } } } } } },
|
|
145
152
|
] },
|
|
146
153
|
control('result'),
|
|
147
|
-
control('status', scriptRead), control('wait', { ...scriptRead, waitMs: { ...integer(0, 60000), default: 30000
|
|
154
|
+
control('status', scriptRead), control('wait', { ...scriptRead, waitMs: { ...integer(0, 60000), default: 30000,
|
|
155
|
+
description: 'Upper bound of one wait call in milliseconds. To keep waiting, call wait again with the same operationId and afterSequence set to the last eventSequence.' } }),
|
|
148
156
|
...['pause', 'resume', 'cancel'].map(name => control(name, scriptRead)),
|
|
149
157
|
operation('decide', { operationId: text, requestId: text, revision, decision: { contentMediaType: 'application/json', description: 'JSON answer to the current ctx.askAgent request; business data is preserved.' }, ...scriptRead }, ['operationId', 'requestId', 'revision', 'decision']),
|
|
150
158
|
operation('runtime-status', {}),
|
|
@@ -156,7 +164,7 @@ function executionCommandSchema(command, permissionNames) {
|
|
|
156
164
|
return variants('operation', [
|
|
157
165
|
operation('start', { ...start, mode: { const: 'supervised', default: 'supervised' } }, ['goal', 'target']),
|
|
158
166
|
operation('start', { ...start, mode: { const: 'autonomous' }, agentModule: text, budget: intentBudget }, ['goal', 'target', 'mode', 'agentModule']),
|
|
159
|
-
control('status',
|
|
167
|
+
control('status', historyRead), control('observe', { basedOnRevision: revision, observationTarget: observationTargetSchema,
|
|
160
168
|
provider: { ...provider, description: 'Observe through this provider on the frozen target. Becomes selected only after the new observation and summary are committed; omitted retains the last committed selection.' } }),
|
|
161
169
|
operation('decide', { operationId: text, decision: intentDecisionSchema() }, ['operationId', 'decision']),
|
|
162
170
|
...['pause', 'resume', 'cancel'].map(name => control(name)),
|
|
@@ -164,7 +172,7 @@ function executionCommandSchema(command, permissionNames) {
|
|
|
164
172
|
]);
|
|
165
173
|
}
|
|
166
174
|
if (command === 'evidence') return variants('operation', [
|
|
167
|
-
operation('export', { namespace: { enum: ['intent', 'script'] }, operationId: text, outputDir: text, includeRecordedPayloads: boolean }, ['namespace', 'operationId', 'outputDir']),
|
|
175
|
+
operation('export', { namespace: { enum: ['intent', 'script', 'response'] }, operationId: text, outputDir: text, includeRecordedPayloads: boolean }, ['namespace', 'operationId', 'outputDir']),
|
|
168
176
|
operation('verify', { archiveDir: text, manifestSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' } }, ['archiveDir', 'manifestSha256']),
|
|
169
177
|
]);
|
|
170
178
|
throw new Error(`No execution contract for ${command}`);
|