Skip to content
Closed
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
14 changes: 11 additions & 3 deletions src/backend/editor/compiler/editor-compiler-platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* `middleware/adapters/web/`.
*/

import { deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program'
import { deployReachedDevice, deployRuntimeProgram } from '@root/backend/shared/library/deploy-runtime-program'
import { probeRuntimeVersion } from '@root/backend/shared/library/probe-runtime-version'
import {
fromSchemaShape,
Expand Down Expand Up @@ -387,7 +387,11 @@ export function createEditorCompilerPlatformPort(
startIntervalMs: context.startIntervalMs,
})

return { ok: deployOutcome === 'STARTED' }
// 'STARTED' is not the only success: a runtime that refused to start
// because its hardware mode switch reads STOP has still taken the
// program. deployReachedDevice keeps that judgement in one place, shared
// with the web adapter.
return { ok: deployReachedDevice(deployOutcome) }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
log(`Runtime v4 upload failed: ${message}`, 'error')
Expand Down Expand Up @@ -486,7 +490,11 @@ export function createEditorCompilerPlatformPort(
startTimeoutMs: context.startTimeoutMs,
startIntervalMs: context.startIntervalMs,
})
return { ok: deployOutcome === 'STARTED' }
// 'STARTED' is not the only success: a runtime that refused to start
// because its hardware mode switch reads STOP has still taken the
// program. deployReachedDevice keeps that judgement in one place, shared
// with the web adapter.
return { ok: deployReachedDevice(deployOutcome) }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
log(`Runtime v3 upload failed: ${message}`, 'error')
Expand Down
19 changes: 19 additions & 0 deletions src/backend/shared/library/__tests__/start-plc-after-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ describe('startPlcAfterBuild', () => {
expect(calls()).toBe(3)
})

it('treats a mode switch in STOP as a warning, not a failure', async () => {
// The runtime refuses to start while a hardware switch reads STOP. The
// program is on the device, so this must NOT read as a failed upload -- that
// sent users hunting for a problem that did not exist.
const { fetch, calls } = scriptedFetch(['START:ERROR_SWITCH_STOP'])
const logs: Array<{ level: string; message: string }> = []
const outcome = await startPlcAfterBuild({
fetchStart: fetch,
onLog: (level, message) => logs.push({ level, message }),
pollIntervalMs: 1,
})
expect(outcome).toBe('SWITCH_IN_STOP')
// Not retried: nothing changes until a human moves the switch.
expect(calls()).toBe(1)
expect(logs[logs.length - 1].level).toBe('warning')
expect(logs[logs.length - 1].message).toMatch(/uploaded/i)
expect(logs[logs.length - 1].message).toMatch(/switch is in STOP/i)
})

it('bails with FAILED on a non-BUSY error reply', async () => {
const { fetch, calls } = scriptedFetch(['ERROR:INVALID_PROGRAM'])
const logs: Array<{ level: string; message: string }> = []
Expand Down
21 changes: 21 additions & 0 deletions src/backend/shared/library/deploy-runtime-program.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Deploy a compiled program to an OpenPLC v4 runtime: upload the zip,
* poll the runtime build until it settles, then send START.
Expand All @@ -23,13 +23,31 @@

export type DeployRuntimeProgramOutcome =
| 'STARTED'
| 'UPLOADED_NOT_STARTED'
| 'UPLOAD_FAILED'
| 'BUILD_FAILED'
| 'BUILD_TIMEOUT'
| 'BUILD_ERROR'
| 'START_FAILED'
| 'START_TIMEOUT'

/**
* Did the program reach the device?
*
* Distinct from "did it start", because those are different questions and the
* upload step must only fail for the first one. A runtime that refuses to start
* while its hardware mode switch reads STOP has still taken the program, and
* reporting that as a failed upload sends the user looking for a problem that
* does not exist.
*
* Shared rather than re-derived per platform: the editor and web adapters both
* turn this outcome into `UploadResult.ok`, and they must not drift on which
* outcomes count.
*/
export function deployReachedDevice(outcome: DeployRuntimeProgramOutcome): boolean {
return outcome === 'STARTED' || outcome === 'UPLOADED_NOT_STARTED'
}

export type DeployRuntimeProgramLogLevel = 'info' | 'error' | 'warning' | 'debug'

export interface DeployRuntimeProgramOptions {
Expand Down Expand Up @@ -109,6 +127,9 @@
pollIntervalMs: opts.startIntervalMs,
})
if (startOutcome === 'STARTED') return 'STARTED'
// Refused by the hardware mode switch: uploaded, deliberately not running.
// startPlcAfterBuild has already explained it as a warning.
if (startOutcome === 'SWITCH_IN_STOP') return 'UPLOADED_NOT_STARTED'
if (startOutcome === 'TIMEOUT') return 'START_TIMEOUT'
return 'START_FAILED'
}
19 changes: 18 additions & 1 deletion src/backend/shared/library/start-plc-after-build.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Send START to the OpenPLC v4 runtime after a successful build,
* retrying while the runtime is still finishing its previous STOP
Expand All @@ -21,12 +21,17 @@
* - 150-ms interval between attempts.
* - `START:OK` or `ALREADY_RUNNING` in the runtime's reply → SUCCESS.
* - `BUSY` in the reply → keep retrying.
* - `ERROR_SWITCH_STOP` → SWITCH_IN_STOP: not an error. The runtime
* refuses to start while a hardware mode switch reads STOP, and
* leaving the switch there is a normal thing for someone to do
* while uploading. The upload succeeded; only the start was
* declined, and the user decides when to allow it.
* - Any other reply → terminal failure (e.g. invalid program).
* - Network error on the fetch → terminal failure.
* - Deadline elapsed → TIMEOUT (a warning, not a fatal error).
*/

