Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions scripts/cdp-bridge/dist/domain/action-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
// composite. Underpins /run-action, self-repair, and auto-emission —
// they all read/write through this single chokepoint so schema
// invariants stay enforced.
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { parseM7Header, serializeM7Header, } from './reusable-action.js';
import { loadOrInitSidecar, sidecarPathFor, yamlEditedSinceLastSeen, } from './sidecar-io.js';
import { loadOrInitSidecar, markSeen, saveSidecar, sidecarPathFor, yamlEditedSinceLastSeen, } from './sidecar-io.js';
import { atomicWriter } from './atomic-writer.js';
import { assertValidActionId, assertWithinDir } from './path-safety.js';
/**
Expand Down Expand Up @@ -229,6 +229,37 @@ export function saveAction(action) {
export function actionWasEditedExternally(action) {
return yamlEditedSinceLastSeen(action.filePath, action.state);
}
/**
* GH #173 (sub-issue 3): treat the YAML's current on-disk mtime as the
* new baseline. Stats the YAML, persists `markSeen(state, currentMtime)`
* to the sidecar, and returns a new ReusableAction with the refreshed
* lastSeenMtimeMs. Subsequent `actionWasEditedExternally()` checks
* return false until something edits the YAML again.
*
* Use case: `cdp_run_action` is called while the human is actively
* composing the YAML. The human's edit IS the intent; the Phase 129
* guardrail (which exists to protect offline human edits from
* auto-repair clobber) is over-protective in this loop. The orchestrator
* acknowledges the edit before running so any downstream repair
* proceeds without `STALE_TARGET`.
*
* No-op when the YAML mtime equals the sidecar's lastSeenMtimeMs (the
* common case where no external write happened).
*/
export function acknowledgeExternalEdit(action) {
let currentMtimeMs;
try {
currentMtimeMs = statSync(action.filePath).mtimeMs;
}
catch {
return action;
}
if (currentMtimeMs <= action.state.lastSeenMtimeMs)
return action;
const nextState = markSeen(action.state, currentMtimeMs);
saveSidecar(action.filePath, nextState);
return { ...action, state: nextState };
}
/**
* Issue #117: CAS variant of `saveAction`. Compares the on-disk
* sidecar's `lastSeenMtimeMs` to the in-memory `action.state.
Expand Down
3 changes: 2 additions & 1 deletion scripts/cdp-bridge/dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions scripts/cdp-bridge/dist/tools/run-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
// 30s+ device snapshot; cascading retries would be slow and could
// mask underlying screen churn).
import { okResult, failResult } from '../utils.js';
import { loadAction, saveActionWithCAS } from '../domain/action-store.js';
import { acknowledgeExternalEdit, loadAction, saveActionWithCAS } from '../domain/action-store.js';
import { appendRunRecord, } from '../domain/reusable-action.js';
import { parseMaestroFailure, isAutoRepairable, } from '../domain/maestro-error-parser.js';
import { createMaestroRunHandler } from './maestro-run.js';
Expand Down Expand Up @@ -121,10 +121,16 @@ export function createRunActionHandler(deps = {}) {
return failResult(`Invalid actionId "${String(args.actionId).slice(0, 80)}" — must match /^[A-Za-z0-9][A-Za-z0-9_-]*$/ and be <= 64 chars`, 'BAD_FILENAME');
}
const projectRoot = args.projectRoot ?? process.cwd();
const action = loadAction(projectRoot, args.actionId);
if (!action) {
const loaded = loadAction(projectRoot, args.actionId);
if (!loaded) {
return failResult(`cdp_run_action: action "${args.actionId}" not found at ${projectRoot}/.rn-agent/actions/${args.actionId}.yaml`, 'NO_PROJECT_ROOT', { hint: 'Verify with /list-learned-actions, or pass projectRoot if cdp-bridge is invoked outside the project dir.' });
}
// GH #173 (sub-issue 3): default-true forceReload acknowledges any
// human edit to the YAML as the new baseline so downstream auto-repair
// doesn't abort with STALE_TARGET. Opt out with forceReload: false to
// get the strict Phase 129 "respect external edits" behavior back.
const forceReload = args.forceReload !== false;
const action = forceReload ? acknowledgeExternalEdit(loaded) : loaded;
const autoRepairEnabled = args.autoRepair !== false;
const trigger = args.trigger ?? 'agent';
const timeoutMs = args.timeoutMs ?? 120_000;
Expand Down
34 changes: 33 additions & 1 deletion scripts/cdp-bridge/src/domain/action-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// they all read/write through this single chokepoint so schema
// invariants stay enforced.

import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import {
type ReusableAction,
Expand All @@ -16,6 +16,8 @@ import {
} from './reusable-action.js';
import {
loadOrInitSidecar,
markSeen,
saveSidecar,
sidecarPathFor,
yamlEditedSinceLastSeen,
} from './sidecar-io.js';
Expand Down Expand Up @@ -265,6 +267,36 @@ export function actionWasEditedExternally(action: ReusableAction): boolean {
return yamlEditedSinceLastSeen(action.filePath, action.state);
}

/**
* GH #173 (sub-issue 3): treat the YAML's current on-disk mtime as the
* new baseline. Stats the YAML, persists `markSeen(state, currentMtime)`
* to the sidecar, and returns a new ReusableAction with the refreshed
* lastSeenMtimeMs. Subsequent `actionWasEditedExternally()` checks
* return false until something edits the YAML again.
*
* Use case: `cdp_run_action` is called while the human is actively
* composing the YAML. The human's edit IS the intent; the Phase 129
* guardrail (which exists to protect offline human edits from
* auto-repair clobber) is over-protective in this loop. The orchestrator
* acknowledges the edit before running so any downstream repair
* proceeds without `STALE_TARGET`.
*
* No-op when the YAML mtime equals the sidecar's lastSeenMtimeMs (the
* common case where no external write happened).
*/
export function acknowledgeExternalEdit(action: ReusableAction): ReusableAction {
let currentMtimeMs: number;
try {
currentMtimeMs = statSync(action.filePath).mtimeMs;
} catch {
return action;
}
if (currentMtimeMs <= action.state.lastSeenMtimeMs) return action;
const nextState = markSeen(action.state, currentMtimeMs);
saveSidecar(action.filePath, nextState);
return { ...action, state: nextState };
}

