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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions orchestrator/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc]
from routes.metrics import metrics_bp
from routes.phases import phases_bp
from routes.pipelines import pipelines_bp
from routes.sdlc_tokens import sdlc_tokens_bp
from routes.signals import signals_bp
from webhooks import webhooks_bp

Expand All @@ -59,13 +60,15 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc]
app.register_blueprint(decisions_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(webhooks_bp)
app.register_blueprint(sdlc_tokens_bp)
except ImportError:
from .routes.containers import containers_bp # type: ignore[no-redef]
from .routes.decisions import decisions_bp # type: ignore[no-redef]
from .routes.health import health_bp # type: ignore[no-redef]
from .routes.metrics import metrics_bp # type: ignore[no-redef]
from .routes.phases import phases_bp # type: ignore[no-redef]
from .routes.pipelines import pipelines_bp # type: ignore[no-redef]
from .routes.sdlc_tokens import sdlc_tokens_bp # type: ignore[no-redef]
from .routes.signals import signals_bp # type: ignore[no-redef]
from .webhooks import webhooks_bp # type: ignore[no-redef]

Expand All @@ -77,6 +80,7 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc]
app.register_blueprint(decisions_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(webhooks_bp)
app.register_blueprint(sdlc_tokens_bp)


@app.before_request
Expand Down
3 changes: 3 additions & 0 deletions orchestrator/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ class Pipeline(BaseModel):
)
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
contract_synced: bool = Field(default=True, description="Whether state is synced with contract")
sdlc_token_gated: bool = Field(
default=False, description="Whether this pipeline requires token-gated approval"
)
error: str | None = Field(default=None, description="Error if failed")
version: int = Field(
default=1, ge=1, description="Optimistic locking version (incremented on each save)"
Expand Down
21 changes: 20 additions & 1 deletion orchestrator/routes/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def get_logger(name: str, **kwargs) -> logging.Logger: # type: ignore[misc]
DecisionNotFoundError,
get_decision_queue,
)
from state_store import InvalidPipelineIdError
from state_store import InvalidPipelineIdError, PipelineNotFoundError, get_state_store

logger = get_logger("orchestrator.decisions")

Expand Down Expand Up @@ -287,6 +287,25 @@ def resolve_decision(pipeline_id: str, decision_id: str) -> tuple[Response, int]
if not resolution:
return make_error_response("Missing resolution")

# Check if pipeline is token-gated — block direct resolution if so
try:
store = get_state_store(repo_path)
pipeline = store.load_pipeline(pipeline_id)
if pipeline.sdlc_token_gated:
return make_error_response(
"This pipeline requires token-gated approval. "
"Type !approve <phase> in your terminal.",
status_code=403,
)
except PipelineNotFoundError:
pass # Pipeline may not exist yet, allow resolution
except Exception:
logger.error("Failed to check token gate", pipeline_id=pipeline_id, exc_info=True)
return make_error_response(
"Unable to verify pipeline token gate. Try again.",
status_code=503,
)

try:
queue = get_decision_queue(pipeline_id, repo_path)
decision = queue.resolve_decision(decision_id, resolution)
Expand Down
9 changes: 9 additions & 0 deletions orchestrator/routes/pipelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,9 @@ def create_pipeline() -> tuple[Response, int]:
repo = data.get("repo")
branch = data.get("branch")

# Check for pre-generated SDLC tokens (set by entrypoint before Claude starts)
from routes.sdlc_tokens import has_tokens_for_pipeline

if not issue_number:
return make_error_response("Missing issue_number")
if not repo:
Expand All @@ -436,6 +439,12 @@ def create_pipeline() -> tuple[Response, int]:
# Contract creation is deferred to _run_pipeline so it writes
# into the per-pipeline worktree instead of the main repo.

# Enable token gating if tokens were pre-generated for this pipeline
if has_tokens_for_pipeline(pipeline.id):
pipeline.sdlc_token_gated = True
store.save_pipeline(pipeline, commit=False)
logger.info("Pipeline token-gated", pipeline_id=pipeline.id)

logger.info(
"Pipeline created",
pipeline_id=pipeline.id,
Expand Down
Loading