Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cde58d3
Bump version to 1.2.4 and auto-add to PATH on Windows install
abbyfluoroethane May 12, 2026
fe8f5b1
Bump version to 1.2.5 and unify version management
abbyfluoroethane May 14, 2026
c05c72b
Fix auto-updater crash: remove incorrect call_from_thread usage
abbyfluoroethane May 14, 2026
1cc011c
Refocus execution detail UI on a single operation at a time
abbyfluoroethane May 17, 2026
4a2101a
Update uv.lock
abbyfluoroethane May 17, 2026
79472e6
Split execution detail UI into tabs
abbyfluoroethane May 17, 2026
ec99882
Bump version to 1.2.8
abbyfluoroethane May 17, 2026
0c59a96
Prepare v1.3.0
abbyfluoroethane May 17, 2026
aa8c034
Visual polish pass for v1.3.0
abbyfluoroethane May 17, 2026
feeda55
Hold step on NC; auto-resume when all dispositions terminal
abbyfluoroethane May 17, 2026
f0de68c
Self-host JetBrains Mono and IBM Plex Sans
abbyfluoroethane May 17, 2026
3b11660
Apply mono/sans design rule consistently
abbyfluoroethane May 17, 2026
f6042da
Auto-grow textareas, drop the resize handle
abbyfluoroethane May 17, 2026
66e9ca9
Align procedure-template editor with the execution UI
abbyfluoroethane May 17, 2026
67f30a7
Op reorder + dependency-graph editor + execution gating
abbyfluoroethane May 17, 2026
f29ad89
Fix sidebar drag-to-reorder: pointer events instead of HTML5 native drag
abbyfluoroethane May 17, 2026
152943e
Fix sidebar drag-reorder: disable native link-drag on <a> rows
abbyfluoroethane May 17, 2026
6deeae1
Fix drag-reorder: suppress text selection on draggable rows
abbyfluoroethane May 17, 2026
2af5cbb
Render procedure ops in order, not by step_number label
abbyfluoroethane May 17, 2026
1c405e9
Renumber step labels on reorder
abbyfluoroethane May 17, 2026
9496f94
Rename BLOCKED label to PENDING for gated ops in the execution viewer
abbyfluoroethane May 17, 2026
7f60832
Move the PENDING gating chip to the left of the step-count progress
abbyfluoroethane May 17, 2026
f939b86
Photo data-capture fields + inline images in step content
abbyfluoroethane May 17, 2026
298e2e9
Fix photo upload 422: omit Content-Type so fetch can set multipart bo…
abbyfluoroethane May 17, 2026
3928c80
Photo data-capture: support single or multiple images per field
abbyfluoroethane May 17, 2026
dbd6206
Pre-tag cleanup for v1.3.0
abbyfluoroethane May 17, 2026
2141945
Reference documents on procedure templates with download links on exe…
abbyfluoroethane May 17, 2026
508f5f2
Run ruff format to unbreak CI
abbyfluoroethane May 17, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ jobs:

- uses: astral-sh/setup-uv@v4

- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
shell: python
run: |
import re, pathlib
version = "${{ github.ref_name }}"[1:] # strip leading 'v'
p = pathlib.Path("pyproject.toml")
p.write_text(re.sub(r'^version = .*', f'version = "{version}"', p.read_text(), count=1, flags=re.MULTILINE))
print(f"Set version to {version}")

- name: Install dependencies
run: uv sync --extra app

Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Changelog

All notable user-visible changes to OPAL. Dates are release dates.

## 1.3.0 — 2026-05-17

First major release under the **amorphous engineering** org.

### Added

#### Execution viewer
- **Tabbed execution detail UI.** `/executions/{id}` is split into six focused tabs — Meta, Operations, Data, BOM, Issue Tickets, Kitting — replacing the long stacked-panel page. Tab switching is an HTMX partial-swap so URL state (`?tab=<slug>&op=<order>`), back/forward, and scroll position are all preserved.
- **Per-operation focus in the Operations tab.** The right pane renders only the selected op's steps; the sidebar lists every op with name (truncated), `OP <n>` + progress + status, and the global StepExecution ID. Sidebar clicks swap just the op block.
- **Captured-data audit table.** The Data tab shows a flat audit-style table (step / field / value / by / at) of every `data_captured` value across the run. Multi-photo fields render as `N image(s) (#id, #id, …)`.
- **Issues badge.** The Issues tab label shows the linked-issue count; the old red full-page alert banner is gone.
- **NC step-hold.** Logging a non-conformance against a step automatically puts the step into `ON_HOLD` and shows a banner across the top of the step content listing the open NCs. START/COMPLETE/SKIP are suppressed. The step auto-resumes to `IN_PROGRESS` the moment every linked NC reaches a terminal disposition (`disposition_approved` or `closed`).
- **Execution gating** from the version snapshot. Ops whose prerequisite ops aren't all in a terminal status surface a yellow `PENDING` chip in the sidebar and `PENDING — waiting on OP X, Y` in the main pane; the START API also refuses the transition.