/**
* Issue #117: CAS variant of `saveAction`. Compares the on-disk
* sidecar's `lastSeenMtimeMs` to the in-memory `action.state.
Expand Down
3 changes: 2 additions & 1 deletion scripts/cdp-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1098,14 +1098,15 @@ trackedTool(
// stderr classification + cdp_repair_action retry on SELECTOR_NOT_FOUND.
trackedTool(
'cdp_run_action',
'Replay a learned action by id with end-to-end auto-repair. Loads the action from .rn-agent/actions/<actionId>.yaml, runs the Maestro flow, and on a SELECTOR_NOT_FOUND failure automatically invokes cdp_repair_action and retries once. Appends a RunRecord to the sidecar with full auto-repair telemetry (passed/failed/refused/skipped + diff). The repair attempt counts toward cdp_repair_action\'s 24h budget. Pass autoRepair=false to opt out of auto-repair (returns the raw maestro_run failure verbatim). The orchestrated home for the L3 self-healing loop — prefer this over invoking maestro_run + cdp_repair_action manually for any flow you intend to re-run on schedule.',
'Replay a learned action by id with end-to-end auto-repair. Loads the action from .rn-agent/actions/<actionId>.yaml, runs the Maestro flow, and on a SELECTOR_NOT_FOUND failure automatically invokes cdp_repair_action and retries once. Appends a RunRecord to the sidecar with full auto-repair telemetry (passed/failed/refused/skipped + diff). The repair attempt counts toward cdp_repair_action\'s 24h budget. Pass autoRepair=false to opt out of auto-repair (returns the raw maestro_run failure verbatim). forceReload defaults true: any human edit to the YAML since the agent\'s last write is acknowledged as the new baseline so downstream repair does not abort with STALE_TARGET (the right default for active composition). Pass forceReload=false for the strict "respect offline human edits" behavior. The orchestrated home for the L3 self-healing loop — prefer this over invoking maestro_run + cdp_repair_action manually for any flow you intend to re-run on schedule.',
{
actionId: z.string().describe('Action id matching <projectRoot>/.rn-agent/actions/<actionId>.yaml.'),
projectRoot: z.string().optional().describe('Override project root (default: process.cwd()).'),
platform: z.enum(['ios', 'android']).optional().describe('Force a specific platform; otherwise auto-detected from the active device session.'),
autoRepair: z.boolean().optional().describe('Auto-repair on SELECTOR_NOT_FOUND failures. Default true. Pass false to disable (e.g. when investigating a failure manually).'),
timeoutMs: z.number().optional().describe('Maestro execution timeout per attempt (ms). Default 120_000.'),
trigger: z.enum(['agent', 'ci', 'human']).optional().describe('RunRecord trigger annotation. Default "agent". CI calls should pass "ci".'),
forceReload: z.boolean().optional().describe('GH #173: when true (default), acknowledge any human edit to the YAML as the new baseline before running so downstream repair does not abort with STALE_TARGET. Pass false for the strict Phase 129 "respect external edits" behavior (useful for CI replays of fixed baselines).'),
},
createRunActionHandler(),
);
Expand Down
24 changes: 21 additions & 3 deletions scripts/cdp-bridge/src/tools/run-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import { okResult, failResult } from '../utils.js';
import type { ToolResult } from '../utils.js';
import type { ToolErrorCode } from '../types.js';
import { loadAction, saveActionWithCAS } from '../domain/action-store.js';
import { acknowledgeExternalEdit, loadAction, saveActionWithCAS } from '../domain/action-store.js';
import {
type RunRecord,
type AutoRepairOutcome,
Expand Down Expand Up @@ -103,6 +103,18 @@ export interface RunActionArgs {
* so a parameterised flow can be replayed identically after repair.
*/
params?: Record<string, string>;
/**
* GH #173 (sub-issue 3): when true (default), treat the YAML's current
* on-disk state as the new baseline before running. Bumps the sidecar's
* lastSeenMtimeMs so a downstream cdp_repair_action call doesn't abort
* with STALE_TARGET during active human composition.
*
* Pass `false` to opt back into the Phase 129 "respect external edits"
* behavior: any human edit since the agent's last write makes repair
* refuse to run. Use this when you don't want auto-repair to clobber
* offline human edits (e.g. CI replays of fixed baselines).
*/
forceReload?: boolean;
}

