@twinsearth/w2m-dsh-plugin 0.5.3 → 0.5.4

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.
@@ -85,6 +85,18 @@ import { parseServer } from '../agent/stun.mjs';
85
85
  // the dedupe key exists to prevent.
86
86
  import { SHELL_ID_DIRECT, computeCommandHash, computeDedupeKey } from '../relay/state.mjs';
87
87
  import { createAutoUpdater, findProfileDir, resolveCurrentVersion } from './auto-update.mjs';
88
+ // v0.6.1: the session's chosen target machine, and the two browser routes the composer control
89
+ // reads it through. Imported rather than inlined because the control and `w2m_run` must agree on
90
+ // what "selected" means, and a second copy of that rule is a second chance to disagree.
91
+ import {
92
+ createLocalIdentity,
93
+ defaultSelectionStore,
94
+ dispatchSelectionFields,
95
+ readFleet,
96
+ registerTargetRoutes,
97
+ sessionKeyFor,
98
+ TARGET_ROUTE_PATH,
99
+ } from './fleet-target.mjs';
88
100
  import { loadSharedConfig, describeSharedConfig } from './shared-config.mjs';
89
101
  import { DEFAULT_DAILY_TIMES, DEFAULT_TIME_ZONE } from './schedule.mjs';
90
102
 
@@ -119,7 +131,7 @@ const MAX_STAGES = 16;
119
131
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
120
132
  * overrides it at runtime for tests and packagers.
121
133
  */
122
- const PLUGIN_VERSION = '0.5.3';
134
+ const PLUGIN_VERSION = '0.5.4';
123
135
 