export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT'
export type StartPlcAfterBuildOutcome = 'STARTED' | 'FAILED' | 'TIMEOUT' | 'SWITCH_IN_STOP'

export type StartPlcAfterBuildLogLevel = 'info' | 'error' | 'warning'

Expand Down Expand Up @@ -74,6 +79,18 @@
return 'STARTED'
}

// A hardware mode switch in STOP is a refusal, not a failure: the
// program is on the device, and the runtime is doing exactly what it
// should by declining to run it until the switch says so. Retrying
// would be wrong too — nothing changes until someone moves it.
if (status.includes('ERROR_SWITCH_STOP')) {
opts.onLog(
'warning',
'Program uploaded. The PLC was not started because the mode switch is in STOP — move it to RUN to start.',
)
return 'SWITCH_IN_STOP'
}

// Only BUSY is retryable — everything else is a real error
// from the runtime (invalid program, compile error, …).
if (!status.includes('BUSY')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities'
import { useCallback, useEffect, useRef, useState } from 'react'

Expand Down Expand Up @@ -425,6 +425,11 @@

const handlePlcControl = useCallback(async (): Promise<void> => {
if (!jwtToken || connectionStatus !== 'connected') return
// Mid-transition the runtime refuses everything but PING and STATUS with
// COMMAND:BUSY, so a start or stop here can only produce a confusing error.
// The button is disabled for this too; guarding the handler as well covers
// any other caller and the gap between a stale render and the next poll.
if (plcStatus === 'TRANSITIONING') return

try {
if (plcStatus === 'RUNNING') {
Expand Down Expand Up @@ -460,6 +465,18 @@
}
}, [runtime, jwtToken, connectionStatus, plcStatus, addLog])

/**
* Run/stop is blocked while the runtime is mid-transition.
*
* TRANSITIONING means a start or stop is in flight: the runtime answers
* COMMAND:BUSY to everything except PING and STATUS, and the state it will
* settle on is not decided yet. Clicking then cannot do what the icon says --
* the icon itself is ambiguous, since it is drawn from a state that is about to
* change -- so the button goes inert until the runtime lands.
*/
const isPlcTransitioning = plcStatus === 'TRANSITIONING'
const isPlcControlBlocked = connectionStatus !== 'connected' || isPlcTransitioning

Comment on lines +468 to +479

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching default.tsx:"
fd -a 'default\.tsx$' . | sed 's#^\./##' | head -50

echo
echo "Target file outline/size:"
wc -l src/frontend/components/_organisms/workspace-activity-bar/default.tsx 2>/dev/null || true
ast-grep outline src/frontend/components/_organisms/workspace-activity-bar/default.tsx --view compact 2>/dev/null | head -200 || true

echo
echo "Relevant lines 430-505:"
sed -n '430,505p' src/frontend/components/_organisms/workspace-activity-bar/default.tsx | nl -ba -v430

echo
echo "Search jwtToken and PLC control handlers/usages:"
rg -n "jwtToken|handlePlcControl|isPlcControlBlocked|useOpenPLCStore|plcStatus|connectionStatus|Transitioning" src/frontend/components/_organisms/workspace-activity-bar/default.tsx src -g '*.{ts,tsx}' | head -200

Repository: Autonomy-Logic/openplc-editor

Length of output: 451


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant lines 430-505:"
sed -n '430,505p' src/frontend/components/_organisms/workspace-activity-bar/default.tsx | awk '{printf "%6d\t%s\n", NR+429, $0}'

echo
echo "JWT/control related lines in target file:"
rg -n "jwtToken|handlePlcControl|isPlcControlBlocked|useOpenPLCStore|plcStatus|connectionStatus|Transitioning|TRANSACTION|TRANSITIONING" src/frontend/components/_organisms/workspace-activity-bar/default.tsx

echo
echo "Store/selector definitions for jwtToken and connectionStatus:"
rg -n "jwtToken|connectionStatus|plcStatus|useOpenPLCStore|createOpenPLCStore|PLCStore|store" src -g '*.{ts,tsx}' | head -300

echo
echo "Read likely store definitions (top 30 matches names):"
rg -l "jwtToken|connectionStatus" src -g '*.{ts,tsx}' | head -20 | while read -r f; do
  echo "--- $f"
  wc -l "$f"
  rg -n "jwtToken|connectionStatus|plcStatus" "$f" | head -80
done

Repository: Autonomy-Logic/openplc-editor

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Candidate store files:"
fd -i 'store\.(ts|tsx)$|index\.ts$' src/frontend/components/_organisms/workspace-activity-bar src/frontend/store 2>/dev/null | awk '{printf "%s\n",$0}' | head -100

echo
echo "Relevant workspace activity button context 905-950:"
sed -n '905,950p' src/frontend/components/_organisms/workspace-activity-bar/default.tsx | awk '{printf "%6d\t%s\n", NR+904, $0}'

echo
echo "Find runtimePort.startPlc/stopPlc and runtimeJwtToken usages:"
rg -n "startPlc|stopPlc|runtimeJwtToken|runtimeConnection\.jwtToken|jwtToken:" src -g '*.{ts,tsx}' | head -250

echo
echo "Inspect target runtime-port type/implementation slice:"
rg -n "startPlc|stopPlc|runtimeJwtToken|RuntimePort|runtimeConnection|jwtToken" src/middleware/shared/ports/runtime-port.ts src/main src/middleware -g '*.{ts,tsx}' | head -300

Repository: Autonomy-Logic/openplc-editor

Length of output: 17971


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Runtime adapter startPlc/stopPlc:"
sed -n '120,155p' src/middleware/adapters/editor/runtime-adapter.ts | awk '{printf "%6d\t%s\n", NR+119, $0}'

echo
echo "Runtime adapter dependency/connection setup:"
rg -n "createEditorRuntimeAdapter|jwtToken|stringOrNull|runtimeConnection|connected|RuntimePort" src/middleware/adapters/editor/runtime-adapter.ts src/frontend/store src/frontend/store/slices/device -g '*.{ts,tsx}' | head -220

echo
echo "Device slice actions around jwtToken:"
sed -n '400,455p' src/frontend/store/slices/device/slice.ts | awk '{printf "%6d\t%s\n", NR+399, $0}'

echo
echo "Read-only verifier: handler disable-conditions vs runtime token dependency"
python3 - <<'PY'
from pathlib import Path
target = Path("src/frontend/components/_organisms/workspace-activity-bar/default.tsx")
text = target.read_text()

checks = {
    "handlePlcControl returns when !jwtToken": "if (!jwtToken || connectionStatus !== 'connected') return" in text,
    "handlePlcControl returns when disconnected": "if (!jwtToken || connectionStatus !== 'connected') return" in text,
    "handlePlcControl returns when transitioning": "if (plcStatus === 'TRANSITIONING') return" in text,
    "isPlcControlBlocked blocked disconnected": "connectionStatus !== 'connected'" in text,
    "isPlcControlBlocked blocked transitioning": "isPlcTransitioning" in text,
    "isPlcControlBlocked currently omits jwtToken": "const isPlcControlBlocked = connectionStatus !== 'connected' || isPlcTransitioning" in text,
}
for name, result in checks.items():
    print(f"{name}: {result}")
PY

Repository: Autonomy-Logic/openplc-editor

Length of output: 26706


Include the token check in the PLC control disabled state.

handlePlcControl exits when jwtToken is absent, but isPlcControlBlocked only blocks disconnected and transitioning states. A connected runtime without a token would keep the PLC button enabled for a no-op click.

Proposed fix
-  const isPlcControlBlocked = connectionStatus !== 'connected' || isPlcTransitioning
+  const isPlcControlBlocked = connectionStatus !== 'connected' || !jwtToken || isPlcTransitioning
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Run/stop is blocked while the runtime is mid-transition.
*
* TRANSITIONING means a start or stop is in flight: the runtime answers
* COMMAND:BUSY to everything except PING and STATUS, and the state it will
* settle on is not decided yet. Clicking then cannot do what the icon says --
* the icon itself is ambiguous, since it is drawn from a state that is about to
* change -- so the button goes inert until the runtime lands.
*/
const isPlcTransitioning = plcStatus === 'TRANSITIONING'
const isPlcControlBlocked = connectionStatus !== 'connected' || isPlcTransitioning
/**
* Run/stop is blocked while the runtime is mid-transition.
*
* TRANSITIONING means a start or stop is in flight: the runtime answers
* COMMAND:BUSY to everything except PING and STATUS, and the state it will
* settle on is not decided yet. Clicking then cannot do what the icon says --
* the icon itself is ambiguous, since it is drawn from a state that is about to
* change -- so the button goes inert until the runtime lands.
*/
const isPlcTransitioning = plcStatus === 'TRANSITIONING'
const isPlcControlBlocked = connectionStatus !== 'connected' || !jwtToken || isPlcTransitioning
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx` around
lines 468 - 479, Update isPlcControlBlocked alongside isPlcTransitioning to also
disable PLC controls when jwtToken is absent, matching the guard in
handlePlcControl and preventing enabled no-op clicks on connected runtimes
without authentication.

// ---------------------------------------------------------------------------
// Simulator control (Start/Stop simulator + auto-debug)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -904,20 +921,22 @@
: 'Start Simulator'
: connectionStatus !== 'connected'
? 'Connect to runtime first'
: plcStatus === 'RUNNING'
? 'Stop PLC'
: 'Start PLC'
: isPlcTransitioning
? 'PLC is changing state...'
: plcStatus === 'RUNNING'
? 'Stop PLC'
: 'Start PLC'
}
>
<PlayButton
onClick={isSimulatorBoard ? () => void handleSimulatorControl() : () => void handlePlcControl()}
disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'}
disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : isPlcControlBlocked}
className={cn(
isSimulatorBoard
? isCompiling || isDebuggerProcessing
? disabledButtonClass
: ''
: connectionStatus !== 'connected'
: isPlcControlBlocked
? disabledButtonClass
: '',
)}
Comment on lines 931 to 942

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Set an action-specific accessible name.

PlayButton supplies aria-label='Play'. This control can start the PLC, stop the PLC, or show a transition state. Screen readers will announce “Play” for the Stop PLC action. Pass the same action label used by the tooltip as aria-label.

Proposed fix
 <PlayButton
+  aria-label={
+    isSimulatorBoard
+      ? simulatorRunning
+        ? 'Stop Simulator'
+        : 'Start Simulator'
+      : connectionStatus !== 'connected'
+        ? 'Connect to runtime first'
+        : isPlcTransitioning
+          ? 'PLC is changing state...'
+          : plcStatus === 'RUNNING'
+            ? 'Stop PLC'
+            : 'Start PLC'
+  }
   onClick={isSimulatorBoard ? () => void handleSimulatorControl() : () => void handlePlcControl()}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<PlayButton
onClick={isSimulatorBoard ? () => void handleSimulatorControl() : () => void handlePlcControl()}
disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'}
disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : isPlcControlBlocked}
className={cn(
isSimulatorBoard
? isCompiling || isDebuggerProcessing
? disabledButtonClass
: ''
: connectionStatus !== 'connected'
: isPlcControlBlocked
? disabledButtonClass
: '',
)}
<PlayButton
aria-label={
isSimulatorBoard
? simulatorRunning
? 'Stop Simulator'
: 'Start Simulator'
: connectionStatus !== 'connected'
? 'Connect to runtime first'
: isPlcTransitioning
? 'PLC is changing state...'
: plcStatus === 'RUNNING'
? 'Stop PLC'
: 'Start PLC'
}
onClick={isSimulatorBoard ? () => void handleSimulatorControl() : () => void handlePlcControl()}
disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : isPlcControlBlocked}
className={cn(
isSimulatorBoard
? isCompiling || isDebuggerProcessing
? disabledButtonClass
: ''
: isPlcControlBlocked
? disabledButtonClass
: '',
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_organisms/workspace-activity-bar/default.tsx` around
lines 931 - 942, Update the PlayButton invocation in the workspace activity bar
to provide an explicit aria-label matching the action-specific label already
used by its tooltip, so PLC start, stop, and transition states are announced
correctly instead of always “Play.”

Expand Down
Loading