#### Procedure-template editor
- **Tabbed editor shell** at `/procedures/{id}` — Meta / Operations / Flow / Kit / Outputs / Versions. Mirrors the execution viewer's HTMX-driven tab pattern.
- **Inline step editor.** Each step row in the Operations tab expands to a full editor (title, instructions, duration, sign-off / contingency flags, step-parts table, data-capture schema builder) — no more separate `/steps/{id}/edit` page. The legacy URL redirects to the inline view with the right step pre-expanded.
- **Drag-to-reorder.** Sidebar ops and main-pane sub-steps are drag-reorderable. On drop the API runs the reorder and the OP / sub-step *display numbers* renumber to match (`OP 1`, `OP 2`, …, contingency ops as `C1`, `C2`, …, sub-steps as `<parent>.<n>`).
- **Flow tab — operation dependency graph.** Resolve-style node graph: each op is a node auto-laid-out by longest-path topological depth; drag from a node's right-edge output port to another's left-edge input port to add a dependency; hover an edge to surface a red X to remove it. Cycle and self-loop are rejected with clear errors.
- **Data-capture `photo` field type.** Single or multi-image per field — operators upload during execution (mobile triggers the camera natively); thumbnails render inline with a REMOVE button; the schema builder offers an "Allow multiple photos" toggle.
- **Inline images in step instructions.** Drag-drop an image onto the instructions textarea or click `INSERT IMAGE`; the helper uploads to `/api/attachments/upload` (scoped to the procedure for cascade cleanup), then splices `![filename](/api/attachments/N/download)` at the caret. The markdown renders the image inline in both the editor's preview and during execution.

### Changed
- Tab strip styled to match `.nav-dropdown-btn` (transparent → orange-bordered active state) so the new chrome reads as the same family as the top nav.
- Status footer is now `position: sticky; bottom: 0` — stays visible at the viewport bottom on long pages.
- Sub-step headers in the Operations tab render `op#.step#` (e.g. `8.5 Verify Charge Continuity`) even when the version snapshot only stores the sub-step part.
- **Textareas auto-grow with content.** Resize handles removed app-wide; CSS `field-sizing: content` powers modern browsers, a small JS handler covers the rest.
- **Mono/sans rule applied consistently.** Sans for prose (op titles, step titles, login subtitles, narrative); mono retained for chrome and data (IDs, timestamps, status pills, table data).
- **JetBrains Mono and IBM Plex Sans are self-hosted.** One variable-axis woff2 per family (latin subset), served under `/static/fonts/`. No external CDN dependency; visual experience is now identical across macOS / Windows / Linux.
- Repo migrated to the `amorphous-engineering/OPAL` GitHub org; all install scripts, auto-updater URLs, and CI references updated.
- Single source of truth for `__version__` — derived from `pyproject.toml` via `importlib.metadata`; the release workflow writes the tag-derived version into the binary at build time.

### Fixed
- **Auto-updater crash on Textual app thread** (regression in 1.2.1). `_apply_update` no longer wraps its UI callback in `call_from_thread`, which Textual rejects on the app's own thread. Affected installs on ≤1.2.4 need a manual binary swap to escape; 1.2.5+ updates cleanly.
- Windows installer now auto-adds the install location to PATH.
- Updater silently dropped malformed release tags (returned `None` with no log) — now logs a warning when a tag fails to parse.
- Updater raised a bare `OSError` when the running binary lived at a write-protected path (e.g. `/usr/local/bin/opal`) — now raises a clear `RuntimeError` naming the path and pointing to permissions.
- MCP server's startup-time `print(..., file=sys.stderr)` calls replaced with `logging` so MCP logs format consistently with the rest of the app.
- Procedure ops in the editor are now sorted by `order` (the field the reorder API updates) instead of by their display step-number label, so drag-reorder is actually reflected on the next render.
- `/api/attachments/upload` FK fields (`procedure_instance_id`, `step_execution_id`, `issue_id`) are now read from the multipart form body via `Form()`. They were silently dropped before — uploads from the JS layer were never linking to their parent record.
- Sidebar drag-reorder works on `<a href>` rows by suppressing native link-drag and text-selection so the pointer-event handler can actually run.