124
136
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
125
137
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -2845,6 +2857,32 @@ export async function apply(ctx, config = {}) {
2845
2857
  // still has to dispatch, and `w2m_status` is where the failure is reported.
2846
2858
  const p2p = createP2PRuntime(ctx, cfg, config.p2pBlock);
2847
2859
 
2860
+ /**
2861
+ * This machine's fleet identity, read once per successful resolution.
2862
+ *
2863
+ * The memoization rule lives in {@link createLocalIdentity} so the route half and the tool half
2864
+ * cannot disagree about when a missing identity becomes a stale one.
2865
+ */
2866
+ const localIdentity = createLocalIdentity(async () => {
2867
+ const device = await readDevice({ stateDir: cfg.stateDir });
2868
+ return { machine_id: device.machine_id, machine_name: device.machine_name, machineName: cfg.machineName };
2869
+ });
2870
+
2871
+ // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
2872
+ // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence —
2873
+ // which is the honest place for it, since a host that has no Connection at all is simply not a web
2874
+ // composition and does not need a warning on every headless run.
2875
+ const targetRoutes = registerTargetRoutes(ctx, {
2876
+ cfg: () => cfg,
2877
+ store: defaultSelectionStore,
2878
+ localIdentity,
2879
+ request,
2880
+ resolveToken,
2881
+ });
2882
+ if (targetRoutes.unexpected) {
2883
+ ctx.logger?.warn?.(`w2m: the composer target control is inactive — ${targetRoutes.reason}`);
2884
+ }
2885
+
2848
2886
  /** Text output: rendered as JSON, because every one of these tools answers with a structure. */
2849
2887
  const jsonOutput = {
2850
2888
  schema: { type: 'string' },
@@ -2872,48 +2910,61 @@ export async function apply(ctx, config = {}) {
2872
2910
  throwIfAborted(exec?.signal);
2873
2911
  const base = requireRabbit(cfg);
2874
2912
  const token = await resolveToken(cfg);
2875
- const result = await request({
2913
+
2914
+ // The read itself, the liveness rule, and "this machine has no identity yet" all come from
2915
+ // the same helper the composer control uses, so the list a model reads and the list an
2916
+ // operator clicks can never disagree about who is online.
2917
+ const identity = await localIdentity();
2918
+ const fleet = await readFleet({
2876
2919
  rabbitUrl: base,
2877
2920
  token,
2921
+ localMachineId: identity.machine_id,
2878
2922
  signal: exec?.signal,
2879
- pathname: '/v1/devices',
2923
+ request,
2880
2924
  });
2881
- if (!result.ok) {
2882
- throw new W2MError('W2M_RABBIT_REFUSED', `W2M_RABBIT_REFUSED: the Rabbit refused GET /v1/devices with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${result.status === 401 ? 'this machine is not paired, or its device_token is stal - pair the agent again' : 'check the Rabbit log for the reason it gave'}`, {
2883
- hint: result.status === 401
2884
- ? 'this machine is not paired, or its device_token is stale; pair the agent again'
2885
- : 'check the Rabbit log for the reason it gave',
2886
- });
2925
+ if (!fleet.ok) {
2926
+ // The typed error is rebuilt here, from the helper's report, so the code, message and hint a
2927
+ // caller sees are the same three it saw before this read moved into `fleet-target.mjs`: the
2928
+ // remediation travels in `hint`, never in the message.
2929
+ const unauthorized = fleet.status === 401;
2930
+ throw new W2MError(
2931
+ unauthorized ? 'W2M_RABBIT_REFUSED' : 'W2M_UNREACHABLE',
2932
+ fleet.status === 0
2933
+ ? `${fleet.error}`
2934
+ : `${fleet.error}; ${unauthorized ? 'this machine is not paired, or its device_token is stale - pair the agent again' : 'check the Rabbit log for the reason it gave'}`,
2935
+ {
2936
+ hint: fleet.hint ?? (unauthorized
2937
+ ? 'this machine is not paired, or its device_token is stale; pair the agent again'
2938
+ : 'check the Rabbit log for the reason it gave'),
2939
+ },
2940
+ );
2887
2941
  }
2888
2942
 
2889
2943
  // `GET /v1/devices` answers `{protocol_version, rabbit_time, devices:[...]}`. Each entry is a
2890
2944
  // device recor - the Rabbit's own lease bookkeeping is on the task, not her - so a device
2891
- // is described by identity and capabilities, and "online" is the honest default for a device
2892
- // the Rabbit still lists.
2893
- const machines = Array.isArray(result.json?.devices)
2894
- ? result.json.devices
2895
- : (Array.isArray(result.json?.machines) ? result.json.machines : []);
2945
+ // is described by identity and capabilities, and the relay's own `state` is the honest
2946
+ // default for a device it still lists.
2896
2947
  const includeStale = args?.include_stale === true;
2897
- const listed = machines.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
2948
+ const listed = fleet.records.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
2898
2949
 
2899
2950
  return JSON.stringify(
2900
2951
  {
2901
2952
  ok: true,
2902
2953
  protocol_version: PROTOCOL_VERSION,
2903
- rabbit_time: result.json?.rabbit_time ?? null,
2954
+ rabbit_time: fleet.rabbit_time,
2904
2955
  count: listed.length,
2905
2956
  devices: listed.map((machine) => ({
2906
2957
  machine_id: machine?.machine_id ?? null,
2907
2958
  name: machine?.machine_name ?? machine?.name ?? null,
2908
- os: machine?.platform?.os ?? null,
2909
- os_version: machine?.platform?.os_version ?? null,
2910
- arch: machine?.platform?.arch ?? null,
2911
- shell: machine?.platform?.shell ?? null,
2959
+ os: machine?.platform?.os ?? machine?.os ?? null,
2960
+ os_version: machine?.platform?.os_version ?? machine?.os_version ?? null,
2961
+ arch: machine?.platform?.arch ?? machine?.arch ?? null,
2962
+ shell: machine?.platform?.shell ?? machine?.shell ?? null,
2912
2963
  caps: machine?.caps ?? null,
2913
2964
  last_seen: machine?.last_seen ?? machine?.last_seen_at ?? null,
2914
2965
  state: machine?.state ?? 'online',
2915
2966
  })),
2916
- excluded_stale: includeStale ? 0 : machines.length - listed.length,
2967
+ excluded_stale: includeStale ? 0 : fleet.records.length - listed.length,
2917
2968
  note:
2918
2969
  'a listed device has paired and is being tracked; it can still refuse the next command ' +
2919
2970
  'at the capability gate, which w2m_wait reports per machine as `refused`',
@@ -2937,7 +2988,10 @@ export async function apply(ctx, config = {}) {
2937
2988
  'Anchors (base_commit, base_tree) are computed here from this machine\'s working tree so ' +
2938
2989
  'the peers can be checked against them. Under the default p2pMode the offer is also pushed ' +
2939
2990
  'over the direct path, best effort: `direct_pushes` names every attempt, and the relay\'s ' +
2940
- 'own offer remains the delivery when a push fails.',
2991
+ 'own offer remains the delivery when a push fails. ' +
2992
+ 'A machine selected in the composer for this session narrows the dispatch to that machine; ' +
2993
+ 'an explicit `executor_machine_id` always wins over it, and the answer\'s `selection` says ' +
2994
+ 'which of the two routed the task.',
2941
2995
  parameters: {
2942
2996
  command_argv: {
2943
2997
  type: 'array',
@@ -3160,6 +3214,34 @@ export async function apply(ctx, config = {}) {
3160
3214
  );
3161
3215
  }
3162
3216
 
3217
+ // -------------------------------------------------------------------------------------------
3218
+ // v0.6.1: the session's chosen target, when the caller named no machine of its own.
3219
+ // -------------------------------------------------------------------------------------------
3220
+ //
3221
+ // The composer control writes a selection into this process, keyed by session; here is where
3222
+ // it becomes real. Read before the payload literal so the routing facts and the answer agree.
3223
+ //
3224
+ // The precedence rule is the whole contract, and it is one line: **the caller's own fields
3225
+ // always win**. `executor_machine_id` is checked below, and `target_machines` is never a tool
3226
+ // argument — so a caller that named a machine (which, for this tool, means
3227
+ // `executor_machine_id` under broadcast) is untouched, and a caller that named none inherits
3228
+ // the selection. Nothing already published changes meaning: the selection only fills fields
3229
+ // that were absent, and the relay has accepted both of them since v0.3.3/v0.4.1.
3230
+ const sessionKey = sessionKeyFor(exec);
3231
+ const selection = defaultSelectionStore.get(sessionKey);
3232
+ const routedBy = dispatchSelectionFields(selection, mode);
3233
+ // broadcast reads the executor field; everything else reads `target_machines`. The relay
3234
+ // ignores the field a mode does not read, and the two are never both set by a selection.
3235
+ const selectionFields = mode === 'broadcast' && executorMachineId !== null
3236
+ ? { fields: {}, applied_as: null }
3237
+ : routedBy.fields;
3238
+ // Whether the selection actually routed THIS task. Reported in the answer, because it is the
3239
+ // one thing an operator cannot recover afterwards: the payload is not in the transcript, and
3240
+ // the same command run twice can legitimately take two different routes.
3241
+ const selectionAppliedAs = mode === 'broadcast' && executorMachineId !== null
3242
+ ? null
3243
+ : routedBy.applied_as;
3244
+
3163
3245
  // Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
3164
3246
  //
3165
3247
  // **Every** stage is checked, not just the first. Checking only `command_argv` would let a
@@ -3256,6 +3338,10 @@ export async function apply(ctx, config = {}) {
3256
3338
  // Only sent for broadcast; the relay ignores it otherwise, and the validation above already
3257
3339
  // refused the combination, so this cannot silently mean nothing.
3258
3340
  ...(executorMachineId !== null ? { executor_machine_id: executorMachineId } : {}),
3341
+ // v0.6.1: the elected machine, when the caller did not name one and a mode that reads it is
3342
+ // in play. Spread after the caller's own field above so the caller always wins even if a
3343
+ // future edit makes both possible.
3344
+ ...selectionFields,
3259
3345
  // Only for pipeline. The relay ignores `stages` under the other modes and the validation
3260
3346
  // above refuses the combination, so this can never silently mean nothing.
3261
3347
  ...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
@@ -3279,15 +3365,22 @@ export async function apply(ctx, config = {}) {
3279
3365
  // OPERATOR_REQUIRED gets its own guidance: it is the one refusal whose fix is a client-side
3280
3366
  // setting rather than something on the relay.
3281
3367
  const operatorRequired = result.error?.code === 'OPERATOR_REQUIRED' || result.status === 401;
3368
+ // v0.6.1: when a task was elected to one machine, NO_ONLINE_DEVICE is the *machine* being
3369
+ // unreachable, not the fleet being empty -- and the two have different fixes. Saying so is
3370
+ // the difference between "start an agent" and "the machine you picked went away".
3371
+ const selectedName = selection?.machine_name ?? selection?.machine_id ?? null;
3372
+ const selectedMissing = result.error?.code === 'NO_ONLINE_DEVICE' && selectionAppliedAs !== null;
3282
3373
  throw new W2MError(
3283
3374
  'W2M_RABBIT_REFUSED',
3284
- `W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
3375
+ `W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : selectedMissing ? `this session's selected machine (${selectedName}) is not among the machines the relay currently holds, so a task narrowed to it has no target - pick another machine in the composer, or clear the selection` : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
3285
3376
  {
3286
3377
  hint: operatorRequired
3287
3378
  ? 'set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup, or read it from `<state>/operator-token.txt`'
3288
- : result.error?.code === 'NO_ONLINE_DEVICE'
3289
- ? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
3290
- : 'check the Rabbit log for the reason it gave',
3379
+ : selectedMissing
3380
+ ? `the selected target was sent as ${selectionAppliedAs}=${selection?.machine_id ?? ''}; clear the composer selection to dispatch to the whole fleet again`
3381
+ : result.error?.code === 'NO_ONLINE_DEVICE'
3382
+ ? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
3383
+ : 'check the Rabbit log for the reason it gave',
3291
3384
  },
3292
3385
  );
3293
3386
  }
@@ -3370,6 +3463,19 @@ export async function apply(ctx, config = {}) {
3370
3463
  // v0.4.0: what the direct path tried, per machine. `mode` is repeated here rather than left
3371
3464
  // to `w2m_status` so one dispatch's answer is self-contained.
3372
3465
  direct_pushes: directPushes,
3466
+ // v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
3467
+ // which one. Published because a dispatch that ran somewhere other than where the operator
3468
+ // pointed is the one failure this feature could hide, and a caller that cannot see the
3469
+ // routing cannot report it.
3470
+ selection: selection === null
3471
+ ? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
3472
+ : {
3473
+ applied: selectionAppliedAs !== null,
3474
+ field: selectionAppliedAs,
3475
+ machine_id: selection.machine_id,
3476
+ machine_name: selection.machine_name,
3477
+ session: sessionKey,
3478
+ },
3373
3479
  note: anchors.base_tree === null
3374
3480
  ? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
3375
3481
  : 'peers are checked against base_commit + base_tree + command_hash',
@@ -3778,6 +3884,23 @@ export async function apply(ctx, config = {}) {
3778
3884
  fingerprint_algo: 'git-temp-index-tree/v1',
3779
3885
  error: anchors.error,
3780
3886
  },
3887
+ /**
3888
+ * v0.6.1: the composer's target control and the selections it holds.
3889
+ *
3890
+ * Reported here because "the control is not in my composer" has exactly two causes — the
3891
+ * host has no fetch-route registry, or the browser half is not loaded — and this is the
3892
+ * one call that can tell them apart from the host side. The selections themselves are
3893
+ * counts, not contents: which session picked which machine is the operator's business and
3894
+ * nothing the model needs.
3895
+ */
3896
+ target_selection: {
3897
+ route: TARGET_ROUTE_PATH,
3898
+ attached: targetRoutes.mounted,
3899
+ detail: targetRoutes.mounted
3900
+ ? 'the composer control reads and writes its selection through this route'
3901
+ : targetRoutes.reason,
3902
+ sessions_with_a_target: defaultSelectionStore.list().length,
3903
+ },
3781
3904
  notes: statusNotes(relay, rtt, cfg, p2pStatus),
3782
3905
  },
3783
3906
  null,