ultravisor 1.0.20 → 1.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultravisor",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "Cyclic process execution with ai integration.",
5
5
  "main": "source/Ultravisor.cjs",
6
6
  "bin": {
@@ -31,10 +31,10 @@
31
31
  "orator": "^6.0.4",
32
32
  "orator-authentication": "^1.0.0",
33
33
  "orator-serviceserver-restify": "^2.0.10",
34
- "pict": "^1.0.361",
34
+ "pict": "^1.0.362",
35
35
  "pict-service-commandlineutility": "^1.0.19",
36
36
  "pict-serviceproviderbase": "^1.0.4",
37
- "ultravisor-beacon": "^0.0.10",
37
+ "ultravisor-beacon": "^0.0.11",
38
38
  "ws": "^8.20.0"
39
39
  },
40
40
  "devDependencies": {
@@ -1521,7 +1521,7 @@ class UltravisorBeaconCoordinator extends libPictService
1521
1521
  let tmpContext = tmpManifest.getRun(tmpWorkItem.RunHash);
1522
1522
  if (tmpContext && tmpContext.WaitingTasks[tmpWorkItem.NodeHash])
1523
1523
  {
1524
- tmpContext.WaitingTasks[tmpWorkItem.NodeHash].ResumeEventName = 'Error';
1524
+ tmpContext.WaitingTasks[tmpWorkItem.NodeHash].ResumeEventName = 'error';
1525
1525
  }
1526
1526
  }
1527
1527
 
@@ -955,11 +955,16 @@ class UltravisorExecutionEngine extends libPictService
955
955
  _resolveStateConnections(pNodeHash, pNode, pContext)
956
956
  {
957
957
  // Start with a copy of the node's static settings
958
+ // Nodes may store config in Settings or Data (flow editor uses Data)
958
959
  let tmpSettings = {};
959
960
 
961
+ if (pNode.Data && typeof(pNode.Data) === 'object')
962
+ {
963
+ tmpSettings = JSON.parse(JSON.stringify(pNode.Data));
964
+ }
960
965
  if (pNode.Settings && typeof(pNode.Settings) === 'object')
961
966
  {
962
- tmpSettings = JSON.parse(JSON.stringify(pNode.Settings));
967
+ Object.assign(tmpSettings, JSON.parse(JSON.stringify(pNode.Settings)));
963
968
  }
964
969
 
965
970
  // Find all incoming State connections targeting this node
@@ -1081,7 +1086,8 @@ class UltravisorExecutionEngine extends libPictService
1081
1086
  let tmpType = tmpConn.ConnectionType
1082
1087
  || this._inferConnectionType(tmpConn, pNodeMap, pPortLabelMap);
1083
1088
 
1084
- if (tmpType === 'Event')
1089
+ let tmpTypeLower = (tmpType || '').toLowerCase();
1090
+ if (tmpTypeLower === 'event')
1085
1091
  {
1086
1092
  if (!tmpMap.eventSources[tmpConn.SourceNodeHash])
1087
1093
  {
@@ -1089,7 +1095,7 @@ class UltravisorExecutionEngine extends libPictService
1089
1095
  }
1090
1096
  tmpMap.eventSources[tmpConn.SourceNodeHash].push(tmpConn);
1091
1097
  }
1092
- else if (tmpType === 'State')
1098
+ else if (tmpTypeLower === 'state')
1093
1099
  {
1094
1100
  if (!tmpMap.stateTargets[tmpConn.TargetNodeHash])
1095
1101
  {
@@ -745,5 +745,77 @@ module.exports =
745
745
  Log: [`Histogram: analyzed ${tmpData.length} records, ${Object.keys(tmpFrequency).length} unique values`]
746
746
  });
747
747
  }
748
+ },
749
+
750
+ // ── random-number ──────────────────────────────────────────────────
751
+ {
752
+ Definition: require('./definitions/random-number.json'),
753
+ Execute: function (pTask, pResolvedSettings, pExecutionContext, fCallback)
754
+ {
755
+ let tmpMin = Number(pResolvedSettings.Min);
756
+ let tmpMax = Number(pResolvedSettings.Max);
757
+ let tmpInteger = pResolvedSettings.Integer !== false && pResolvedSettings.Integer !== 'false';
758
+
759
+ if (isNaN(tmpMin)) tmpMin = 0;
760
+ if (isNaN(tmpMax)) tmpMax = 2147483647;
761
+ if (tmpMin >= tmpMax)
762
+ {
763
+ return fCallback(null, {
764
+ EventToFire: 'Error',
765
+ Outputs: { Value: 0 },
766
+ Log: [`RandomNumber: Min (${tmpMin}) must be less than Max (${tmpMax})`]
767
+ });
768
+ }
769
+
770
+ let tmpValue = Math.random() * (tmpMax - tmpMin) + tmpMin;
771
+ if (tmpInteger)
772
+ {
773
+ tmpValue = Math.floor(tmpValue);
774
+ }
775
+
776
+ return fCallback(null, {
777
+ EventToFire: 'Complete',
778
+ Outputs: { Value: tmpValue },
779
+ Log: [`RandomNumber: generated ${tmpValue} in range [${tmpMin}, ${tmpMax})${tmpInteger ? ' (integer)' : ''}`]
780
+ });
781
+ }
782
+ },
783
+
784
+ // ── random-string ──────────────────────────────────────────────────
785
+ {
786
+ Definition: require('./definitions/random-string.json'),
787
+ Execute: function (pTask, pResolvedSettings, pExecutionContext, fCallback)
788
+ {
789
+ let tmpLength = parseInt(pResolvedSettings.Length, 10) || 16;
790
+ let tmpFormat = (pResolvedSettings.Format || 'hex').toLowerCase();
791
+ let tmpValue = '';
792
+
793
+ if (tmpFormat === 'uuid')
794
+ {
795
+ // RFC 4122 v4 UUID
796
+ tmpValue = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,
797
+ (c) =>
798
+ {
799
+ let r = Math.random() * 16 | 0;
800
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
801
+ });
802
+ }
803
+ else
804
+ {
805
+ let tmpChars = tmpFormat === 'alphanumeric'
806
+ ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
807
+ : '0123456789abcdef';
808
+ for (let i = 0; i < tmpLength; i++)
809
+ {
810
+ tmpValue += tmpChars.charAt(Math.floor(Math.random() * tmpChars.length));
811
+ }
812
+ }
813
+
814
+ return fCallback(null, {
815
+ EventToFire: 'Complete',
816
+ Outputs: { Value: tmpValue },
817
+ Log: [`RandomString: generated ${tmpFormat} string (${tmpValue.length} chars)`]
818
+ });
819
+ }
748
820
  }
