@mobileaidev/ai-app-bridge 0.2.15 → 0.3.0-rc.2
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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +306 -53
- package/bin/ai-app-bridge.js +51 -4504
- package/bin/android-permissions.js +152 -0
- package/bin/android-uia-xml.js +74 -0
- package/bin/artifact-paths.js +127 -1
- package/bin/bridge-forward.js +56 -0
- package/bin/command-discovery.js +86 -0
- package/bin/command-errors.js +60 -0
- package/bin/command-registry.js +504 -0
- package/bin/command-request.js +14 -0
- package/bin/command-router.js +80 -0
- package/bin/connection-cache.js +119 -0
- package/bin/device-provider.js +2934 -0
- package/bin/execution-host.js +596 -0
- package/bin/execution-runtime.js +101 -0
- package/bin/fact-codec.js +321 -0
- package/bin/fact-recorder.js +691 -0
- package/bin/fact-store.js +226 -0
- package/bin/feedback-probe.js +285 -0
- package/bin/intent/install-intent.js +267 -0
- package/bin/intent/intent-action-executor.js +129 -0
- package/bin/intent/intent-autonomous-adapter.js +46 -0
- package/bin/intent/intent-capture-port.js +64 -0
- package/bin/intent/intent-entry.js +226 -0
- package/bin/intent/intent-errors.js +28 -0
- package/bin/intent/intent-evidence-store.js +121 -0
- package/bin/intent/intent-lifetime.js +62 -0
- package/bin/intent/intent-observation-target.js +33 -0
- package/bin/intent/intent-observer.js +194 -0
- package/bin/intent/intent-production-adapter.js +334 -0
- package/bin/intent/intent-provider.js +27 -0
- package/bin/intent/intent-runtime.js +63 -0
- package/bin/intent/intent-worker.js +432 -0
- package/bin/intent/ios-intent-adapter.js +90 -0
- package/bin/intent/permission-intent.js +239 -0
- package/bin/intent/web-intent-adapter.js +31 -0
- package/bin/ios-device-outcome.js +47 -0
- package/bin/ios-execution.js +108 -0
- package/bin/ios-provider.js +497 -583
- package/bin/ios-runtime-binding.js +54 -0
- package/bin/ios-wda-execution.js +70 -0
- package/bin/ios-wda-port.js +98 -0
- package/bin/ios-wda-project.js +79 -0
- package/bin/mcp-server.js +83 -1040
- package/bin/mmap-scan-index.js +329 -0
- package/bin/observation-collector.js +862 -0
- package/bin/runtime-client.js +154 -0
- package/bin/runtime-directory.js +107 -0
- package/bin/runtime-protocol.js +36 -0
- package/bin/script/bounded-script-registry.js +103 -0
- package/bin/script/node-runtime-adapter.js +233 -0
- package/bin/script/progress-projector.js +63 -0
- package/bin/script/python-runtime-adapter.js +111 -0
- package/bin/script/rolling-summary.js +134 -0
- package/bin/script/script-agent-port.js +40 -0
- package/bin/script/script-assert.js +95 -0
- package/bin/script/script-capture-port.js +76 -0
- package/bin/script/script-catalog.js +85 -0
- package/bin/script/script-durable-restore.js +195 -0
- package/bin/script/script-entry-code.js +26 -0
- package/bin/script/script-entry-route.js +38 -0
- package/bin/script/script-entry.js +3 -0
- package/bin/script/script-errors.js +29 -0
- package/bin/script/script-evidence-store.js +22 -0
- package/bin/script/script-format-removed.js +26 -0
- package/bin/script/script-host-port.js +397 -0
- package/bin/script/script-ledger.js +64 -0
- package/bin/script/script-result.js +57 -0
- package/bin/script/script-sdk.js +152 -0
- package/bin/script/script-sdk.py +153 -0
- package/bin/script/script-session-channel.js +127 -0
- package/bin/script/script-spec.js +86 -0
- package/bin/script/script-supervisor.js +919 -0
- package/bin/script/templates/checkpoint-reentry.js +13 -0
- package/bin/segment-index.js +481 -0
- package/bin/segmented-fact-store.js +1571 -0
- package/bin/shared-kernel/android-h5-target.js +10 -0
- package/bin/shared-kernel/android-install-execution.js +176 -0
- package/bin/shared-kernel/android-sdk-endpoint.js +42 -0
- package/bin/shared-kernel/android-shell-execution.js +195 -0
- package/bin/shared-kernel/argument-schema.js +117 -0
- package/bin/shared-kernel/canonical-path.js +17 -0
- package/bin/shared-kernel/device-acknowledgements.js +53 -0
- package/bin/shared-kernel/device-completion-history.js +52 -0
- package/bin/shared-kernel/device-mutation-lease.js +219 -0
- package/bin/shared-kernel/device-ownership-recovery.js +95 -0
- package/bin/shared-kernel/device-ownership-store.js +94 -0
- package/bin/shared-kernel/evidence-adapters.js +251 -0
- package/bin/shared-kernel/evidence-archive.js +329 -0
- package/bin/shared-kernel/evidence-recording.js +131 -0
- package/bin/shared-kernel/evidence-schema.js +194 -0
- package/bin/shared-kernel/evidence-store.js +190 -0
- package/bin/shared-kernel/execution-admission.js +22 -0
- package/bin/shared-kernel/execution-contracts.js +171 -0
- package/bin/shared-kernel/execution-io.js +106 -0
- package/bin/shared-kernel/execution-ledger.js +125 -0
- package/bin/shared-kernel/execution-scope.js +87 -0
- package/bin/shared-kernel/execution-target.js +115 -0
- package/bin/shared-kernel/flutter-execution.js +13 -0
- package/bin/shared-kernel/flutter-h5-port.js +60 -0
- package/bin/shared-kernel/flutter-h5-target.js +9 -0
- package/bin/shared-kernel/flutter-target.js +75 -0
- package/bin/shared-kernel/h5-execution.js +11 -0
- package/bin/shared-kernel/h5-target.js +31 -0
- package/bin/shared-kernel/host-fact-store.js +49 -0
- package/bin/shared-kernel/ios-h5-target.js +9 -0
- package/bin/shared-kernel/ios-native-target.js +71 -0
- package/bin/shared-kernel/live-capture-query.js +115 -0
- package/bin/shared-kernel/managed-sdk-execution.js +78 -0
- package/bin/shared-kernel/native-execution.js +13 -0
- package/bin/shared-kernel/native-target.js +156 -0
- package/bin/shared-kernel/provider-command-contracts.js +55 -0
- package/bin/shared-kernel/recorded-payload-archive.js +195 -0
- package/bin/shared-kernel/request-context.js +35 -0
- package/bin/shared-kernel/semantic-node.js +55 -0
- package/bin/shared-kernel/summary-transformer.js +352 -0
- package/bin/shared-kernel/target-lease-protocol.js +47 -0
- package/bin/shared-kernel/text-wait.js +111 -0
- package/bin/shared-kernel/uia-execution.js +96 -0
- package/bin/shared-kernel/uia-protocol.js +214 -0
- package/bin/shared-kernel/uia-runtime-port.js +377 -0
- package/bin/shared-kernel/uia-target.js +39 -0
- package/bin/shared-kernel/web-dom-target.js +44 -0
- package/bin/shared-kernel/xml-attributes.js +25 -0
- package/bin/target-execution.js +275 -0
- package/bin/web/command-schema.js +60 -0
- package/bin/web/session-store.js +157 -0
- package/bin/web-provider.js +334 -553
- package/docs/COMMAND_CONTRACT.md +1563 -0
- package/docs/EVIDENCE_ARCHIVE.md +214 -0
- package/docs/INTENT_FOREGROUND.md +71 -0
- package/docs/INTENT_NATIVE_EDITING.md +79 -0
- package/docs/RELEASE.md +59 -0
- package/docs/SCRIPT_AUTHORING.md +489 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/LICENSE +201 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/NOTICE +7 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +36 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/bindings/node/sfs_node.c +597 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/include/sfs.h +178 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +5 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +24 -0
- package/node_modules/@mobileaidev/segmented-fact-store-native/src/sfs.c +2349 -0
- package/package.json +60 -5
- package/runtime/ios-wda/AABWDABinding.h +19 -0
- package/runtime/ios-wda/AABWDABinding.m +97 -0
- package/runtime/ios-wda/AABWDAExecution.h +26 -0
- package/runtime/ios-wda/AABWDAExecution.m +172 -0
- package/runtime/ios-wda/AABWDAIntegration.h +71 -0
- package/runtime/ios-wda/AABWDAManagedRoutes.h +392 -0
- package/runtime/ios-wda/AABWDAReceiptStore.h +10 -0
- package/runtime/ios-wda/AABWDAReceiptStore.m +116 -0
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +22 -0
- package/skills/ai-app-bridge-use/SKILL.md +23 -360
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
4
|
+
const { CommandError } = require('../command-errors');
|
|
5
|
+
const { sanitizePersistentValue } = require('../fact-codec');
|
|
6
|
+
const { getHostFactStore, withHostFactStore } = require('./host-fact-store');
|
|
7
|
+
const { digest } = require('./uia-protocol');
|
|
8
|
+
|
|
9
|
+
const schema = 'aab.device-completion/v1';
|
|
10
|
+
const targetKey = serial => `device-completion:${serial}`;
|
|
11
|
+
|
|
12
|
+
function representation(json, originalSha256) {
|
|
13
|
+
const stored = sanitizePersistentValue(json), storedSha256 = digest(stored);
|
|
14
|
+
return { representation: stored === json ? 'original-json' : 'redacted-json', originalSha256, storedSha256, json: stored };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function persistCompletion({ pending, proof, historyTarget }) {
|
|
18
|
+
const record = { schemaVersion: schema, kind: 'uia-node', serial: pending.target.serial,
|
|
19
|
+
bootId: pending.bootId, runtimeEpoch: pending.runtimeEpoch, actionId: pending.actionId,
|
|
20
|
+
request: representation(pending.requestJson, pending.requestSha256),
|
|
21
|
+
completion: representation(proof.receiptJson, proof.responseSha256) };
|
|
22
|
+
return withHostFactStore(historyTarget, store => {
|
|
23
|
+
const key = targetKey(record.serial);
|
|
24
|
+
const saved = store.record({ partition: 'action', targetKey: key, runtimeEpoch: record.runtimeEpoch, actionId: record.actionId,
|
|
25
|
+
dedupeKey: `${key}:${record.runtimeEpoch}:${record.actionId}:${pending.requestSha256}:${proof.responseSha256}`,
|
|
26
|
+
payload: record }, { durability: 'sync' });
|
|
27
|
+
if (saved.ok !== true || saved.receipt?.stored !== true)
|
|
28
|
+
throw new CommandError('device_completion_history_unavailable', 'The original completion could not be committed to FactStore; phone acknowledgement remains pending.');
|
|
29
|
+
const page = store.read({ partitions: ['action'], targetKey: key, runtimeEpoch: record.runtimeEpoch, actionId: record.actionId, limit: 2 });
|
|
30
|
+
if (!page.ok || page.items?.length !== 1 || !isDeepStrictEqual(page.items[0].payload, record))
|
|
31
|
+
throw new CommandError('device_completion_history_invalid', 'The committed completion representation could not be read back exactly.');
|
|
32
|
+
return { storeDirectory: historyTarget.directory, targetKey: key, globalSeq: page.items[0].globalSeq,
|
|
33
|
+
runtimeEpoch: record.runtimeEpoch, actionId: record.actionId,
|
|
34
|
+
originalCompletionAvailable: record.request.representation === 'original-json' && record.completion.representation === 'original-json' };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readCompletion({ serial, runtimeEpoch, actionId }) {
|
|
39
|
+
const page = getHostFactStore().read({ partitions: ['action'], targetKey: targetKey(serial), runtimeEpoch, actionId, limit: 2 });
|
|
40
|
+
if (!page.ok) return { ok: false, error: page.error || 'device_completion_history_unavailable' };
|
|
41
|
+
if (page.items?.length !== 1) return { ok: false, error: page.items?.length ? 'device_completion_history_ambiguous' : 'device_completion_not_retained',
|
|
42
|
+
serial, runtimeEpoch, actionId, message: 'No unique retained completion representation matches this identity. Absence is not an action outcome.' };
|
|
43
|
+
const { payload: record, globalSeq } = page.items[0];
|
|
44
|
+
if (record?.schemaVersion !== schema || record.serial !== serial || record.runtimeEpoch !== runtimeEpoch || record.actionId !== actionId
|
|
45
|
+
|| [record.request, record.completion].some(value => !value || typeof value.json !== 'string' || digest(value.json) !== value.storedSha256
|
|
46
|
+
|| !/^[0-9a-f]{64}$/.test(value.originalSha256) || value.representation !== (value.originalSha256 === value.storedSha256 ? 'original-json' : 'redacted-json')))
|
|
47
|
+
return { ok: false, error: 'device_completion_history_invalid' };
|
|
48
|
+
return { ok: true, serial, runtimeEpoch, actionId, source: 'host-fact-store', globalSeq,
|
|
49
|
+
originalCompletionAvailable: record.request.representation === 'original-json' && record.completion.representation === 'original-json', record };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { persistCompletion, readCompletion };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AsyncLocalStorage } = require('node:async_hooks');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const { CommandError } = require('../command-errors');
|
|
6
|
+
const { createOwnershipStore, schemaVersion } = require('./device-ownership-store');
|
|
7
|
+
const acknowledgements = require('./device-acknowledgements');
|
|
8
|
+
|
|
9
|
+
const context = new AsyncLocalStorage();
|
|
10
|
+
const effectContext = new AsyncLocalStorage();
|
|
11
|
+
|
|
12
|
+
function rejection(error, serial, ownership) {
|
|
13
|
+
return { ok: false, error, serial, active: 1, dispatched: false, ambiguous: false,
|
|
14
|
+
message: error === 'target_busy' ? 'Another Host operation owns this physical device.'
|
|
15
|
+
: 'A previous device action has no confirmed completion. Reconcile device ownership before another mutation.',
|
|
16
|
+
...(ownership ? { ownership: structuredClone(ownership) } : {}) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function knownOutcome(value) {
|
|
20
|
+
if (value?.settled === true) return true;
|
|
21
|
+
if (value?.settled === false || value?.ambiguous === true || value?.dispatched === null) return false;
|
|
22
|
+
if (value?.dispatched === false && value?.ambiguous === false) return true;
|
|
23
|
+
// A protocol error without dispatch metadata is not proof that the remote
|
|
24
|
+
// operation stopped. In particular, an H5 timeout can leave a queued write.
|
|
25
|
+
return value?.ok !== false && value?.error == null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createDeviceMutationLease({ directory } = {}) {
|
|
29
|
+
const store = createOwnershipStore(directory);
|
|
30
|
+
const held = new Map();
|
|
31
|
+
function acquire(serial) {
|
|
32
|
+
if (typeof serial !== 'string' || !serial) return { ok: false, error: 'serial_required', dispatched: false, ambiguous: false };
|
|
33
|
+
const parent = context.getStore();
|
|
34
|
+
if (parent?.serial === serial && parent.directory === store.directory && !parent.closed) {
|
|
35
|
+
if (parent.state.phase === 'unresolved') return rejection('device_ownership_unresolved', serial, parent.state);
|
|
36
|
+
return { ok: true, serial, run: action => {
|
|
37
|
+
if (parent.closed) throw new CommandError('device_ownership_lost', 'The owning device operation has already closed.');
|
|
38
|
+
return action();
|
|
39
|
+
}, release() {} };
|
|
40
|
+
}
|
|
41
|
+
if (held.has(serial)) return rejection('target_busy', serial, held.get(serial).state);
|
|
42
|
+
const lock = store.lock(serial);
|
|
43
|
+
if (!lock) return rejection('target_busy', serial);
|
|
44
|
+
try {
|
|
45
|
+
const previous = store.read(serial);
|
|
46
|
+
if (previous?.pending || previous?.reservations.length) { lock.close(); return rejection('device_ownership_unresolved', serial, previous); }
|
|
47
|
+
const state = { schemaVersion, serial, phase: 'owned', owner: { id: randomUUID(), pid: process.pid, acquiredAtMs: Date.now() },
|
|
48
|
+
pending: null, reservations: [], lastSettlement: previous?.lastSettlement ?? null,
|
|
49
|
+
pendingAcknowledgements: previous?.pendingAcknowledgements ?? [] };
|
|
50
|
+
store.write(serial, state);
|
|
51
|
+
const owner = { serial, directory: store.directory, state, closed: false,
|
|
52
|
+
commit() { if (owner.closed) throw new CommandError('device_ownership_lost', 'The owning device operation has already closed.'); store.write(serial, state); } };
|
|
53
|
+
held.set(serial, owner);
|
|
54
|
+
return {
|
|
55
|
+
ok: true, serial,
|
|
56
|
+
retain(descriptor) {
|
|
57
|
+
if (owner.closed || state.phase === 'unresolved') throw new CommandError('device_ownership_lost', 'Cannot start work after the owner has stopped.');
|
|
58
|
+
const reservation = { ...descriptor, id: randomUUID(), preparedAtMs: Date.now() };
|
|
59
|
+
state.reservations.push(reservation); owner.commit();
|
|
60
|
+
let done = false;
|
|
61
|
+
return { settle(proof) {
|
|
62
|
+
if (done) return;
|
|
63
|
+
if (owner.closed) throw new CommandError('device_ownership_lost', 'The device owner has closed.');
|
|
64
|
+
if (proof?.settled !== true) return;
|
|
65
|
+
state.reservations = state.reservations.filter(item => item !== reservation);
|
|
66
|
+
state.lastSettlement = { pendingId: reservation.id, settledAtMs: Date.now(), proof };
|
|
67
|
+
owner.commit(); done = true;
|
|
68
|
+
} };
|
|
69
|
+
},
|
|
70
|
+
async run(action) {
|
|
71
|
+
if (owner.closed) throw new CommandError('device_ownership_lost', 'The owning device operation has already closed.');
|
|
72
|
+
if (state.phase === 'unresolved' || state.pending) return rejection('device_ownership_unresolved', serial, state);
|
|
73
|
+
// Only an actual transport effect or installation reservation can
|
|
74
|
+
// outlive this Host. Observation/preparation does not dispatch work.
|
|
75
|
+
const receipts = [];
|
|
76
|
+
owner.receipts = receipts;
|
|
77
|
+
try {
|
|
78
|
+
return await context.run(owner, action);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (receipts.length && !state.pending) {
|
|
81
|
+
error.settled = true;
|
|
82
|
+
if (receipts.some(proof => proof.dispatched === true)) error.dispatched = true;
|
|
83
|
+
if (receipts.length === 1) error.executionReceipt = receipts[0];
|
|
84
|
+
else error.executionReceipts = receipts;
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
} finally {
|
|
88
|
+
if (state.pending) { state.phase = 'unresolved'; owner.commit(); }
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
release() {
|
|
92
|
+
if (owner.closed) return;
|
|
93
|
+
try { state.phase = state.pending || state.reservations.length ? 'unresolved' : 'idle'; owner.commit(); }
|
|
94
|
+
finally { owner.closed = true; held.delete(serial); lock.close(); }
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
} catch (error) { lock.close(); throw error; }
|
|
98
|
+
}
|
|
99
|
+
async function run(serial, action) {
|
|
100
|
+
const token = acquire(serial);
|
|
101
|
+
if (!token.ok) return token;
|
|
102
|
+
try { return await token.run(action); } finally { token.release(); }
|
|
103
|
+
}
|
|
104
|
+
function status(serial) {
|
|
105
|
+
if (typeof serial !== 'string' || !serial) return { ok: false, error: 'serial_required' };
|
|
106
|
+
const lock = held.has(serial) ? null : store.lock(serial);
|
|
107
|
+
try {
|
|
108
|
+
const state = store.read(serial);
|
|
109
|
+
const unresolved = state?.pending || state?.reservations.length;
|
|
110
|
+
return { ok: true, serial, active: !lock || unresolved ? 1 : 0, maxActive: 1,
|
|
111
|
+
phase: !lock ? 'owned' : unresolved ? 'unresolved' : 'idle', ownership: state ? structuredClone(state) : null };
|
|
112
|
+
} finally { lock?.close(); }
|
|
113
|
+
}
|
|
114
|
+
async function reconcile(serial, verify) {
|
|
115
|
+
const lock = held.has(serial) ? null : store.lock(serial);
|
|
116
|
+
if (!lock) return rejection('target_busy', serial);
|
|
117
|
+
try {
|
|
118
|
+
const state = store.read(serial);
|
|
119
|
+
if (!state?.pending && !state?.reservations.length) return { ok: true, serial, phase: 'idle', recovered: false };
|
|
120
|
+
for (const pending of [state.pending, ...state.reservations].filter(Boolean)) {
|
|
121
|
+
const proof = await verify(structuredClone(pending));
|
|
122
|
+
if (proof?.settled !== true) return { ...rejection('device_ownership_unresolved', serial, state), recovery: proof };
|
|
123
|
+
acknowledgements.append(state, pending, proof);
|
|
124
|
+
state.lastSettlement = { pendingId: pending.id, settledAtMs: Date.now(), proof };
|
|
125
|
+
if (state.pending === pending) state.pending = null;
|
|
126
|
+
else state.reservations = state.reservations.filter(item => item !== pending);
|
|
127
|
+
store.write(serial, state);
|
|
128
|
+
}
|
|
129
|
+
state.phase = 'idle'; store.write(serial, state);
|
|
130
|
+
return { ok: true, serial, phase: 'idle', recovered: true, settlement: state.lastSettlement,
|
|
131
|
+
executionReceipt: state.lastSettlement.proof };
|
|
132
|
+
} finally { lock.close(); }
|
|
133
|
+
}
|
|
134
|
+
async function drainAcknowledgements(serial, acknowledge) {
|
|
135
|
+
const lock = held.has(serial) ? null : store.lock(serial);
|
|
136
|
+
if (!lock) return rejection('target_busy', serial);
|
|
137
|
+
try {
|
|
138
|
+
const state = store.read(serial), errors = [], retired = [];
|
|
139
|
+
if (!state) return { ok: true, remaining: 0, errors, retired };
|
|
140
|
+
for (const entry of [...state.pendingAcknowledgements]) {
|
|
141
|
+
try {
|
|
142
|
+
const result = await retireAcknowledgement(state, entry, () => store.write(serial, state), acknowledge);
|
|
143
|
+
retired.push({ actionId: entry.pending.actionId, disposition: result.disposition, history: result.history });
|
|
144
|
+
}
|
|
145
|
+
catch (error) { errors.push({ actionId: entry.pending.actionId, error: error.code || 'uia_acknowledgement_failed' }); }
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, remaining: state.pendingAcknowledgements.length, errors, retired };
|
|
148
|
+
} finally { lock.close(); }
|
|
149
|
+
}
|
|
150
|
+
return { acquire, run, status, reconcile, drainAcknowledgements, directory: store.directory };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Ordinary transport calls inherit a managed provider's effect scope. Its
|
|
154
|
+
// cancellation request must not replace the identity of the original action.
|
|
155
|
+
async function runDeviceEffect(descriptor, action, settled = knownOutcome, { allowNested = false } = {}) {
|
|
156
|
+
const owner = context.getStore();
|
|
157
|
+
if (!owner) return action();
|
|
158
|
+
if (owner.closed) throw new CommandError('device_ownership_lost', 'The device owner has closed.');
|
|
159
|
+
if (effectContext.getStore() === owner) return action();
|
|
160
|
+
const state = owner.state;
|
|
161
|
+
if (state.phase === 'unresolved' || state.pending && state.pending.allowNested !== true) {
|
|
162
|
+
throw new CommandError('device_ownership_unresolved', 'The preceding device action is still unresolved.');
|
|
163
|
+
}
|
|
164
|
+
acknowledgements.checkCapacity(state, descriptor);
|
|
165
|
+
const pending = { ...descriptor, ...(allowNested ? { allowNested: true } : {}), id: randomUUID(), preparedAtMs: Date.now() };
|
|
166
|
+
state.pending = pending; owner.commit();
|
|
167
|
+
const finish = proof => {
|
|
168
|
+
if (owner.closed || state.pending !== pending) throw new CommandError('device_ownership_lost', 'A different device operation owns the settlement.');
|
|
169
|
+
acknowledgements.append(state, pending, proof);
|
|
170
|
+
state.lastSettlement = { pendingId: pending.id, settledAtMs: Date.now(), proof };
|
|
171
|
+
state.pending = null; owner.commit();
|
|
172
|
+
owner.receipts?.push(proof);
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
const result = await (allowNested ? action() : effectContext.run(owner, action));
|
|
176
|
+
// The adapter's actual transport has taken responsibility for settlement.
|
|
177
|
+
// Do not restore a generic marker after its specific receipt is durable.
|
|
178
|
+
if (allowNested && state.pending !== pending) return result;
|
|
179
|
+
const proof = settled(result);
|
|
180
|
+
if (proof) finish(proof === true ? { kind: descriptor.kind, actionId: descriptor.actionId ?? null,
|
|
181
|
+
runtimeEpoch: descriptor.runtimeEpoch ?? null, settled: true, dispatched: result?.dispatched ?? null } : proof);
|
|
182
|
+
else { state.phase = 'unresolved'; owner.commit(); }
|
|
183
|
+
return result;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (allowNested && state.pending !== pending) throw error;
|
|
186
|
+
if (error?.dispatched === false && error?.ambiguous !== true) finish({ kind: descriptor.kind, settled: true, dispatched: false });
|
|
187
|
+
else { state.phase = 'unresolved'; owner.commit(); }
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let processLease;
|
|
193
|
+
function getProcessDeviceMutationLease() { return processLease ||= createDeviceMutationLease(); }
|
|
194
|
+
|
|
195
|
+
function isDeviceSettlementDurable(proof) {
|
|
196
|
+
const owner = context.getStore();
|
|
197
|
+
return Boolean(owner && !owner.closed && JSON.stringify(owner.state.lastSettlement?.proof) === JSON.stringify(proof));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function retireAcknowledgement(state, entry, commit, acknowledge) {
|
|
201
|
+
const history = require('./device-completion-history').persistCompletion(entry);
|
|
202
|
+
const result = await acknowledge(structuredClone(entry));
|
|
203
|
+
if (!acknowledgements.accepted(result))
|
|
204
|
+
throw new CommandError(result?.error || 'uia_acknowledgement_failed', 'The original receipt acknowledgement remains pending.');
|
|
205
|
+
const previous = state.pendingAcknowledgements;
|
|
206
|
+
state.pendingAcknowledgements = previous.filter(item => item !== entry);
|
|
207
|
+
try { commit(); } catch (error) { state.pendingAcknowledgements = previous; throw error; }
|
|
208
|
+
return { ...result, history };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function completeDeviceAcknowledgement(proof, acknowledge) {
|
|
212
|
+
const owner = context.getStore();
|
|
213
|
+
if (!owner || owner.closed) throw new CommandError('device_ownership_lost', 'Acknowledgement requires the original device owner.');
|
|
214
|
+
const entry = owner.state.pendingAcknowledgements.find(item => JSON.stringify(item.proof) === JSON.stringify(proof));
|
|
215
|
+
if (!entry) throw new CommandError('device_acknowledgement_invalid', 'No durable acknowledgement matches this completion receipt.');
|
|
216
|
+
return retireAcknowledgement(owner.state, entry, () => owner.commit(), acknowledge);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { createDeviceMutationLease, getProcessDeviceMutationLease, runDeviceEffect, isDeviceSettlementDurable, completeDeviceAcknowledgement };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getProcessDeviceMutationLease } = require('./device-mutation-lease');
|
|
4
|
+
const protocols = { flutter: require('./flutter-execution'), native: require('./native-execution'), h5: require('./h5-execution') };
|
|
5
|
+
const shellProtocol = require('./android-shell-execution');
|
|
6
|
+
const installProtocol = require('./android-install-execution');
|
|
7
|
+
const uiaProtocol = require('./uia-protocol');
|
|
8
|
+
const { createUiaRuntimePort } = require('./uia-runtime-port');
|
|
9
|
+
const acknowledgements = require('./device-acknowledgements');
|
|
10
|
+
|
|
11
|
+
async function deviceOwnership(args, { lease = getProcessDeviceMutationLease(), ports,
|
|
12
|
+
shellPortFactory = shellProtocol.createAndroidShellPort, installPortFactory = installProtocol.createAndroidInstallPort,
|
|
13
|
+
uiaPortFactory = createUiaRuntimePort } = {}) {
|
|
14
|
+
if (args.operation === 'status') return lease.status(args.serial);
|
|
15
|
+
if (args.operation === 'receipt') return require('./device-completion-history').readCompletion(args);
|
|
16
|
+
const bridge = ports || require('../device-provider');
|
|
17
|
+
const installCleanup = [];
|
|
18
|
+
const recovered = await lease.reconcile(args.serial, async pending => {
|
|
19
|
+
if (pending.kind === 'uia-node') {
|
|
20
|
+
if (!uiaProtocol.validIdentity(pending) || pending.target.serial !== args.serial) return { settled: false, error: 'invalid_uia_execution_identity' };
|
|
21
|
+
try {
|
|
22
|
+
const port = uiaPortFactory({ ...pending.target, timeoutMs: args.timeoutMs ?? 5000 });
|
|
23
|
+
const result = await port.recover(pending);
|
|
24
|
+
const proof = uiaProtocol.settlementProof(result, pending);
|
|
25
|
+
if (!proof) return { settled: false, error: result?.error || 'uia_original_completion_unavailable', actionId: pending.actionId };
|
|
26
|
+
return proof;
|
|
27
|
+
} catch (error) { return { settled: false, error: error.code || 'uia_completion_query_failed', message: error.message }; }
|
|
28
|
+
}
|
|
29
|
+
if (pending.kind === 'android-install') {
|
|
30
|
+
if (pending.target?.serial !== args.serial || typeof pending.target.adb !== 'string' || !installProtocol.validIdentity(pending)) {
|
|
31
|
+
return { settled: false, error: 'invalid_install_execution_identity' };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const port = installPortFactory({ ...pending.target, timeoutMs: args.timeoutMs ?? 5000 });
|
|
35
|
+
const result = await port.read(pending, true);
|
|
36
|
+
const proof = installProtocol.settlementProof(result, pending);
|
|
37
|
+
if (!proof) return { settled: false, error: 'install_completion_unavailable', actionId: pending.actionId };
|
|
38
|
+
installCleanup.push({ port, pending, result });
|
|
39
|
+
return proof;
|
|
40
|
+
} catch (error) { return { settled: false, error: error.code || 'install_completion_query_failed' }; }
|
|
41
|
+
}
|
|
42
|
+
if (pending.kind === 'android-shell') {
|
|
43
|
+
if (pending.target?.serial !== args.serial || typeof pending.target.adb !== 'string') {
|
|
44
|
+
return { settled: false, error: 'remote_completion_proof_unavailable' };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const port = shellPortFactory({ ...pending.target, timeoutMs: args.timeoutMs ?? 5000 });
|
|
48
|
+
const result = await port.cancel(pending);
|
|
49
|
+
return shellProtocol.settlementProof(result, pending)
|
|
50
|
+
|| { settled: false, error: result?.error || 'shell_action_not_settled', actionId: pending.actionId };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { settled: false, error: error.code || 'remote_completion_query_failed', message: error.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const protocol = protocols[pending.kind];
|
|
56
|
+
if (!protocol || typeof pending.actionId !== 'string' || typeof pending.runtimeEpoch !== 'string'
|
|
57
|
+
|| typeof pending.target?.packageName !== 'string') return { settled: false, error: 'remote_completion_proof_unavailable' };
|
|
58
|
+
const identity = { actionId: pending.actionId, runtimeEpoch: pending.runtimeEpoch };
|
|
59
|
+
const ctx = bridge.createBridgeContext({ ...pending.target, serial: args.serial, timeoutMs: args.timeoutMs ?? 5000 });
|
|
60
|
+
try {
|
|
61
|
+
// Re-discover and verify this phone/package connection. A changed runtime,
|
|
62
|
+
// missing action or idle SDK alone is never proof that this action ended.
|
|
63
|
+
const completion = { native: bridge.nativeCompletion, flutter: bridge.flutterCompletion, h5: bridge.h5Completion }[pending.kind];
|
|
64
|
+
const reply = await completion(ctx, identity);
|
|
65
|
+
if (reply?.ok !== true || reply.actionId !== identity.actionId || reply.runtimeEpoch !== identity.runtimeEpoch
|
|
66
|
+
|| !protocol.terminalReceipt(reply.executionResult, identity)) return {
|
|
67
|
+
settled: false, error: reply?.error || `invalid_${pending.kind}_cancel_receipt`, actionId: identity.actionId,
|
|
68
|
+
};
|
|
69
|
+
return protocol.settlementProof(reply.executionResult, identity);
|
|
70
|
+
} catch (error) { return { settled: false, error: error.code || 'remote_completion_query_failed', message: error.message }; }
|
|
71
|
+
});
|
|
72
|
+
// Reconciliation fsyncs each exact proof before its phone copy may be retired.
|
|
73
|
+
// Even a later unresolved reservation cannot invalidate an earlier settlement.
|
|
74
|
+
for (const { port, pending, result } of installCleanup) {
|
|
75
|
+
try { await port.acknowledge(pending, result); }
|
|
76
|
+
catch (error) { (recovered.cleanupErrors ||= []).push({ actionId: pending.actionId, error: error.code || 'install_cleanup_failed' }); }
|
|
77
|
+
}
|
|
78
|
+
if (recovered.error !== 'target_busy') {
|
|
79
|
+
// This queue survives a Host exit even when there is no unresolved effect.
|
|
80
|
+
// Retiring an older known receipt never clears a different pending action.
|
|
81
|
+
const cleanup = await lease.drainAcknowledgements(args.serial, async ({ pending, proof }) => {
|
|
82
|
+
const port = uiaPortFactory({ ...pending.target, timeoutMs: args.timeoutMs ?? 5000 });
|
|
83
|
+
return port.acknowledge(pending, acknowledgements.completion(pending, proof));
|
|
84
|
+
});
|
|
85
|
+
if (!cleanup.ok) (recovered.cleanupErrors ||= []).push({ error: cleanup.error });
|
|
86
|
+
else {
|
|
87
|
+
recovered.pendingAcknowledgements = cleanup.remaining;
|
|
88
|
+
if (cleanup.retired.length) recovered.acknowledgements = cleanup.retired;
|
|
89
|
+
if (cleanup.errors.length) (recovered.cleanupErrors ||= []).push(...cleanup.errors);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return recovered;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { deviceOwnership };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
7
|
+
const { CommandError } = require('../command-errors');
|
|
8
|
+
const acknowledgements = require('./device-acknowledgements');
|
|
9
|
+
|
|
10
|
+
const schemaVersion = 'aab.device-ownership/v2';
|
|
11
|
+
const maxJournalBytes = 4 * 1024 * 1024;
|
|
12
|
+
// Keep the original physical lock namespace across journal format upgrades.
|
|
13
|
+
const defaultDirectory = () => path.resolve(process.env.AI_APP_BRIDGE_DEVICE_OWNERSHIP_DIR
|
|
14
|
+
|| path.join(os.homedir(), '.ai-app-bridge', 'device-ownership', 'v1'));
|
|
15
|
+
|
|
16
|
+
// SQLite supplies an OS-managed exclusive file lock, with no heartbeat expiry.
|
|
17
|
+
// Process death releases the lock; the separately committed journal survives.
|
|
18
|
+
// Lock files are never unlinked, which would split ownership by inode.
|
|
19
|
+
function createOwnershipStore(directory = defaultDirectory()) {
|
|
20
|
+
directory = path.resolve(directory);
|
|
21
|
+
function paths(serial) {
|
|
22
|
+
const key = createHash('sha256').update(serial).digest('hex');
|
|
23
|
+
return { lock: path.join(directory, `${key}.sqlite`), journal: path.join(directory, `${key}.json`) };
|
|
24
|
+
}
|
|
25
|
+
function read(serial) {
|
|
26
|
+
let value;
|
|
27
|
+
let fd;
|
|
28
|
+
try {
|
|
29
|
+
fd = fs.openSync(paths(serial).journal, 'r');
|
|
30
|
+
if (fs.fstatSync(fd).size > maxJournalBytes) throw new Error('Device ownership journal exceeds 4 MiB.');
|
|
31
|
+
const bytes = fs.readFileSync(fd);
|
|
32
|
+
if (bytes.length > maxJournalBytes) throw new Error('Device ownership journal exceeds 4 MiB.');
|
|
33
|
+
value = JSON.parse(bytes.toString('utf8'));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT') return null;
|
|
37
|
+
throw new CommandError('device_ownership_corrupt', 'Cannot read the device ownership journal.', { details: { serial, cause: error.message } });
|
|
38
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
39
|
+
if (!['aab.device-ownership/v1', schemaVersion].includes(value?.schemaVersion) || value.serial !== serial || !Array.isArray(value.reservations) || !['owned', 'unresolved', 'idle'].includes(value.phase)
|
|
40
|
+
|| !(value.pending === null || value.pending && typeof value.pending.id === 'string' && typeof value.pending.kind === 'string')) {
|
|
41
|
+
throw new CommandError('device_ownership_corrupt', 'Invalid device ownership journal.', { details: { serial } });
|
|
42
|
+
}
|
|
43
|
+
if (value.schemaVersion === 'aab.device-ownership/v1') {
|
|
44
|
+
if (Object.hasOwn(value, 'pendingAcknowledgements'))
|
|
45
|
+
throw new CommandError('device_ownership_corrupt', 'A v1 journal cannot contain v2 acknowledgement obligations.');
|
|
46
|
+
// Lossless data upgrade: never move the lock or discard an old unknown action.
|
|
47
|
+
value = { ...value, schemaVersion, pendingAcknowledgements: [] };
|
|
48
|
+
}
|
|
49
|
+
const queue = value.pendingAcknowledgements;
|
|
50
|
+
if (!Array.isArray(queue) || queue.length > acknowledgements.capacity || queue.some(item => !acknowledgements.valid(item, serial))
|
|
51
|
+
|| new Set(queue.map(item => item.pending.id)).size !== queue.length)
|
|
52
|
+
throw new CommandError('device_ownership_corrupt', 'Invalid pending receipt acknowledgement journal.', { details: { serial } });
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function lock(serial) {
|
|
56
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
57
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
58
|
+
const file = paths(serial).lock;
|
|
59
|
+
let db;
|
|
60
|
+
try {
|
|
61
|
+
db = new DatabaseSync(file);
|
|
62
|
+
fs.chmodSync(file, 0o600);
|
|
63
|
+
db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
|
|
64
|
+
} catch (error) {
|
|
65
|
+
db?.close();
|
|
66
|
+
if (error.errcode === 5) return null;
|
|
67
|
+
throw new CommandError('device_ownership_unavailable', 'Cannot acquire the shared device ownership lock.', { details: { serial, cause: error.message } });
|
|
68
|
+
}
|
|
69
|
+
let closed = false;
|
|
70
|
+
return { close() { if (!closed) { closed = true; try { db.exec('ROLLBACK'); } finally { db.close(); } } } };
|
|
71
|
+
}
|
|
72
|
+
function write(serial, value) {
|
|
73
|
+
const file = paths(serial).journal;
|
|
74
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
75
|
+
let fd;
|
|
76
|
+
try {
|
|
77
|
+
const bytes = JSON.stringify(value) + '\n';
|
|
78
|
+
if (Buffer.byteLength(bytes) > maxJournalBytes) throw new Error('Device ownership journal exceeds 4 MiB.');
|
|
79
|
+
fd = fs.openSync(temporary, 'wx', 0o600);
|
|
80
|
+
fs.writeFileSync(fd, bytes);
|
|
81
|
+
fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
82
|
+
fs.renameSync(temporary, file);
|
|
83
|
+
const parent = fs.openSync(directory, 'r');
|
|
84
|
+
try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
87
|
+
try { fs.unlinkSync(temporary); } catch (failure) { if (failure.code !== 'ENOENT') error.cleanupError = failure.code; }
|
|
88
|
+
throw new CommandError('device_ownership_unavailable', 'Cannot commit device ownership; no new action is allowed.', { details: { serial, cause: error.message } });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { directory, read, lock, write };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { createOwnershipStore, defaultDirectory, schemaVersion };
|