interface MaestroEnvelope {
Expand Down Expand Up @@ -191,14 +203,20 @@ export function createRunActionHandler(deps: RunActionDeps = {}) {
}

const projectRoot = args.projectRoot ?? process.cwd();
const action = loadAction(projectRoot, args.actionId);
if (!action) {
const loaded = loadAction(projectRoot, args.actionId);
if (!loaded) {
return failResult(
`cdp_run_action: action "${args.actionId}" not found at ${projectRoot}/.rn-agent/actions/${args.actionId}.yaml`,
'NO_PROJECT_ROOT',
{ hint: 'Verify with /list-learned-actions, or pass projectRoot if cdp-bridge is invoked outside the project dir.' },
);
}
// GH #173 (sub-issue 3): default-true forceReload acknowledges any
// human edit to the YAML as the new baseline so downstream auto-repair
// doesn't abort with STALE_TARGET. Opt out with forceReload: false to
// get the strict Phase 129 "respect external edits" behavior back.
const forceReload = args.forceReload !== false;
const action = forceReload ? acknowledgeExternalEdit(loaded) : loaded;

const autoRepairEnabled = args.autoRepair !== false;
const trigger: 'agent' | 'ci' | 'human' = args.trigger ?? 'agent';
Expand Down
Loading
Loading