749
821
  ];
@@ -0,0 +1,24 @@
1
+ {
2
+ "Hash": "random-number",
3
+ "Type": "random-number",
4
+ "Name": "Random Number",
5
+ "Description": "Generates a random number within a specified range. Use Integer=true for whole numbers (e.g., seeds), false for decimals.",
6
+ "Category": "data",
7
+ "Capability": "Data Transform",
8
+ "Action": "GenerateRandomNumber",
9
+ "Tier": "Engine",
10
+ "EventInputs": [{ "Name": "Generate" }],
11
+ "EventOutputs": [
12
+ { "Name": "Complete" },
13
+ { "Name": "Error", "IsError": true }
14
+ ],
15
+ "SettingsInputs": [
16
+ { "Name": "Min", "DataType": "Number", "Required": false, "Description": "Minimum value (inclusive). Default: 0" },
17
+ { "Name": "Max", "DataType": "Number", "Required": false, "Description": "Maximum value (exclusive for float, inclusive for integer). Default: 2147483647" },
18
+ { "Name": "Integer", "DataType": "Boolean", "Required": false, "Description": "If true, generate a whole number. Default: true" }
19
+ ],
20
+ "StateOutputs": [
21
+ { "Name": "Value", "DataType": "Number", "Description": "The generated random number" }
22
+ ],
23
+ "DefaultSettings": { "Min": 0, "Max": 2147483647, "Integer": true }
24
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "Hash": "random-string",
3
+ "Type": "random-string",
4
+ "Name": "Random String",
5
+ "Description": "Generates a random string. Useful for unique filenames, run IDs, and identifiers.",
6
+ "Category": "data",
7
+ "Capability": "Data Transform",
8
+ "Action": "GenerateRandomString",
9
+ "Tier": "Engine",
10
+ "EventInputs": [{ "Name": "Generate" }],
11
+ "EventOutputs": [
12
+ { "Name": "Complete" },
13
+ { "Name": "Error", "IsError": true }
14
+ ],
15
+ "SettingsInputs": [
16
+ { "Name": "Length", "DataType": "Number", "Required": false, "Description": "Length of the generated string. Default: 16" },
17
+ { "Name": "Format", "DataType": "String", "Required": false, "Description": "Format: hex, alphanumeric, uuid. Default: hex" }
18
+ ],
19
+ "StateOutputs": [
20
+ { "Name": "Value", "DataType": "String", "Description": "The generated random string" }
21
+ ],
22
+ "DefaultSettings": { "Length": 16, "Format": "hex" }
23
+ }
@@ -46,12 +46,24 @@ module.exports =
46
46
  });
47
47
  }
48
48
 
49
- // Build work item settings from resolved settings
50
- let tmpSettings = {
51
- Command: pResolvedSettings.Command || '',
52
- Parameters: pResolvedSettings.Parameters || '',
53
- InputData: pResolvedSettings.InputData || ''
54
- };
49
+ // Build work item settings from ALL resolved settings
50
+ // (includes node Data fields + state connection values)
51
+ let tmpSettings = {};
52
+ let tmpResolvedKeys = Object.keys(pResolvedSettings);
53
+ for (let rk = 0; rk < tmpResolvedKeys.length; rk++)
54
+ {
55
+ let tmpKey = tmpResolvedKeys[rk];
56
+ // Skip internal/meta fields that aren't work item settings
57
+ if (tmpKey === 'RemoteCapability' || tmpKey === 'RemoteAction'
58
+ || tmpKey === 'AffinityKey' || tmpKey === 'TimeoutMs'
59
+ || tmpKey === 'PromptMessage' || tmpKey === 'OutputAddress'
60
+ || tmpKey === 'InputSchema') continue;
61
+ tmpSettings[tmpKey] = pResolvedSettings[tmpKey];
62
+ }
63
+ // Ensure legacy fields exist for backward compat
64
+ if (!tmpSettings.Command) tmpSettings.Command = '';
65
+ if (!tmpSettings.Parameters) tmpSettings.Parameters = '';
66
+ if (!tmpSettings.InputData) tmpSettings.InputData = '';
55
67
 