### Known limitations
- **Inline images in published versions can 404 after the procedure is deleted.** Soft-deleting a procedure cascades attachment rows, so markdown stored in a `ProcedureVersion.content` snapshot referencing those images will render broken thumbnails. Versions are supposed to be immutable; this is a real gap that will be addressed in a follow-up (likely by promoting "delete with versions" to "archive" or by copying image bytes into the snapshot).
- **Sidebar drag-reorder is mouse-only.** Touch / pen pointer support is on the 1.3.x list.
- **`OPAL_manual.md` predates the 1.3.0 UI rewrites.** A docs-only point release will bring it up to date.

### Internal
- Ruff pass + reformat across `src/`.
- Release CI builds binaries for macOS arm64, macOS x86_64, Linux x86_64, and Windows x86_64.
- Two Alembic migrations: `bb285af00e88` (step_dependency table) and `eb217417d6d2` (attachment.procedure_id column).
2 changes: 1 addition & 1 deletion OPAL_manual.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OPAL User Manual

**Version 0.4.5**
**Version 1.3.0**

**Operations, Procedures, Assets, Logistics**

Expand Down
16 changes: 6 additions & 10 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,16 @@ function Install-Binary {

# --- PATH check ---

function Update-PathAdvice {
function Update-Path {
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($UserPath -and $UserPath.Split(";") -contains $InstallDir) {
return
}

Write-Warn "$InstallDir is not in your PATH"
Write-Host ""
Write-Host " Run this to add it permanently:"
Write-Host ""
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" -ForegroundColor Cyan
Write-Host ""
Write-Host " Then restart your terminal."
Write-Host ""
[Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User")
$env:Path = "$InstallDir;$env:Path"
Write-Info "Added $InstallDir to your PATH"
Write-Warn "Restart your terminal for the PATH change to take effect in new sessions"
}

# --- Main ---
Expand All @@ -133,7 +129,7 @@ function Install-Opal {
$DownloadUrl = Find-DownloadUrl -AssetPattern $AssetName

Install-Binary -Url $DownloadUrl -AssetName $AssetName
Update-PathAdvice
Update-Path

Write-Host ""
Write-Host "OPAL $Tag installed successfully." -ForegroundColor White
Expand Down
36 changes: 36 additions & 0 deletions migrations/versions/a310ab9da1d8_add_kind_column_to_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Add kind column to attachment

Revision ID: a310ab9da1d8
Revises: eb217417d6d2
Create Date: 2026-05-17 06:22:33.821880

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'a310ab9da1d8'
down_revision: Union[str, None] = 'eb217417d6d2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('attachment', schema=None) as batch_op:
batch_op.add_column(sa.Column('kind', sa.String(length=20), nullable=True, comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; null = legacy/unscoped"))
batch_op.create_index(batch_op.f('ix_attachment_kind'), ['kind'], unique=False)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('attachment', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_attachment_kind'))
batch_op.drop_column('kind')

# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Add step_dependency table for operation-level gating

Revision ID: bb285af00e88
Revises: 00d3ac121e5e
Create Date: 2026-05-17 04:51:50.070485

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'bb285af00e88'
down_revision: Union[str, None] = '00d3ac121e5e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('step_dependency',
sa.Column('step_id', sa.Integer(), nullable=False, comment='The dependent (gated) step'),
sa.Column('depends_on_step_id', sa.Integer(), nullable=False, comment='The prerequisite step'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['depends_on_step_id'], ['procedure_step.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['step_id'], ['procedure_step.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('step_id', 'depends_on_step_id', name='uq_step_dependency')
)
with op.batch_alter_table('step_dependency', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_step_dependency_depends_on_step_id'), ['depends_on_step_id'], unique=False)
batch_op.create_index(batch_op.f('ix_step_dependency_step_id'), ['step_id'], unique=False)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('step_dependency', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_step_dependency_step_id'))
batch_op.drop_index(batch_op.f('ix_step_dependency_depends_on_step_id'))

op.drop_table('step_dependency')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Add procedure_id to attachment for template-level images

Revision ID: eb217417d6d2
Revises: bb285af00e88
Create Date: 2026-05-17 05:47:36.286130

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'eb217417d6d2'
down_revision: Union[str, None] = 'bb285af00e88'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('attachment', schema=None) as batch_op:
batch_op.add_column(sa.Column('procedure_id', sa.Integer(), nullable=True, comment='Template-level scope: inline images in step instructions / procedure description'))
batch_op.create_index(batch_op.f('ix_attachment_procedure_id'), ['procedure_id'], unique=False)
batch_op.create_foreign_key(
'fk_attachment_procedure_id', 'master_procedure',
['procedure_id'], ['id'], ondelete='CASCADE',
)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('attachment', schema=None) as batch_op:
batch_op.drop_constraint('fk_attachment_procedure_id', type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_attachment_procedure_id'))
batch_op.drop_column('procedure_id')

# ### end Alembic commands ###
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "opal"
version = "1.2.3"
version = "1.3.0"
description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
4 changes: 3 additions & 1 deletion src/opal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
An enterprise resource planning system for small teams and hardware projects.
"""

__version__ = "1.2.1"
from importlib.metadata import version

__version__ = version("opal")
43 changes: 37 additions & 6 deletions src/opal/api/routes/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import uuid
from pathlib import Path

from fastapi import APIRouter, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel

Expand All @@ -28,6 +28,8 @@ class AttachmentResponse(BaseModel):
procedure_instance_id: int | None
step_execution_id: int | None
issue_id: int | None = None
procedure_id: int | None = None
kind: str | None = None
created_at: str

model_config = {"from_attributes": True}
Expand All @@ -43,6 +45,8 @@ def _attachment_to_response(att: Attachment) -> AttachmentResponse:
procedure_instance_id=att.procedure_instance_id,
step_execution_id=att.step_execution_id,
issue_id=att.issue_id,
procedure_id=att.procedure_id,
kind=att.kind,
created_at=att.created_at.isoformat(),
)

Expand All @@ -52,13 +56,17 @@ async def upload_attachment(
db: DbSession,
user_id: CurrentUserId,
file: UploadFile,
procedure_instance_id: int | None = None,
step_execution_id: int | None = None,
issue_id: int | None = None,
procedure_instance_id: int | None = Form(default=None),
step_execution_id: int | None = Form(default=None),
issue_id: int | None = Form(default=None),
procedure_id: int | None = Form(default=None),
kind: str | None = Form(default=None),
) -> AttachmentResponse:
"""Upload a file attachment.

Can be linked to a procedure instance, step execution, and/or issue.
Can be linked to a procedure instance, step execution, issue, and/or
procedure template. `procedure_id` scopes inline images used in step
instructions so they're cleaned up when the procedure is deleted.
"""
settings = get_active_settings()

Expand Down Expand Up @@ -101,6 +109,20 @@ async def upload_attachment(
if not issue:
raise HTTPException(status_code=404, detail=f"Issue {issue_id} not found")

if procedure_id:
from opal.db.models import MasterProcedure

procedure = (
db.query(MasterProcedure)
.filter(
MasterProcedure.id == procedure_id,
MasterProcedure.deleted_at.is_(None),
)
.first()
)
if not procedure:
raise HTTPException(status_code=404, detail=f"Procedure {procedure_id} not found")

# Sanitize filename and generate stored name
original_name = file.filename or "unnamed"
# Strip path separators and limit length
Expand All @@ -122,6 +144,8 @@ async def upload_attachment(
procedure_instance_id=procedure_instance_id,
step_execution_id=step_execution_id,
issue_id=issue_id,
procedure_id=procedure_id,
kind=kind,
)
db.add(attachment)
db.flush()
Expand Down Expand Up @@ -161,8 +185,11 @@ async def list_attachments(
procedure_instance_id: int | None = Query(None),
step_execution_id: int | None = Query(None),
issue_id: int | None = Query(None),
procedure_id: int | None = Query(None),
kind: str | None = Query(None),
) -> list[AttachmentResponse]:
"""List attachments, optionally filtered by instance, step, or issue."""
"""List attachments, optionally filtered by instance, step, issue,
procedure, or kind."""
query = db.query(Attachment)

if procedure_instance_id is not None:
Expand All @@ -171,6 +198,10 @@ async def list_attachments(
query = query.filter(Attachment.step_execution_id == step_execution_id)
if issue_id is not None:
query = query.filter(Attachment.issue_id == issue_id)
if procedure_id is not None:
query = query.filter(Attachment.procedure_id == procedure_id)
if kind is not None:
query = query.filter(Attachment.kind == kind)

attachments = query.order_by(Attachment.created_at.desc()).limit(200).all()
return [_attachment_to_response(a) for a in attachments]
Expand Down
Loading
Loading