@mobileaidev/ai-app-bridge 0.4.1 → 0.4.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/README.md +9 -3
- package/bin/command-registry.js +5 -5
- package/bin/executors/automation-owner.js +15 -4
- package/bin/runtime-client.js +5 -1
- package/bin/runtime-directory.js +1 -1
- package/bin/shared-kernel/device-force-stop.js +98 -0
- package/bin/shared-kernel/device-mutation-lease.js +28 -7
- package/bin/shared-kernel/device-ownership-store.js +2 -1
- package/bin/shared-kernel/host-process-stop.js +62 -0
- package/bin/shared-kernel/uia-runtime-port.js +19 -0
- package/docs/COMMAND_CONTRACT.md +26 -2
- package/docs/INSTALLATION.md +2 -2
- package/docs/OPTIONAL_EXECUTORS.md +4 -4
- package/docs/RELEASE.md +37 -71
- package/package.json +1 -1
- package/runtime/uia/ai-app-bridge-uia.jar +0 -0
- package/runtime/uia/manifest.json +3 -2
- package/skills/ai-app-bridge-use/SKILL.md +2 -0
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ discovery. Every request checks the mapping before dispatch. Mutating requests
|
|
|
7
7
|
are never replayed after a missing route or uncertain result. For manual cleanup,
|
|
8
8
|
pass the exact serial and returned Host port to `remove-forward`.
|
|
9
9
|
|
|
10
|
-
This source version is `0.4.
|
|
10
|
+
This source version is `0.4.2`; registry publication is a separate release step.
|
|
11
11
|
It fixes iOS observation ownership and preserves extraction results when
|
|
12
|
-
temporary cleanup fails. Device and Web SDKs share version `0.4.
|
|
12
|
+
temporary cleanup fails. Device and Web SDKs share version `0.4.2`; their execution contracts are unchanged.
|
|
13
13
|
The default installation includes the Script/Intent and capture contracts below.
|
|
14
14
|
Local package verification does not change npm dist-tags.
|
|
15
15
|
The supported Node range is `>=26.3.0 <27`; this release was checked on 26.3.0.
|
|
@@ -67,7 +67,7 @@ domains, commands, and options, then call `run` with the selected command.
|
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
# Install the current stable release; see docs/RELEASE.md for packaging.
|
|
70
|
-
npm install -g @mobileaidev/ai-app-bridge@0.4.
|
|
70
|
+
npm install -g @mobileaidev/ai-app-bridge@0.4.2
|
|
71
71
|
|
|
72
72
|
ai-app-bridge status --extract null --package-name io.github.mobileaidev.aiappbridge.sample
|
|
73
73
|
ai-app-bridge tree --extract null --package-name io.github.mobileaidev.aiappbridge.sample
|
|
@@ -341,6 +341,12 @@ recovery. `uia-runtime --serial DEVICE --operation status|start|stop` controls
|
|
|
341
341
|
its lifecycle. Fresh observation rotates a full, durably acknowledged session;
|
|
342
342
|
startup retires only confirmed history. Explicit start checks the phone's
|
|
343
343
|
process lock before reopening a dead runtime; unknown actions still block it.
|
|
344
|
+
For a stuck action or agent handoff, run `ai-app-bridge device-ownership
|
|
345
|
+
--operation force-stop --serial DEVICE --extract null`. This independent reset
|
|
346
|
+
stops old Host tasks and phone executors, archives the unknown action, and frees
|
|
347
|
+
occupancy without waiting for its receipt. Tasks sharing those Host processes
|
|
348
|
+
also stop. New operations can then start; dispatched effects are not rolled back.
|
|
349
|
+
Occupancy errors include the same recovery hint.
|
|
344
350
|
Host crashes leave a durable pending-acknowledgement queue. `device-ownership
|
|
345
351
|
--operation reconcile --serial DEVICE` commits completion history, acknowledges
|
|
346
352
|
live or stopped phone records and retires that queue without replay.
|
package/bin/command-registry.js
CHANGED
|
@@ -17,7 +17,7 @@ const commandDefinitions = [
|
|
|
17
17
|
{ command: 'flutter-executor', domain: 'advanced', summary: 'Optional Flutter integration_test executor on Android. Open the installed application test entrypoint, observe widgets, run WidgetTester actions, query receipts and close the test process. Requires app.test in Script.', targetKind: 'android-app', options: ['operation'] },
|
|
18
18
|
{ command: 'web-executor', domain: 'web', summary: 'Optional Playwright executor: inspect readiness, prepare pinned browser dependencies, open a browser, observe bound frames, act, wait, query original receipts, or close. Browser input and test operations retain their actual mechanisms. Script requires app.test.', targetKind: 'web-target', options: ['operation'] },
|
|
19
19
|
{ command: 'runtime', domain: 'execution', summary: 'Inspect, start or orderly stop the shared local execution runtime. CLI exit and MCP disconnect leave operations running; stop cancels and drains them.', targetKind: 'host-runtime', options: ['operation'] },
|
|
20
|
-
{ command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile
|
|
20
|
+
{ command: 'device-ownership', domain: 'execution', summary: 'Read ownership, reconcile completion, or force-stop stuck tasks and reset device occupancy without requiring old receipts. Force-stop terminates the owning and shared Host runtimes, resets phone UIA history, and stops recorded SDK apps; dispatched effects keep an unknown outcome. Also cancel-install or read a retained UIA receipt.', targetKind: 'android-device', options: ['operation', 'serial', 'timeoutMs', 'runtimeEpoch', 'actionId'] },
|
|
21
21
|
{ command: 'uia-runtime', domain: 'advanced', summary: 'Read, start or orderly stop the Android API 25+ UIA node runtime. Start checks the phone process lock; unacknowledged original receipts are retained.', targetKind: 'android-device', options: ['operation', 'serial', 'adb', 'timeoutMs'] },
|
|
22
22
|
{ command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
|
|
23
23
|
{ command: 'tree', domain: 'core', summary: 'Read Android View tree from the in-app bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
|
|
@@ -252,7 +252,7 @@ function isMutationCommand(command, args = {}) {
|
|
|
252
252
|
if (command === 'web-executor') return ['prepare', 'open', 'act', 'navigate', 'close'].includes(args.operation);
|
|
253
253
|
if (command === 'web-command' && args.name === 'domSnapshot') return false;
|
|
254
254
|
return mutationCommands.has(command) || (command === 'logcat' && args.clear === true)
|
|
255
|
-
|| (command === 'device-ownership' &&
|
|
255
|
+
|| (command === 'device-ownership' && ['cancel-install', 'force-stop'].includes(args.operation))
|
|
256
256
|
|| (command === 'ios-wda-session' && args.operation !== 'status')
|
|
257
257
|
|| (command === 'uia-runtime' && args.operation !== 'status')
|
|
258
258
|
|| ((command === 'webview-network' || command === 'webview-console') && args.script !== undefined);
|
|
@@ -321,7 +321,7 @@ function commandContract(command) {
|
|
|
321
321
|
providersByPlatform: { android: ['native', 'uia', 'flutter', 'h5'], ios: ['native', 'h5', 'flutter'], web: ['h5'] } } : {}),
|
|
322
322
|
execution: { kind: role === 'execution' ? 'operation' : isMutationCommand(command) ? 'mutation' : 'query',
|
|
323
323
|
mutation: isMutationCommand(command),
|
|
324
|
-
conditionalMutation: command === 'web-executor' ? 'operation in prepare,open,act,navigate,close' : ['android-executor', 'flutter-executor'].includes(command) ? 'operation in open,act,close' : command === 'uia-runtime' ? 'operation != status' : command === 'logcat' ? 'clear=true' : ['webview-network', 'webview-console'].includes(command) ? 'script is supplied' : null,
|
|
324
|
+
conditionalMutation: command === 'device-ownership' ? 'operation in reconcile,cancel-install,force-stop' : command === 'web-executor' ? 'operation in prepare,open,act,navigate,close' : ['android-executor', 'flutter-executor'].includes(command) ? 'operation in open,act,close' : command === 'uia-runtime' ? 'operation != status' : command === 'logcat' ? 'clear=true' : ['webview-network', 'webview-console'].includes(command) ? 'script is supplied' : null,
|
|
325
325
|
arbitration: command === 'script' || command === 'intent' ? 'target-platform-physical-device'
|
|
326
326
|
: isAndroidMutation(command) ? 'cross-process-physical-android-device'
|
|
327
327
|
: platform === 'ios' && isMutationCommand(command) ? 'cross-process-physical-ios-device'
|
|
@@ -342,11 +342,11 @@ function commandSchema(command) {
|
|
|
342
342
|
properties: { operation: { enum: ['start', 'status', 'stop'] } } };
|
|
343
343
|
if (definition.domain === 'web') return require('./web/command-schema').webSchema(command);
|
|
344
344
|
if (command === 'device-ownership') return { type: 'object', additionalProperties: false,
|
|
345
|
-
properties: { operation: { enum: ['status', 'reconcile', 'receipt', 'cancel-install'] }, serial: { type: 'string', minLength: 1 },
|
|
345
|
+
properties: { operation: { enum: ['status', 'reconcile', 'receipt', 'cancel-install', 'force-stop'] }, serial: { type: 'string', minLength: 1 },
|
|
346
346
|
runtimeEpoch: { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' },
|
|
347
347
|
actionId: { type: 'string', minLength: 1, maxLength: 1024 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 30000 } },
|
|
348
348
|
required: ['operation', 'serial'], oneOf: [
|
|
349
|
-
{ properties: { operation: { enum: ['status', 'reconcile'] }, runtimeEpoch: false, actionId: false } },
|
|
349
|
+
{ properties: { operation: { enum: ['status', 'reconcile', 'force-stop'] }, runtimeEpoch: false, actionId: false } },
|
|
350
350
|
{ properties: { operation: { const: 'receipt' }, timeoutMs: false }, required: ['runtimeEpoch', 'actionId'] },
|
|
351
351
|
{ properties: { operation: { const: 'cancel-install' }, runtimeEpoch: false }, required: ['actionId'] },
|
|
352
352
|
] };
|
|
@@ -8,8 +8,19 @@ const { CommandError } = require('../command-errors');
|
|
|
8
8
|
const { atomicJson, readJson } = require('./managed-runtime');
|
|
9
9
|
const { AndroidExecutorPort } = require('./android-port');
|
|
10
10
|
|
|
11
|
-
const fileFor = serial => path.join(
|
|
12
|
-
function owner(serial) { return readJson(fileFor(serial)); }
|
|
11
|
+
const fileFor = (serial, directory = defaultDirectory()) => path.join(directory, 'automation-sessions', createHash('sha256').update(serial).digest('hex') + '.json');
|
|
12
|
+
function owner(serial, directory) { return readJson(fileFor(serial, directory)); }
|
|
13
|
+
function archive(serial, resetId, directory = defaultDirectory()) {
|
|
14
|
+
const file = fileFor(serial, directory);
|
|
15
|
+
if (!fs.existsSync(file)) return null;
|
|
16
|
+
const destination = path.join(directory, 'force-stopped', `${resetId}.automation.json`);
|
|
17
|
+
fs.renameSync(file, destination);
|
|
18
|
+
for (const parent of [path.dirname(file), path.dirname(destination)]) {
|
|
19
|
+
const fd = fs.openSync(parent, 'r');
|
|
20
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
21
|
+
}
|
|
22
|
+
return destination;
|
|
23
|
+
}
|
|
13
24
|
function claim(serial, descriptorFile) {
|
|
14
25
|
const descriptor = readJson(descriptorFile);
|
|
15
26
|
if (!descriptor || descriptor.serial !== serial) throw new CommandError('executor_descriptor_mismatch', 'Automation claim requires the exact device descriptor.');
|
|
@@ -35,8 +46,8 @@ async function assertAvailable(serial) {
|
|
|
35
46
|
const runner = readJson(path.join(path.dirname(current.descriptorFile), 'runner-result.json'));
|
|
36
47
|
if ((runner?.sessionId === current.sessionId && runner.instrumentFinished)
|
|
37
48
|
|| await port.bootChanged() || (descriptor.pid && await port.processEnded())) { release(serial, current.sessionId); return; }
|
|
38
|
-
throw new CommandError('uia_owned_by_test_executor', 'The test session owns UiAutomation.
|
|
49
|
+
throw new CommandError('uia_owned_by_test_executor', 'The test session owns UiAutomation. Close that session, or use device-ownership force-stop to stop stuck tasks and reset this device.',
|
|
39
50
|
{ dispatched: false, ambiguous: false, details: { serial, sessionId: current.sessionId, runtimeEpoch: descriptor.runtimeEpoch, packageName: descriptor.packageName } });
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
module.exports = { owner, claim, release, assertAvailable };
|
|
53
|
+
module.exports = { owner, claim, release, archive, assertAvailable };
|
package/bin/runtime-client.js
CHANGED
|
@@ -137,7 +137,8 @@ async function run(request, { signal } = {}) {
|
|
|
137
137
|
let extract;
|
|
138
138
|
try {
|
|
139
139
|
request = validateRunRequest(request);
|
|
140
|
-
if (request.command === 'runtime' || (request.command === 'evidence' && request.arguments.operation === 'verify')
|
|
140
|
+
if (request.command === 'runtime' || (request.command === 'evidence' && request.arguments.operation === 'verify')
|
|
141
|
+
|| (request.command === 'device-ownership' && request.arguments.operation === 'force-stop')) {
|
|
141
142
|
extract = require('./extraction/prepare').prepareExtraction(request.extract);
|
|
142
143
|
}
|
|
143
144
|
}
|
|
@@ -145,6 +146,9 @@ async function run(request, { signal } = {}) {
|
|
|
145
146
|
const local = async reply => ({ value: await finishReply({ body: publicReply({ command: request.command, reply, completed: true }),
|
|
146
147
|
extract, output: request.output }) });
|
|
147
148
|
try {
|
|
149
|
+
if (request.command === 'device-ownership' && request.arguments.operation === 'force-stop') {
|
|
150
|
+
return local({ value: await require('./shared-kernel/device-force-stop').forceStopDevice(request.arguments) });
|
|
151
|
+
}
|
|
148
152
|
// Verification is an offline command in both transports; it neither
|
|
149
153
|
// opens FactStore nor depends on a running owner or valid store profile.
|
|
150
154
|
if (request.command === 'evidence' && request.arguments.operation === 'verify') {
|
package/bin/runtime-directory.js
CHANGED
|
@@ -80,7 +80,7 @@ function requireCompatible(status, client) {
|
|
|
80
80
|
let next;
|
|
81
81
|
if (!codeMismatch) next = 'Use matching persistent/provider configuration; the running Runtime was not restarted.';
|
|
82
82
|
else if (outdatedSide === 'client') next = 'The client package is older. Update and reconnect the client; do not stop the newer Runtime to fix this client.';
|
|
83
|
-
else if (outdatedSide === 'runtime') next = 'The Runtime package is older. Finish its active tasks, then explicitly run runtime --operation stop --extract null and reconnect with the updated installation.';
|
|
83
|
+
else if (outdatedSide === 'runtime') next = 'The Runtime package is older. Finish its active tasks, then explicitly run runtime --operation stop --extract null and reconnect with the updated installation. If a device task is stuck, device-ownership --operation force-stop --serial <serial> --extract null forcibly stops old tasks and resets occupancy.';
|
|
84
84
|
else next = 'Builds or Node environments differ. Align the installations and reconnect; fingerprints alone cannot identify an older side.';
|
|
85
85
|
throw new CommandError(codeMismatch ? 'runtime_code_mismatch' : 'runtime_configuration_mismatch',
|
|
86
86
|
`Client: ${describe(client)}. Runtime: ${describe(runtime)}. ${next}`,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { randomUUID } = require('node:crypto');
|
|
6
|
+
const { CommandError } = require('../command-errors');
|
|
7
|
+
const { createOwnershipStore, schemaVersion } = require('./device-ownership-store');
|
|
8
|
+
const { stopHosts } = require('./host-process-stop');
|
|
9
|
+
|
|
10
|
+
async function stopDevice(serial, pending, timeoutMs, automation) {
|
|
11
|
+
const { readJson } = require('../executors/managed-runtime');
|
|
12
|
+
const descriptor = automation ? readJson(automation.descriptorFile) : null;
|
|
13
|
+
if (descriptor && (descriptor.serial !== serial || descriptor.sessionId !== automation.sessionId))
|
|
14
|
+
throw new CommandError('executor_descriptor_mismatch', 'The recorded test session does not match this device.');
|
|
15
|
+
const { executablePath } = require('./executable-path');
|
|
16
|
+
const adb = pending.find(item => item.target?.adb)?.target.adb || descriptor?.adb || executablePath(process.env.ADB || 'adb');
|
|
17
|
+
const { createUiaRuntimePort } = require('./uia-runtime-port');
|
|
18
|
+
const uia = await createUiaRuntimePort({ adb, serial, timeoutMs }).forceStop();
|
|
19
|
+
const packages = [...new Set([...pending.map(item => item.target?.packageName || item.packageName),
|
|
20
|
+
descriptor?.packageName, descriptor?.instrumentation?.split('/')[0]].filter(Boolean))];
|
|
21
|
+
const { execFileBounded } = require('./execution-io');
|
|
22
|
+
for (const packageName of packages) {
|
|
23
|
+
if (!/^[a-zA-Z0-9_.]+$/.test(packageName)) throw new CommandError('invalid_force_stop_package', 'The recorded Android package is invalid.');
|
|
24
|
+
await execFileBounded(adb, ['-s', serial, 'shell', 'am', 'force-stop', packageName], { timeoutMs, mutation: false, encoding: 'utf8' });
|
|
25
|
+
}
|
|
26
|
+
return { ...uia, stoppedPackages: packages };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// This control runs in the CLI/MCP client, before contacting the shared runtime.
|
|
30
|
+
// A hung runtime, its queues, and version mismatch cannot intercept the reset.
|
|
31
|
+
async function forceStopDevice(args, { directory, stopHost = stopHosts, stopDevice: remoteStop = stopDevice,
|
|
32
|
+
runtimeEndpoint } = {}) {
|
|
33
|
+
const store = createOwnershipStore(directory);
|
|
34
|
+
const control = store.controlLock(args.serial);
|
|
35
|
+
if (!control) throw new CommandError('device_reset_in_progress', 'Another force-stop is resetting this device. Retry after it finishes.');
|
|
36
|
+
let lock;
|
|
37
|
+
let journalError = null;
|
|
38
|
+
const readForReset = () => {
|
|
39
|
+
try { return store.read(args.serial); }
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'device_ownership_corrupt') throw error;
|
|
42
|
+
// Forced reset archives corrupt bytes instead of requiring the damaged
|
|
43
|
+
// task record to prove completion. Normal admission remains strict.
|
|
44
|
+
journalError = error.code; return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
let previous = readForReset();
|
|
49
|
+
if (runtimeEndpoint === undefined) {
|
|
50
|
+
const { runtimeLocation, readEndpoint } = require('../runtime-directory');
|
|
51
|
+
runtimeEndpoint = readEndpoint(runtimeLocation());
|
|
52
|
+
}
|
|
53
|
+
const stoppedHostPids = await stopHost(previous?.owner, runtimeEndpoint);
|
|
54
|
+
const deadline = Date.now() + (args.timeoutMs ?? 10000);
|
|
55
|
+
while (!(lock = store.lock(args.serial))) {
|
|
56
|
+
if (Date.now() >= deadline) throw new CommandError('force_stop_host_still_owned', 'The terminated Host has not released its OS lock.');
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
58
|
+
}
|
|
59
|
+
const resetId = randomUUID(), resetAtMs = Date.now();
|
|
60
|
+
previous = readForReset();
|
|
61
|
+
const pending = previous?.resetPending || [previous?.pending, ...(previous?.reservations || [])].filter(Boolean);
|
|
62
|
+
const abandoned = pending.map(item => ({ kind: item.kind, actionId: item.actionId ?? null, runtimeEpoch: item.runtimeEpoch ?? null }));
|
|
63
|
+
const archiveDir = path.join(store.directory, 'force-stopped');
|
|
64
|
+
fs.mkdirSync(archiveDir, { recursive: true, mode: 0o700 });
|
|
65
|
+
const archivePath = fs.existsSync(store.paths(args.serial).journal) ? path.join(archiveDir, `${resetId}.json`) : null;
|
|
66
|
+
if (archivePath) {
|
|
67
|
+
const file = fs.openSync(archivePath, 'wx', 0o600);
|
|
68
|
+
try { fs.writeFileSync(file, fs.readFileSync(store.paths(args.serial).journal)); fs.fsyncSync(file); }
|
|
69
|
+
finally { fs.closeSync(file); }
|
|
70
|
+
const parent = fs.openSync(archiveDir, 'r');
|
|
71
|
+
try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); }
|
|
72
|
+
}
|
|
73
|
+
const state = { schemaVersion, serial: args.serial, phase: 'idle', owner: null, pending: null, reservations: [],
|
|
74
|
+
pendingAcknowledgements: [], lastSettlement: previous?.lastSettlement ?? null, resetRequired: true, resetPending: pending,
|
|
75
|
+
lastReset: { resetId, resetAtMs, disposition: 'force_stopped', outcome: 'unknown', abandoned, archivePath, stoppedHostPids,
|
|
76
|
+
...(journalError ? { journalError } : {}) } };
|
|
77
|
+
store.write(args.serial, state);
|
|
78
|
+
const automationStore = require('../executors/automation-owner');
|
|
79
|
+
let phone;
|
|
80
|
+
try {
|
|
81
|
+
const automation = automationStore.owner(args.serial, store.directory);
|
|
82
|
+
phone = await remoteStop(args.serial, pending, args.timeoutMs ?? 10000, automation);
|
|
83
|
+
if (phone?.ok !== true) throw new CommandError(phone?.error || 'force_stop_device_failed', 'The phone did not confirm the forced reset.');
|
|
84
|
+
if (automation) state.lastReset.automationArchivePath = automationStore.archive(args.serial, resetId, store.directory);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const code = typeof error.code === 'string' && /^[a-z][a-z0-9_]*$/.test(error.code) ? error.code : 'force_stop_device_failed';
|
|
87
|
+
return { ok: false, error: code, phase: 'idle', serial: args.serial,
|
|
88
|
+
ownershipReleased: true, remoteResetPending: true, ...state.lastReset,
|
|
89
|
+
remoteError: { code, message: error.message },
|
|
90
|
+
message: 'Local occupancy was reset. Reconnect the device and repeat device-ownership force-stop before new actions.' };
|
|
91
|
+
}
|
|
92
|
+
state.resetRequired = false; delete state.resetPending; state.lastReset.phone = phone; store.write(args.serial, state);
|
|
93
|
+
return { ok: true, serial: args.serial, phase: 'idle', ownershipReleased: true, remoteResetPending: false,
|
|
94
|
+
...state.lastReset, phone, message: 'Forced reset completed. Old tasks were stopped; agents can start new operations.' };
|
|
95
|
+
} finally { lock?.close(); control.close(); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { forceStopDevice };
|
|
@@ -9,10 +9,15 @@ const acknowledgements = require('./device-acknowledgements');
|
|
|
9
9
|
const context = new AsyncLocalStorage();
|
|
10
10
|
const effectContext = new AsyncLocalStorage();
|
|
11
11
|
|
|
12
|
+
const recoveryHint = serial => ({ command: 'device-ownership', arguments: { operation: 'force-stop', serial },
|
|
13
|
+
message: 'If stuck, force-stop this device to cancel old tasks and reset occupancy. Already dispatched effects are not rolled back.' });
|
|
14
|
+
|
|
12
15
|
function rejection(error, serial, ownership) {
|
|
13
16
|
return { ok: false, error, serial, active: 1, dispatched: false, ambiguous: false,
|
|
14
17
|
message: error === 'target_busy' ? 'Another Host operation owns this physical device.'
|
|
18
|
+
: error === 'device_reset_required' ? 'Phone reset is incomplete. Reconnect and repeat device-ownership force-stop before new actions.'
|
|
15
19
|
: 'A previous device action has no confirmed completion. Reconcile device ownership before another mutation.',
|
|
20
|
+
recoveryHint: recoveryHint(serial),
|
|
16
21
|
...(ownership ? { ownership: structuredClone(ownership) } : {}) };
|
|
17
22
|
}
|
|
18
23
|
|
|
@@ -39,13 +44,19 @@ function createDeviceMutationLease({ directory } = {}) {
|
|
|
39
44
|
}, release() {} };
|
|
40
45
|
}
|
|
41
46
|
if (held.has(serial)) return rejection('target_busy', serial, held.get(serial).state);
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
47
|
+
const control = store.controlLock(serial);
|
|
48
|
+
if (!control) return rejection('target_busy', serial);
|
|
49
|
+
let lock;
|
|
44
50
|
try {
|
|
51
|
+
lock = store.lock(serial);
|
|
52
|
+
if (!lock) return rejection('target_busy', serial);
|
|
45
53
|
const previous = store.read(serial);
|
|
54
|
+
if (previous?.resetRequired) { lock.close(); return rejection('device_reset_required', serial, previous); }
|
|
46
55
|
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,
|
|
56
|
+
const state = { schemaVersion, serial, phase: 'owned', owner: { id: randomUUID(), pid: process.pid,
|
|
57
|
+
processStart: require('./host-process-stop').processStart(), acquiredAtMs: Date.now() },
|
|
48
58
|
pending: null, reservations: [], lastSettlement: previous?.lastSettlement ?? null,
|
|
59
|
+
lastReset: previous?.lastReset ?? null,
|
|
49
60
|
pendingAcknowledgements: previous?.pendingAcknowledgements ?? [] };
|
|
50
61
|
store.write(serial, state);
|
|
51
62
|
const owner = { serial, directory: store.directory, state, closed: false,
|
|
@@ -94,7 +105,8 @@ function createDeviceMutationLease({ directory } = {}) {
|
|
|
94
105
|
finally { owner.closed = true; held.delete(serial); lock.close(); }
|
|
95
106
|
},
|
|
96
107
|
};
|
|
97
|
-
} catch (error) { lock
|
|
108
|
+
} catch (error) { lock?.close(); throw error; }
|
|
109
|
+
finally { control.close(); }
|
|
98
110
|
}
|
|
99
111
|
async function run(serial, action) {
|
|
100
112
|
const token = acquire(serial);
|
|
@@ -108,11 +120,16 @@ function createDeviceMutationLease({ directory } = {}) {
|
|
|
108
120
|
const state = store.read(serial);
|
|
109
121
|
const unresolved = state?.pending || state?.reservations.length;
|
|
110
122
|
return { ok: true, serial, active: !lock || unresolved ? 1 : 0, maxActive: 1,
|
|
111
|
-
phase: !lock ? 'owned' : unresolved ? 'unresolved' : 'idle', ownership: state ? structuredClone(state) : null
|
|
123
|
+
phase: !lock ? 'owned' : unresolved ? 'unresolved' : 'idle', ownership: state ? structuredClone(state) : null,
|
|
124
|
+
...(unresolved || state?.resetRequired ? { recoveryHint: recoveryHint(serial) } : {}) };
|
|
112
125
|
} finally { lock?.close(); }
|
|
113
126
|
}
|
|
114
127
|
async function reconcile(serial, verify) {
|
|
115
|
-
const
|
|
128
|
+
const control = store.controlLock(serial);
|
|
129
|
+
if (!control) return rejection('target_busy', serial);
|
|
130
|
+
let lock;
|
|
131
|
+
try { lock = held.has(serial) ? null : store.lock(serial); }
|
|
132
|
+
finally { control.close(); }
|
|
116
133
|
if (!lock) return rejection('target_busy', serial);
|
|
117
134
|
try {
|
|
118
135
|
const state = store.read(serial);
|
|
@@ -132,7 +149,11 @@ function createDeviceMutationLease({ directory } = {}) {
|
|
|
132
149
|
} finally { lock.close(); }
|
|
133
150
|
}
|
|
134
151
|
async function drainAcknowledgements(serial, acknowledge) {
|
|
135
|
-
const
|
|
152
|
+
const control = store.controlLock(serial);
|
|
153
|
+
if (!control) return rejection('target_busy', serial);
|
|
154
|
+
let lock;
|
|
155
|
+
try { lock = held.has(serial) ? null : store.lock(serial); }
|
|
156
|
+
finally { control.close(); }
|
|
136
157
|
if (!lock) return rejection('target_busy', serial);
|
|
137
158
|
try {
|
|
138
159
|
const state = store.read(serial), errors = [], retired = [];
|
|
@@ -88,7 +88,8 @@ function createOwnershipStore(directory = defaultDirectory()) {
|
|
|
88
88
|
throw new CommandError('device_ownership_unavailable', 'Cannot commit device ownership; no new action is allowed.', { details: { serial, cause: error.message } });
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
const controlLock = serial => createOwnershipStore(path.join(directory, 'reset-controls')).lock(serial);
|
|
92
|
+
return { directory, read, lock, write, paths, controlLock };
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
module.exports = { createOwnershipStore, defaultDirectory, schemaVersion };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
const { CommandError } = require('../command-errors');
|
|
5
|
+
|
|
6
|
+
function processInfo(pid) {
|
|
7
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return null;
|
|
8
|
+
try {
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
const raw = execFileSync('powershell.exe', ['-NoProfile', '-Command',
|
|
11
|
+
`$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; if($p){@{start=$p.CreationDate.ToUniversalTime().ToString('O');command=$p.CommandLine}|ConvertTo-Json -Compress}`],
|
|
12
|
+
{ encoding: 'utf8', timeout: 3000, windowsHide: true });
|
|
13
|
+
return raw.trim() ? JSON.parse(raw) : null;
|
|
14
|
+
}
|
|
15
|
+
const raw = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'pgid=', '-o', 'args='],
|
|
16
|
+
{ encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
const match = raw.trim().match(/^(\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(\d+)\s+([\s\S]+)$/);
|
|
18
|
+
return match ? { start: match[1], group: Number(match[2]), command: match[3] } : null;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.status === 1) return null;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let ownStart;
|
|
26
|
+
function processStart() { return ownStart ||= processInfo(process.pid)?.start; }
|
|
27
|
+
|
|
28
|
+
async function stopHosts(owner, endpoint) {
|
|
29
|
+
const stopped = [];
|
|
30
|
+
for (const item of [owner, endpoint]) {
|
|
31
|
+
if (!item || stopped.includes(item.pid)) continue;
|
|
32
|
+
const info = processInfo(item.pid);
|
|
33
|
+
if (!info) continue;
|
|
34
|
+
if (item.pid === process.pid) throw new CommandError('force_stop_requires_control_client',
|
|
35
|
+
'Run device-ownership force-stop through CLI or MCP, outside the execution owner.');
|
|
36
|
+
const identified = item.processStart ? item.processStart === info.start
|
|
37
|
+
: /[\\/]bin[\\/](execution-runtime|ai-app-bridge|mcp-server)\.js(?:\s|$)/.test(info.command);
|
|
38
|
+
// PID reuse means the original Host is gone. It must neither block reset
|
|
39
|
+
// nor cause an unrelated replacement process to be killed.
|
|
40
|
+
if (!identified) continue;
|
|
41
|
+
try {
|
|
42
|
+
if (process.platform === 'win32') execFileSync('taskkill.exe', ['/PID', String(item.pid), '/F', '/T'], { timeout: 5000, windowsHide: true, stdio: 'pipe' });
|
|
43
|
+
else if (info.group === item.pid) process.kill(-item.pid, 'SIGKILL');
|
|
44
|
+
else {
|
|
45
|
+
const inventory = execFileSync('ps', ['-axo', 'pid=', '-o', 'ppid='],
|
|
46
|
+
{ encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
47
|
+
const pairs = inventory.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number));
|
|
48
|
+
const descendants = [item.pid];
|
|
49
|
+
for (let i = 0; i < descendants.length; i++)
|
|
50
|
+
for (const [pid, parent] of pairs) if (parent === descendants[i]) descendants.push(pid);
|
|
51
|
+
for (const pid of descendants.reverse()) {
|
|
52
|
+
try { process.kill(pid, 'SIGKILL'); }
|
|
53
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
stopped.push(item.pid);
|
|
57
|
+
} catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
58
|
+
}
|
|
59
|
+
return stopped;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { processStart, stopHosts };
|
|
@@ -294,6 +294,25 @@ function createUiaRuntimePort({ adb, serial, timeoutMs = 10000, root = protocol.
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
return {
|
|
297
|
+
async forceStop() {
|
|
298
|
+
// Host reset owns the separate administrative gate and has already
|
|
299
|
+
// terminated previous Host writers. This never waits for old callbacks.
|
|
300
|
+
const asset = bundle(), destination = await installAsset(asset);
|
|
301
|
+
let raw;
|
|
302
|
+
try { raw = await shell(`CLASSPATH=${quote(destination)} app_process /system/bin ${mainClass} ${quote(root)} ${asset.manifest.sha256} force-reset`); }
|
|
303
|
+
catch (error) {
|
|
304
|
+
let remote;
|
|
305
|
+
try { remote = JSON.parse(error.stderr?.trim()); } catch { /* non-protocol ADB failure */ }
|
|
306
|
+
if (typeof remote?.error === 'string') throw failure(remote.error, remote.message || 'The phone rejected forced reset.');
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
let result;
|
|
310
|
+
try { result = JSON.parse(raw); }
|
|
311
|
+
catch { throw failure('uia_force_reset_invalid', 'The phone did not return a forced-reset receipt.'); }
|
|
312
|
+
if (result?.ok !== true || result.schemaVersion !== 'aab.uia.reset.v1' || result.stopped !== true)
|
|
313
|
+
throw failure(result?.error || 'uia_force_reset_unconfirmed', 'The UIA executor was not force-reset.');
|
|
314
|
+
return result;
|
|
315
|
+
},
|
|
297
316
|
async withInstrumentation(descriptorFile, start) {
|
|
298
317
|
return withConnectionLock(async () => {
|
|
299
318
|
const automation = require('../executors/automation-owner');
|
package/docs/COMMAND_CONTRACT.md
CHANGED
|
@@ -710,8 +710,8 @@ the local ADB process does not by itself confirm a cancelled install has settled
|
|
|
710
710
|
Use `device-ownership {operation:"status",serial:"DEVICE"}` to inspect ownership,
|
|
711
711
|
or `operation:"reconcile"` to query the recorded action through its original
|
|
712
712
|
package and transport configuration. Reconciliation exclusively owns the device
|
|
713
|
-
while checking;
|
|
714
|
-
|
|
713
|
+
while checking; normal reconciliation does not expire or replay actions, or
|
|
714
|
+
substitute their target. Android Native, Flutter, H5 and managed shell actions can
|
|
715
715
|
recover from an identity-matching `aab.native-execution/v1`,
|
|
716
716
|
`aab.flutter-execution/v1`, `aab.h5-execution/v1` or
|
|
717
717
|
`aab.android-shell-execution/v1` terminal receipt. UIA node actions use
|
|
@@ -725,6 +725,30 @@ Other transports without a matching completion protocol remain unresolved.
|
|
|
725
725
|
Installation uses the original phone job and PackageInstaller session contract
|
|
726
726
|
described below.
|
|
727
727
|
|
|
728
|
+
**Stuck device / agent handoff:** use `device-ownership
|
|
729
|
+
{operation:"force-stop",serial:"DEVICE"}`. CLI: `ai-app-bridge device-ownership
|
|
730
|
+
--operation force-stop --serial DEVICE --extract null`. This emergency reset runs
|
|
731
|
+
in the CLI/MCP client before connecting to the execution Runtime, including when
|
|
732
|
+
the Runtime hangs, its queue is blocked, or its version differs. It kills the
|
|
733
|
+
recorded owning Host and the shared Host runtime, including Script/Intent child
|
|
734
|
+
processes and queued tasks. Other tasks in those Host processes also stop.
|
|
735
|
+
On the selected phone it kills UIA and managed shell worker groups, archives UIA
|
|
736
|
+
sessions while retaining the process-lock inode, and stops recorded SDK apps.
|
|
737
|
+
A retained instrumentation/Flutter test owner is stopped and its UiAutomation
|
|
738
|
+
claim archived, so a replacement agent can observe the phone again.
|
|
739
|
+
A new observation starts a fresh UIA epoch. No terminal callback is required.
|
|
740
|
+
|
|
741
|
+
The ownership journal becomes idle. `disposition:force_stopped,outcome:unknown`
|
|
742
|
+
preserves abandoned action identities and the original archived journal; this
|
|
743
|
+
does not roll back dispatched effects or fabricate the original outcome.
|
|
744
|
+
Even a corrupt Host task journal is archived as bytes and reset explicitly.
|
|
745
|
+
Device admission and force-stop share an independent administrative gate.
|
|
746
|
+
If the phone cannot reset, local tasks and occupancy still stop; the reply has
|
|
747
|
+
`ownershipReleased:true,remoteResetPending:true`. Reconnect and repeat force-stop.
|
|
748
|
+
A durable reset-required marker prevents new dispatch until phone reset succeeds,
|
|
749
|
+
including after a reset-client crash. Occupancy failures include a short
|
|
750
|
+
`recoveryHint` with exact force-stop arguments. Resetting an idle device is valid.
|
|
751
|
+
|
|
728
752
|
The ownership journal is `aab.device-ownership/v2`, under the **same physical
|
|
729
753
|
lock directory** above. Reading v1 preserves every unresolved identity and
|
|
730
754
|
reservation; the next commit upgrades its format. v1 had no pending-ack queue,
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -5,7 +5,7 @@ The same npm package contains CLI and MCP; running only MCP is supported.
|
|
|
5
5
|
FactStore is an embedded library bundled in that package. There is no separate
|
|
6
6
|
database, FactStore service or CLI process to install first.
|
|
7
7
|
|
|
8
|
-
This source targets the coordinated 0.4.
|
|
8
|
+
This source targets the coordinated 0.4.2 release.
|
|
9
9
|
Registry publication is a separate step; before
|
|
10
10
|
publication, use the reviewed local tarball instead of expecting this registry
|
|
11
11
|
version to resolve.
|
|
@@ -15,7 +15,7 @@ version to resolve.
|
|
|
15
15
|
"mcpServers": {
|
|
16
16
|
"ai-app-bridge": {
|
|
17
17
|
"command": "npx",
|
|
18
|
-
"args": ["--yes", "--package", "@mobileaidev/ai-app-bridge@0.4.
|
|
18
|
+
"args": ["--yes", "--package", "@mobileaidev/ai-app-bridge@0.4.2", "ai-app-bridge-mcp"]
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Optional UI executors (0.4.
|
|
1
|
+
# Optional UI executors (0.4.2)
|
|
2
2
|
|
|
3
3
|
Bridge keeps its existing SDK paths and exposes optional executors through `capabilities`, `run`, and JavaScript/Python Script. Select an executor explicitly. No command silently changes a touch into a setter, switches framework after failure, or repeats an uncertain action.
|
|
4
4
|
|
|
@@ -52,9 +52,9 @@ android {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
dependencies {
|
|
55
|
-
androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.4.
|
|
55
|
+
androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-instrumentation:0.4.2")
|
|
56
56
|
// Optional H5 adapter:
|
|
57
|
-
androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.4.
|
|
57
|
+
androidTestImplementation("com.github.mobileAiDev.ai-app-bridge:ai-app-bridge-test-espresso-web:0.4.2")
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -109,7 +109,7 @@ Espresso text actions have different semantics. `replaceText` is the framework's
|
|
|
109
109
|
|
|
110
110
|
## Flutter
|
|
111
111
|
|
|
112
|
-
Add `ai_app_bridge_test: 0.4.
|
|
112
|
+
Add `ai_app_bridge_test: 0.4.2` to the application's `dev_dependencies`. The helper takes `flutter_test` and `integration_test` from the **same Flutter SDK** as the application. It is a Dart test helper, not an additional Android plugin with its own AGP/Kotlin versions.
|
|
113
113
|
|
|
114
114
|
The helper declares Flutter **>=3.41.0** and Dart **>=3.11.0 <4.0.0**. The 0.3.8 automatic preparation was exercised with LocalSend on Flutter **3.41.9 / Android API 36**, preserving all 214 production dependency versions. Earlier executor validation covered Flutter 3.41.9 / API 25 and 3.44.8 / API 36. These are specific verified combinations; other SDK versions still need validation with the application's plugin graph.
|
|
115
115
|
|
package/docs/RELEASE.md
CHANGED
|
@@ -1,71 +1,37 @@
|
|
|
1
|
-
# 0.4.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
0.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
设备产物时记录实际版本/包/序列号,不能用旧安装冒充新包验收。
|
|
39
|
-
|
|
40
|
-
## 发布前门禁
|
|
41
|
-
|
|
42
|
-
本次必须通过新增缺陷回归、Host 完整功能/性能组、真实 iPhone 恢复验证及
|
|
43
|
-
最终 tarball 安装检查。SDK 同步版本后重新验证构建与公开依赖;未变更的
|
|
44
|
-
原生 addon 复用 0.4.0 四平台验收,并核实归档字节相同。
|
|
45
|
-
|
|
46
|
-
1. 固定审核提交,核对工作包和 A01–A16,保留失败与未验证项。完整 npm
|
|
47
|
-
功能组与安静环境串行性能组通过,范例由文档读取实际执行。
|
|
48
|
-
2. 对 [Host 支持矩阵](INSTALLATION.md) 的四个 artifact 校验 checksum 和
|
|
49
|
-
实际加载,运行 native tests 与全新 tarball 安装。正常安装和纯 MCP
|
|
50
|
-
JS/regex 路径禁止调用本地编译器/Python;Python 回归使用单独环境。
|
|
51
|
-
3. 核对 npm pack 清单真实包含 addon、加载器、source read/worker 和文档,
|
|
52
|
-
codeFingerprint 哈希实际选中的二进制。固定版本 npx 首次/重复启动、
|
|
53
|
-
MCP 断连后 Runtime 存续、明确 stop/restart 与持久化恢复均有证据。
|
|
54
|
-
4. 核实实际 CLI 路径、MCP 启动版本/指纹、Runtime code/config/Node 身份,
|
|
55
|
-
并检查受影响消费脚本的 extract/公共响应迁移。安装成功不替代入口更新。
|
|
56
|
-
5. Android/Swift/Flutter/Web 的发行清单和版本一致。需要时运行相应构建,
|
|
57
|
-
真实设备证据和离线/受控 ADB 测试分开记录。三类实际任务对照保留来源、
|
|
58
|
-
时间、目标和原始记录,不能将缺设备写成不适用。
|
|
59
|
-
|
|
60
|
-
## 对外发布顺序
|
|
61
|
-
|
|
62
|
-
本次推送已验收提交与统一 `0.4.1` tag,核实 JitPack 公开坐标成功解析,
|
|
63
|
-
再发布依赖它们的 Flutter SDK/helper。Web npm 与主 CLI/MCP npm 分别发布
|
|
64
|
-
已经验证的 tarball,核实 `latest`/`next` 均为 0.4.1、公开下载 checksum
|
|
65
|
-
与候选包一致,并创建对应 GitHub Release。保留旧 `0.4.0` tag 不动。远端流水线
|
|
66
|
-
成功与设备业务验收分别列明。本地构建或 MavenLocal/path 替换不能证明公开
|
|
67
|
-
坐标可安装。
|
|
68
|
-
|
|
69
|
-
客户端升级时退出旧 MCP 再重新连接。若是旧客户端碰到新 Runtime,先升
|
|
70
|
-
客户端;只有明确 Runtime 是待升级一侧时,在其任务结束后显式 stop。
|
|
71
|
-
同版本不同指纹只能说明构建或 Node 环境不一致,不能凭 hash 判断新旧。
|
|
1
|
+
# 0.4.2 统一补丁发行检查
|
|
2
|
+
|
|
3
|
+
本次补齐 Android 卡死任务的强制停止与占用重置入口,并统一 CLI/MCP、
|
|
4
|
+
Android SDK/plugin/执行器、iOS Swift、Flutter SDK/helper、Web SDK 为 0.4.2。
|
|
5
|
+
嵌入式 native store 保持 0.2.0。发布结果单独记录,源码版本不代表 registry 已发布。
|
|
6
|
+
|
|
7
|
+
## 修复
|
|
8
|
+
|
|
9
|
+
- `device-ownership force-stop` 在 CLI/MCP 客户端独立执行,绕过挂住的
|
|
10
|
+
Runtime、执行队列和版本检查;终止旧 Host 及共享 Host 的子进程和任务。
|
|
11
|
+
- 手机 UIA 增加强制 reset:停止旧进程,在原 owner.lock 下归档未知/损坏记录,
|
|
12
|
+
为新 epoch 清出可用空间。普通 cancel/reconcile 的原回执合同保持不变。
|
|
13
|
+
- 原任务结果保留为 `force_stopped/unknown`;占用错误附简短 recoveryHint。
|
|
14
|
+
手机断开时本地占用仍释放,保留 reset-required 标记,重连后重复重置。
|
|
15
|
+
- SDK/Web 的执行行为不变,仅版本和依赖同步。新 UIA bundle 随 CLI 发布。
|
|
16
|
+
|
|
17
|
+
## 放行检查
|
|
18
|
+
|
|
19
|
+
1. 新增未知回执、活进程、并发占用、离线重试及提示合同回归;Host 完整功能组
|
|
20
|
+
和串行性能组通过。UIA JVM 测试通过,DEX/manifest 同步到 CLI。
|
|
21
|
+
2. OnePlus b46093e6 的旧 admitted 操作真实解除;新会话再次观察和操作成功,
|
|
22
|
+
占用返回 idle。保存强制停止事实和新操作原始回执。
|
|
23
|
+
3. Android build/lint/test、Swift iPhoneOS 构建、Flutter 分析和测试、Web 测试。
|
|
24
|
+
版本同步不代替业务 App 验收;测试 helper 无 test 目录不记为测试通过。
|
|
25
|
+
4. 最终 npm tarball 在仓库外安装;CLI/MCP 发现、提取、Script 和强制停止
|
|
26
|
+
合同通过。未变更的四平台 native addon 校验归档字节。
|
|
27
|
+
5. 两个 Flutter 包发布 dry-run;源码和公开依赖版本一致,历史 changelog 保留。
|
|
28
|
+
|
|
29
|
+
## 发布与客户端升级
|
|
30
|
+
|
|
31
|
+
推送验收提交和新 0.4.2 tag;核实 JitPack SDK/plugin/执行器坐标后发布
|
|
32
|
+
Flutter SDK/helper。发布受检 CLI 与 Web npm tarball,并将 latest/next
|
|
33
|
+
统一为 0.4.2;核实公开下载 checksum,再创建 GitHub Release。
|
|
34
|
+
旧 tag 保留。需要凭据或人工登录时明确记录阻塞,不把未发布渠道写成已发布。
|
|
35
|
+
|
|
36
|
+
全局 CLI 升级后核实路径/版本/运行 Runtime。已有 MCP 连接需重连加载新的
|
|
37
|
+
客户端控制入口。遇到旧占用可使用新 CLI 的 force-stop,不必等待旧任务。
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": "aab.uia.bundle.v1",
|
|
3
3
|
"mainClass": "io.github.mobileaidev.aiappbridge.uia.UiaRuntime",
|
|
4
4
|
"artifact": "ai-app-bridge-uia.jar",
|
|
5
|
-
"sha256": "
|
|
5
|
+
"sha256": "fa7c38c912a39a14b687ca53b16e849ea6f3c15cd8d0a1e29aa48fb1feb26637",
|
|
6
6
|
"minApi": 25,
|
|
7
7
|
"compileApi": 35,
|
|
8
8
|
"buildTools": "36.0.0",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaHttp.java": "43447130a1829354e50c4df4dac2a66afea3ef7d9f8fa5732d773f526fc6e72a",
|
|
18
18
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaJournal.java": "718a367429c68b16f56418e36d6f73f9147508b85e424c15a8d81c061a82a892",
|
|
19
19
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaNodes.java": "afd426091f98ff9114ac9947c1c124a1167f566cc875e2063f171c9e42a298cf",
|
|
20
|
-
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/
|
|
20
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaReset.java": "5dfd537ee83a4ae8e6bd87aab5606edf21c132271aea2d46609606e8baa488e5",
|
|
21
|
+
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/UiaRuntime.java": "b8d1b298f29a3abcf80d0b5aba8a225607e08612f06b4deda56edcbb547bcac9",
|
|
21
22
|
"android/ai-app-bridge-uia/src/main/java/io/github/mobileaidev/aiappbridge/uia/Wire.java": "d54e79c5e064abf5c7b36d2ab43205b7d47cca001ec37bf9b82ab01e33cf816c"
|
|
22
23
|
}
|
|
23
24
|
}
|
|
@@ -21,6 +21,8 @@ CLI 与 MCP 共用独立执行 Runtime、命令合同和 operationId。CLI 响
|
|
|
21
21
|
|
|
22
22
|
旧 MCP 实例可能与已安装 CLI 不同。缺少 Intent/Script 或参数不匹配时,核对实际入口版本,选用支持当前合同的入口;不要套用旧 batch、工具别名或外层参数。
|
|
23
23
|
|
|
24
|
+
**卡死 / 换 agent:** Android 遇到 `target_busy`、`device_ownership_unresolved` 或旧动作一直没有终态时,按返回的 `recoveryHint` 调 `device-ownership {operation:"force-stop",serial:"原设备"}`;CLI 为 `ai-app-bridge device-ownership --operation force-stop --serial 原设备 --extract null`。这是独立重置入口:无需旧回执,强制结束旧 Host 及共享 Runtime 的任务,重置手机执行器并解除占用。共享这些 Host 的其他任务也会结束;已派发效果记为未知。读取 `ownershipReleased/remoteResetPending`;手机已重置后重新观察并开始新操作,手机断开时重连后再执行一次。
|
|
25
|
+
|
|
24
26
|
## 响应与提取
|
|
25
27
|
|
|
26
28
|
先看 `execution` 的原执行事实及 `failureStage`,再消费 `value`;Script 内部 `ctx.call` 仍按 `ok/result` 处理,不加 extract。`control` 保留续跑字段和采集覆盖;当前 Script 问题从 `control.pendingQuestion` 读取,即使 events 被游标过滤也可回答。
|