56
68
  // Resolve universal addresses in InputData (JSON string).
57
69
  // Addresses like >retold-remote/File/path become concrete
@@ -122,7 +134,7 @@ module.exports =
122
134
  // Pause execution — the BeaconCoordinator will call resumeOperation when the Beacon reports back
123
135
  return fCallback(null, {
124
136
  WaitingForInput: true,
125
- ResumeEventName: 'Complete',
137
+ ResumeEventName: 'complete',
126
138
  PromptMessage: `Waiting for Beacon worker (${tmpWorkItemInfo.Capability}/${tmpWorkItemInfo.Action})`,
127
139
  OutputAddress: '',
128
140
  Outputs: {},
@@ -106,14 +106,42 @@ module.exports =
106
106
  if (tmpExistingValue !== undefined && tmpExistingValue !== null && tmpExistingValue !== '')
107
107
  {
108
108
  return fCallback(null, {
109
- EventToFire: 'ValueInputComplete',
109
+ EventToFire: 'complete',
110
110
  Outputs: { InputValue: tmpExistingValue },
111
111
  Log: [`Auto-resolved from pre-seeded state: "${tmpOutputAddress}" = "${String(tmpExistingValue).substring(0, 100)}"`]
112
112
  });
113
113
  }
114
114
  }
115
115
 
116
- // No pre-seeded value pause and wait for interactive input
116
+ // If the operation was triggered programmatically (has pre-seeded params),
117
+ // use the DefaultValue so the whole chain runs without pausing
118
+ let tmpIsProgrammatic = pExecutionContext.OperationState
119
+ && Object.keys(pExecutionContext.OperationState).length > 0;
120
+ let tmpDefaultValue = pResolvedSettings.DefaultValue
121
+ || (pResolvedSettings.InputSchema && pResolvedSettings.InputSchema.Default !== undefined
122
+ ? String(pResolvedSettings.InputSchema.Default) : undefined);
123
+ if (tmpIsProgrammatic && tmpDefaultValue !== undefined && tmpDefaultValue !== null && tmpDefaultValue !== '')
124
+ {
125
+ return fCallback(null, {
126
+ EventToFire: 'complete',
127
+ Outputs: { InputValue: tmpDefaultValue },
128
+ Log: [`Auto-resolved from default: "${tmpOutputAddress}" = "${String(tmpDefaultValue).substring(0, 100)}"`]
129
+ });
130
+ }
131
+
132
+ // For optional fields with no default in programmatic mode, pass empty string
133
+ let tmpIsOptional = pResolvedSettings.InputSchema
134
+ && pResolvedSettings.InputSchema.Required === false;
135
+ if (tmpIsProgrammatic && tmpIsOptional)
136
+ {
137
+ return fCallback(null, {
138
+ EventToFire: 'complete',
139
+ Outputs: { InputValue: '' },
140
+ Log: [`Auto-resolved optional field: "${tmpOutputAddress}" = "" (no value provided)`]
141
+ });
142
+ }
143
+
144
+ // No pre-seeded value and no default — pause and wait for interactive input
117
145
  let tmpOptions = pResolvedSettings.Options || '';
118
146
  if (Array.isArray(tmpOptions))
119
147
  {
@@ -29,15 +29,69 @@ class UltravisorTaskTypeValueInput extends libTaskTypeBase
29
29
  let tmpPromptMessage = pResolvedSettings.PromptMessage || 'Please provide a value:';
30
30
  let tmpOutputAddress = pResolvedSettings.OutputAddress || '';
31
31
 
32
- // Signal that this task is waiting for input
32
+ // Auto-resolve: if the output address already has a value in state
33
+ // (e.g., pre-seeded via /Operation/:Hash/Trigger with Parameters),
34
+ // skip the pause and fire immediately. This lets operations work both
35
+ // interactively (flow editor — pauses for input) and programmatically
36
+ // (API trigger / retold-labs operation runner — runs straight through).
37
+ if (tmpOutputAddress && pExecutionContext.StateManager)
38
+ {
39
+ let tmpExistingValue = pExecutionContext.StateManager.resolveAddress(
40
+ tmpOutputAddress, pExecutionContext, pExecutionContext.NodeHash);
41
+ if (tmpExistingValue !== undefined && tmpExistingValue !== null && tmpExistingValue !== '')
42
+ {
43
+ return fCallback(null, {
44
+ EventToFire: 'complete',
45
+ Outputs: { InputValue: tmpExistingValue },
46
+ Log: [`Auto-resolved from pre-seeded state: "${tmpOutputAddress}" = "${String(tmpExistingValue).substring(0, 100)}"`]
47
+ });
48
+ }
49
+ }
50
+
51
+ // If the operation was triggered programmatically (OperationState has pre-seeded values),
52
+ // auto-resolve using the DefaultValue so the whole chain runs without pausing.
53
+ // In interactive mode (empty OperationState), pause for user input.
54
+ let tmpIsProgrammatic = pExecutionContext.OperationState
55
+ && Object.keys(pExecutionContext.OperationState).length > 0;
56
+ let tmpDefaultValue = pResolvedSettings.DefaultValue
57
+ || (pResolvedSettings.InputSchema && pResolvedSettings.InputSchema.Default !== undefined
58
+ ? String(pResolvedSettings.InputSchema.Default) : undefined);
59
+ if (tmpIsProgrammatic && tmpDefaultValue !== undefined && tmpDefaultValue !== null && tmpDefaultValue !== '')
60
+ {
61
+ return fCallback(null, {
62
+ EventToFire: 'complete',
63
+ Outputs: { InputValue: tmpDefaultValue },
64
+ Log: [`Auto-resolved from default: "${tmpOutputAddress}" = "${String(tmpDefaultValue).substring(0, 100)}"`]
65
+ });
66
+ }
67
+
68
+ // For optional fields with no default in programmatic mode, pass empty string
69
+ let tmpIsOptional = pResolvedSettings.InputSchema
70
+ && pResolvedSettings.InputSchema.Required === false;
71
+ if (tmpIsProgrammatic && tmpIsOptional)
72
+ {
73
+ return fCallback(null, {
74
+ EventToFire: 'complete',
75
+ Outputs: { InputValue: '' },
76
+ Log: [`Auto-resolved optional field: "${tmpOutputAddress}" = "" (no value provided)`]
77
+ });
78
+ }
79
+
80
+ // No pre-seeded value and no default — pause and wait for interactive input
33
81
  // The ExecutionEngine will set the run to WaitingForInput status
82
+ let tmpOptions = pResolvedSettings.Options || '';
83
+ if (Array.isArray(tmpOptions))
84
+ {
85
+ tmpOptions = JSON.stringify(tmpOptions);
86
+ }
87
+
34
88
  return fCallback(null, {
35
89
  WaitingForInput: true,
36
90
  PromptMessage: tmpPromptMessage,
37
91
  OutputAddress: tmpOutputAddress,
38
92
  InputType: pResolvedSettings.InputType || 'text',
39
93
  DefaultValue: pResolvedSettings.DefaultValue || '',
40
- Options: pResolvedSettings.Options || '',
94
+ Options: tmpOptions,
41
95
  Outputs: {},
42
96
  Log: [`Waiting for input: "${tmpPromptMessage}" (-> ${tmpOutputAddress})`]
43
97
  });
@@ -2055,6 +2055,16 @@ class UltravisorAPIServer extends libPictService
2055
2055
  return;
2056
2056
  }
2057
2057
 
2058
+ // Diagnostic: log what we RECEIVED from the client before forwarding
2059
+ // to registerBeacon. If the client sent HostID but we're storing null,
2060
+ // this log line pins down exactly where the drop happens (client vs
2061
+ // server). Gated on LogNoisiness>=2 to stay quiet in production.
2062
+ let tmpNoisy = (this.fable && this.fable.LogNoisiness) || 0;
2063
+ if (tmpNoisy >= 2)
2064
+ {
2065
+ this.log.info(`[WSRegister] received from client: Name=${pData.Name} HostID=${pData.HostID || '(none)'} SharedMounts=${JSON.stringify(pData.SharedMounts || [])} Ops=${(pData.Operations || []).length}`);
2066
+ }
2067
+
2058
2068
  // IMPORTANT: this enumeration must include every field the coordinator
2059
2069
  // cares about, including HostID and SharedMounts (used by the shared-fs
2060
2070
  // reachability auto-detect). Forgetting to forward a field here means
@@ -2073,6 +2083,12 @@ class UltravisorAPIServer extends libPictService
2073
2083
  SharedMounts: pData.SharedMounts
2074
2084
  });
2075
2085
 
2086
+ // Diagnostic: confirm what was actually STORED on the beacon record.
2087
+ if (tmpNoisy >= 2)
2088
+ {
2089
+ this.log.info(`[WSRegister] stored beacon ${tmpBeacon.BeaconID}: HostID=${tmpBeacon.HostID || '(null)'} SharedMounts=${JSON.stringify(tmpBeacon.SharedMounts || [])}`);
2090
+ }
2091
+
2076
2092
  pWebSocket._BeaconID = tmpBeacon.BeaconID;
2077
2093
  this._BeaconWebSockets[tmpBeacon.BeaconID] = pWebSocket;
2078
2094
 
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Unit tests for the UltravisorBeaconReachability service, specifically the
3
+ * shared-fs reachability strategy (findSharedFsPeer + resolveStrategy).
4
+ *
5
+ * These tests exist to catch regressions in the logic that decides whether
6
+ * two beacons can skip the HTTP file-transfer layer because they share a
7
+ * filesystem on the same host. The shared-fs optimization is nearly invisible
8
+ * when it works (you just see faster thumbnails) and almost invisible when it
9
+ * silently stops working (you see slower thumbnails and a lot more bandwidth).
10
+ * The only way to catch regressions early is to assert the branch behavior
11
+ * of `findSharedFsPeer` and `resolveStrategy` directly, with mocked coordinator
12
+ * state standing in for a real running Ultravisor.
13
+ *
14
+ * Test layout:
15
+ *
16
+ * findSharedFsPeer
17
+ * - positive match: two beacons, same host, overlapping mount → MATCH
18
+ * - negative: source beacon missing HostID (legacy) → null
19
+ * - negative: source beacon has empty SharedMounts → null
20
+ * - negative: source beacon missing entirely → null
21
+ * - negative: no coordinator service registered → null
22
+ * - negative: only the source beacon exists (no peers) → null
23
+ * - negative: peer on different host → null
24
+ * - negative: peer on same host but no overlapping MountID → null
25
+ * - negative: peer is Offline → null
26
+ * - positive: multiple peers, one matches → MATCH (first match wins)
27
+ *
28
+ * resolveStrategy
29
+ * - local (same beacon) → Strategy: local
30
+ * - shared-fs (same host, overlapping mount) → Strategy: shared-fs + root
31
+ * - direct (different host, reachable matrix) → Strategy: direct
32
+ * - proxy fallback (matrix says unreachable/untested) → Strategy: proxy
33
+ *
34
+ * _findSharedMount
35
+ * - helper-level tests for the pure mount-overlap logic
36
+ */
37
+
38
+ const libPict = require('pict');
39
+ const libUltravisorBeaconReachability = require('../source/services/Ultravisor-Beacon-Reachability.cjs');
40
+
41
+ var Chai = require('chai');
42
+ var Expect = Chai.expect;
43
+
44
+ /**
45
+ * Build a Pict instance with a MOCK BeaconCoordinator and the real
46
+ * Reachability service. The coordinator mock exposes the same two methods
47
+ * (getBeacon, listBeacons) that Reachability consumes, so we can construct
48
+ * arbitrary beacon topologies without spinning up the real coordinator.
49
+ *
50
+ * @param {Object<string, object>} pBeacons - Keyed by BeaconID
51
+ * @returns {{ fable, reachability, coordinator }}
52
+ */
53
+ function _buildTestHarness(pBeacons)
54
+ {
55
+ let tmpFable = new libPict(
56
+ {
57
+ Product: 'Ultravisor-Test-Reachability',
58
+ LogLevel: 5
59
+ });
60
+
61
+ // Mock coordinator — just enough surface area for Reachability to work.
62
+ let tmpMockCoordinator =
63
+ {
64
+ serviceType: 'UltravisorBeaconCoordinator',
65
+ Hash: 'MockCoordinator',
66
+ _Beacons: pBeacons || {},
67
+ getBeacon: function (pBeaconID)
68
+ {
69
+ return this._Beacons[pBeaconID] || null;
70
+ },
71
+ listBeacons: function ()
72
+ {
73
+ return Object.values(this._Beacons);
74
+ },
75
+ setBeacons: function (pNewBeacons)
76
+ {
77
+ this._Beacons = pNewBeacons || {};
78
+ }
79
+ };
80
+
81
+ // Register the mock under the same name Reachability expects.
82
+ if (!tmpFable.servicesMap['UltravisorBeaconCoordinator'])
83
+ {
84
+ tmpFable.servicesMap['UltravisorBeaconCoordinator'] = {};
85
+ }
86
+ tmpFable.servicesMap['UltravisorBeaconCoordinator'][tmpMockCoordinator.Hash] = tmpMockCoordinator;
87
+
88
+ // Instantiate the real Reachability service.
89
+ tmpFable.addAndInstantiateServiceTypeIfNotExists('UltravisorBeaconReachability', libUltravisorBeaconReachability);
90
+ let tmpReachability = Object.values(tmpFable.servicesMap['UltravisorBeaconReachability'])[0];
91
+
92
+ return {
93
+ fable: tmpFable,
94
+ reachability: tmpReachability,
95
+ coordinator: tmpMockCoordinator
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Helper: build a fake beacon record. Anything not specified defaults to the
101
+ * "typical online beacon on host-alpha with one shared mount" shape.
102
+ */
103
+ function _beacon(pOverrides)
104
+ {
105
+ let tmpBase =
106
+ {
107
+ BeaconID: 'bcn-default',
108
+ Name: 'default',
109
+ Status: 'Online',
110
+ HostID: 'host-alpha',
111
+ SharedMounts: [{ MountID: 'mnt-1', Root: '/data' }],
112
+ Contexts: { File: { BasePath: '/data', BaseURL: '/content/' } },
113
+ BindAddresses: [{ IP: '10.0.0.2', Port: 7777, Protocol: 'http' }]
114
+ };
115
+ return Object.assign(tmpBase, pOverrides || {});
116
+ }
117
+
118
+ suite
119
+ (
120
+ 'Ultravisor Beacon Reachability',
121
+ function ()
122
+ {
123
+ // ================================================================
124
+ // findSharedFsPeer
125
+ // ================================================================
126
+ suite
127
+ (
128
+ 'findSharedFsPeer',
129
+ function ()
130
+ {
131
+ test
132
+ (
133
+ 'returns null when the coordinator service is not registered',
134
+ function ()
135
+ {
136
+ let tmpFable = new libPict({ Product: 'Ultravisor-Test-Reachability', LogLevel: 5 });
137
+ // Note: NO coordinator registered — simulate a broken test harness
138
+ tmpFable.addAndInstantiateServiceTypeIfNotExists('UltravisorBeaconReachability', libUltravisorBeaconReachability);
139
+ let tmpReachability = Object.values(tmpFable.servicesMap['UltravisorBeaconReachability'])[0];
140
+
141
+ Expect(tmpReachability.findSharedFsPeer('bcn-whatever')).to.equal(null);
142
+ }
143
+ );
144
+
145
+ test
146
+ (
147
+ 'returns null when the source beacon is not in the coordinator registry',
148
+ function ()
149
+ {
150
+ let tmpHarness = _buildTestHarness({});
151
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-ghost')).to.equal(null);
152
+ }
153
+ );
154
+
155
+ test
156
+ (
157
+ 'returns null when the source beacon has no HostID (legacy beacon)',
158
+ function ()
159
+ {
160
+ let tmpHarness = _buildTestHarness(
161
+ {
162
+ 'bcn-legacy': _beacon({ BeaconID: 'bcn-legacy', HostID: null }),
163
+ 'bcn-peer': _beacon({ BeaconID: 'bcn-peer', HostID: 'host-alpha' })
164
+ });
165
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-legacy')).to.equal(null);
166
+ }
167
+ );
168
+
169
+ test
170
+ (
171
+ 'returns null when the source beacon has an empty SharedMounts array',
172
+ function ()
173
+ {
174
+ let tmpHarness = _buildTestHarness(
175
+ {
176
+ 'bcn-empty': _beacon({ BeaconID: 'bcn-empty', SharedMounts: [] }),
177
+ 'bcn-peer': _beacon({ BeaconID: 'bcn-peer' })
178
+ });
179
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-empty')).to.equal(null);
180
+ }
181
+ );
182
+
183
+ test
184
+ (
185
+ 'returns null when only the source beacon is registered (no peers at all)',
186
+ function ()
187
+ {
188
+ let tmpHarness = _buildTestHarness(
189
+ {
190
+ 'bcn-lonely': _beacon({ BeaconID: 'bcn-lonely' })
191
+ });
192
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-lonely')).to.equal(null);
193
+ }
194
+ );
195
+
196
+ test
197
+ (
198
+ 'returns null when the peer is on a different host',
199
+ function ()
200
+ {
201
+ let tmpHarness = _buildTestHarness(
202
+ {
203
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source', HostID: 'host-alpha' }),
204
+ 'bcn-remote': _beacon({ BeaconID: 'bcn-remote', HostID: 'host-beta' })
205
+ });
206
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-source')).to.equal(null);
207
+ }
208
+ );
209
+
210
+ test
211
+ (
212
+ 'returns null when the peer shares a host but has no overlapping MountID',
213
+ function ()
214
+ {
215
+ let tmpHarness = _buildTestHarness(
216
+ {
217
+ 'bcn-source': _beacon(
218
+ {
219
+ BeaconID: 'bcn-source',
220
+ SharedMounts: [{ MountID: 'mnt-alpha', Root: '/data-alpha' }]
221
+ }),
222
+ 'bcn-peer': _beacon(
223
+ {
224
+ BeaconID: 'bcn-peer',
225
+ SharedMounts: [{ MountID: 'mnt-beta', Root: '/data-beta' }]
226
+ })
227
+ });
228
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-source')).to.equal(null);
229
+ }
230
+ );
231
+
232
+ test
233
+ (
234
+ 'returns null when the only matching peer is Offline',
235
+ function ()
236
+ {
237
+ let tmpHarness = _buildTestHarness(
238
+ {
239
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
240
+ 'bcn-dead': _beacon({ BeaconID: 'bcn-dead', Status: 'Offline' })
241
+ });
242
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-source')).to.equal(null);
243
+ }
244
+ );
245
+
246
+ test
247
+ (
248
+ 'returns a MATCH when a peer shares host and MountID (happy path)',
249
+ function ()
250
+ {
251
+ let tmpHarness = _buildTestHarness(
252
+ {
253
+ 'bcn-retold-remote': _beacon({ BeaconID: 'bcn-retold-remote' }),
254
+ 'bcn-orator-conversion': _beacon({ BeaconID: 'bcn-orator-conversion' })
255
+ });
256
+ let tmpResult = tmpHarness.reachability.findSharedFsPeer('bcn-retold-remote');
257
+ Expect(tmpResult).to.not.equal(null);
258
+ Expect(tmpResult.Peer.BeaconID).to.equal('bcn-orator-conversion');
259
+ Expect(tmpResult.Mount.MountID).to.equal('mnt-1');
260
+ Expect(tmpResult.Mount.Root).to.equal('/data');
261
+ }
262
+ );
263
+
264
+ test
265
+ (
266
+ 'returns the first MATCH when multiple peers share host+mount',
267
+ function ()
268
+ {
269
+ let tmpHarness = _buildTestHarness(
270
+ {
271
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
272
+ 'bcn-peer-1': _beacon({ BeaconID: 'bcn-peer-1' }),
273
+ 'bcn-peer-2': _beacon({ BeaconID: 'bcn-peer-2' })
274
+ });
275
+ let tmpResult = tmpHarness.reachability.findSharedFsPeer('bcn-source');
276
+ Expect(tmpResult).to.not.equal(null);
277
+ // Whichever peer is first in listBeacons iteration order wins — we
278
+ // don't care which; we just care that SOMETHING matched.
279
+ Expect(['bcn-peer-1', 'bcn-peer-2']).to.include(tmpResult.Peer.BeaconID);
280
+ }
281
+ );
282
+
283
+ test
284
+ (
285
+ 'does not match the source beacon against itself',
286
+ function ()
287
+ {
288
+ let tmpHarness = _buildTestHarness(
289
+ {
290
+ 'bcn-solo': _beacon({ BeaconID: 'bcn-solo' })
291
+ });
292
+ // With only the source beacon in the registry, no match.
293
+ Expect(tmpHarness.reachability.findSharedFsPeer('bcn-solo')).to.equal(null);
294
+ }
295
+ );
296
+
297
+ test
298
+ (
299
+ 'finds a peer when source has multiple mounts and at least one overlaps',
300
+ function ()
301
+ {
302
+ let tmpHarness = _buildTestHarness(
303
+ {
304
+ 'bcn-source': _beacon(
305
+ {
306
+ BeaconID: 'bcn-source',
307
+ SharedMounts: [
308
+ { MountID: 'mnt-content', Root: '/media' },
309
+ { MountID: 'mnt-cache', Root: '/cache' },
310
+ { MountID: 'mnt-config', Root: '/config' }
311
+ ]
312
+ }),
313
+ 'bcn-peer': _beacon(
314
+ {
315
+ BeaconID: 'bcn-peer',
316
+ SharedMounts: [
317
+ { MountID: 'mnt-cache', Root: '/cache' }
318
+ ]
319
+ })
320
+ });
321
+ let tmpResult = tmpHarness.reachability.findSharedFsPeer('bcn-source');
322
+ Expect(tmpResult).to.not.equal(null);
323
+ Expect(tmpResult.Mount.MountID).to.equal('mnt-cache');
324
+ Expect(tmpResult.Mount.Root).to.equal('/cache');
325
+ }
326
+ );
327
+ }
328
+ );
329
+
330
+ // ================================================================
331
+ // resolveStrategy
332
+ // ================================================================
333
+ suite
334
+ (
335
+ 'resolveStrategy',
336
+ function ()
337
+ {
338
+ test
339
+ (
340
+ 'returns Strategy=local when source and requesting beacon are the same',
341
+ function ()
342
+ {
343
+ let tmpHarness = _buildTestHarness(
344
+ {
345
+ 'bcn-only': _beacon({ BeaconID: 'bcn-only' })
346
+ });
347
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-only', 'bcn-only');
348
+ Expect(tmpResult.Strategy).to.equal('local');
349
+ }
350
+ );
351
+
352
+ test
353
+ (
354
+ 'returns Strategy=shared-fs when beacons share host+mount',
355
+ function ()
356
+ {
357
+ let tmpHarness = _buildTestHarness(
358
+ {
359
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
360
+ 'bcn-consumer': _beacon({ BeaconID: 'bcn-consumer' })
361
+ });
362
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-consumer');
363
+ Expect(tmpResult.Strategy).to.equal('shared-fs');
364
+ Expect(tmpResult.SharedMountRoot).to.equal('/data');
365
+ }
366
+ );
367
+
368
+ test
369
+ (
370
+ 'returns Strategy=proxy when beacons are on different hosts and reachability is untested',
371
+ function ()
372
+ {
373
+ let tmpHarness = _buildTestHarness(
374
+ {
375
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source', HostID: 'host-alpha' }),
376
+ 'bcn-remote': _beacon({ BeaconID: 'bcn-remote', HostID: 'host-beta' })
377
+ });
378
+ // Untested matrix → falls through to proxy
379
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-remote');
380
+ Expect(tmpResult.Strategy).to.equal('proxy');
381
+ }
382
+ );
383
+
384
+ test
385
+ (
386
+ 'returns Strategy=direct when the matrix says the pair is reachable',
387
+ function ()
388
+ {
389
+ let tmpHarness = _buildTestHarness(
390
+ {
391
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source', HostID: 'host-alpha' }),
392
+ 'bcn-remote': _beacon({ BeaconID: 'bcn-remote', HostID: 'host-beta' })
393
+ });
394
+ // Stub the reachability matrix to say these two CAN reach each other
395
+ tmpHarness.reachability.getReachability = function ()
396
+ {
397
+ return { Status: 'reachable' };
398
+ };
399
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-remote');
400
+ Expect(tmpResult.Strategy).to.equal('direct');
401
+ Expect(tmpResult.DirectURL).to.be.a('string');
402
+ }
403
+ );
404
+
405
+ test
406
+ (
407
+ 'returns Strategy=proxy when the source beacon does not exist in the registry',
408
+ function ()
409
+ {
410
+ let tmpHarness = _buildTestHarness(
411
+ {
412
+ 'bcn-requestor': _beacon({ BeaconID: 'bcn-requestor' })
413
+ });
414
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-vanished', 'bcn-requestor');
415
+ Expect(tmpResult.Strategy).to.equal('proxy');
416
+ }
417
+ );
418
+
419
+ test
420
+ (
421
+ 'prefers shared-fs over direct when both are possible',
422
+ function ()
423
+ {
424
+ let tmpHarness = _buildTestHarness(
425
+ {
426
+ 'bcn-source': _beacon({ BeaconID: 'bcn-source' }),
427
+ 'bcn-consumer': _beacon({ BeaconID: 'bcn-consumer' })
428
+ });
429
+ // Even if the matrix says they're reachable via HTTP, shared-fs
430
+ // should still win because it's cheaper.
431
+ tmpHarness.reachability.getReachability = function ()
432
+ {
433
+ return { Status: 'reachable' };
434
+ };
435
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-source', 'bcn-consumer');
436
+ Expect(tmpResult.Strategy).to.equal('shared-fs');
437
+ }
438
+ );
439
+
440
+ test
441
+ (
442
+ 'falls back to direct/proxy when one of the beacons is a legacy (no HostID) beacon',
443
+ function ()
444
+ {
445
+ let tmpHarness = _buildTestHarness(
446
+ {
447
+ 'bcn-legacy': _beacon({ BeaconID: 'bcn-legacy', HostID: null }),
448
+ 'bcn-modern': _beacon({ BeaconID: 'bcn-modern' })
449
+ });
450
+ // A legacy beacon can't participate in shared-fs — should fall
451
+ // through to the matrix-based direct/proxy decision.
452
+ let tmpResult = tmpHarness.reachability.resolveStrategy('bcn-legacy', 'bcn-modern');
453
+ Expect(tmpResult.Strategy).to.not.equal('shared-fs');
454
+ Expect(['direct', 'proxy']).to.include(tmpResult.Strategy);
455
+ }
456
+ );
457
+ }
458
+ );
459
+
460
+ // ================================================================
461
+ // _findSharedMount (helper)
462
+ // ================================================================
463
+ suite
464
+ (
465
+ '_findSharedMount',
466
+ function ()
467
+ {
468
+ test
469
+ (
470
+ 'returns null for empty or missing arrays',
471
+ function ()
472
+ {
473
+ let tmpHarness = _buildTestHarness({});
474
+ let tmpR = tmpHarness.reachability;
475
+ Expect(tmpR._findSharedMount(null, null)).to.equal(null);
476
+ Expect(tmpR._findSharedMount(undefined, undefined)).to.equal(null);
477
+ Expect(tmpR._findSharedMount([], [])).to.equal(null);
478
+ Expect(tmpR._findSharedMount([{ MountID: 'x' }], [])).to.equal(null);
479
+ Expect(tmpR._findSharedMount([], [{ MountID: 'x' }])).to.equal(null);
480
+ }
481
+ );
482
+
483
+ test
484
+ (
485
+ 'returns the first matching mount from the source side',
486
+ function ()
487
+ {
488
+ let tmpHarness = _buildTestHarness({});
489
+ let tmpMatch = tmpHarness.reachability._findSharedMount(
490
+ [
491
+ { MountID: 'a', Root: '/root-a' },
492
+ { MountID: 'b', Root: '/root-b' }
493
+ ],
494
+ [
495
+ { MountID: 'b', Root: '/root-b-as-seen-by-peer' }
496
+ ]);
497
+ Expect(tmpMatch).to.not.equal(null);
498
+ Expect(tmpMatch.MountID).to.equal('b');
499
+ // Source-side root wins — which matters because the dispatcher
500
+ // uses this Root to build the LocalPath for the requesting side.
501
+ Expect(tmpMatch.Root).to.equal('/root-b');
502
+ }
503
+ );
504
+
505
+ test
506
+ (
507
+ 'skips entries that have no MountID',
508
+ function ()
509
+ {
510
+ let tmpHarness = _buildTestHarness({});
511
+ Expect(tmpHarness.reachability._findSharedMount(
512
+ [{ Root: '/a' }, { MountID: 'c', Root: '/c' }],
513
+ [{ MountID: 'c', Root: '/c' }]
514
+ ).MountID).to.equal('c');
515
+ }
516
+ );
517
+ }
518
+ );
519
+ }
520
+ );
@@ -190,7 +190,7 @@ suite
190
190
  Expect(tmpInstance.definition.Hash).to.equal('read-file');
191
191
 
192
192
  let tmpDefs = tmpRegistry.listDefinitions();
193
- Expect(tmpDefs.length).to.equal(54);
193
+ Expect(tmpDefs.length).to.equal(56);
194
194
 
195
195
  // Verify all registered definitions have Capability, Action, and Tier
196
196
  for (let i = 0; i < tmpDefs.length; i++)
@@ -1806,7 +1806,7 @@ suite
1806
1806
  let tmpBuiltInConfigs = require('../source/services/tasks/Ultravisor-BuiltIn-TaskConfigs.cjs');
1807
1807
  let tmpCount = tmpRegistry.registerTaskTypesFromConfigArray(tmpBuiltInConfigs);
1808
1808
 
1809
- Expect(tmpCount).to.equal(54);
1809
+ Expect(tmpCount).to.equal(56);
1810
1810
 
1811
1811
  // Spot-check a few
1812
1812
  Expect(tmpRegistry.hasTaskType('error-message')).to.equal(true);
@@ -1991,7 +1991,7 @@ suite
1991
1991
 
1992
1992
  // Configs already registered by createTestFable — verify all present
1993
1993
  let tmpDefs = tmpRegistry.listDefinitions();
1994
- Expect(tmpDefs.length).to.equal(54);
1994
+ Expect(tmpDefs.length).to.equal(56);
1995
1995
  }
1996
1996
  );
1997
1997
  }
@@ -3877,7 +3877,7 @@ suite
3877
3877
 
3878
3878
  // Verify WaitingTasks has the dispatch node
3879
3879
  Expect(pContext.WaitingTasks['dispatch-1']).to.not.equal(undefined);
3880
- Expect(pContext.WaitingTasks['dispatch-1'].ResumeEventName).to.equal('Complete');
3880
+ Expect(pContext.WaitingTasks['dispatch-1'].ResumeEventName).to.equal('complete');
3881
3881
 
3882
3882
  // Verify a work item was enqueued
3883
3883
  let tmpWorkItems = tmpCoordinator.listWorkItems();