@rashidee/co2 1.2.6 → 1.3.2
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/dist/.co2-dat/app.db +0 -0
- package/dist/.co2-dat/app.db-shm +0 -0
- package/dist/.co2-dat/app.db-wal +0 -0
- package/dist/.co2-dat/config.json +4 -0
- package/dist/index.js +5303 -3139
- package/drizzle/0011_workflow_history_v152.sql +5 -0
- package/drizzle/0012_prd_sections_v160.sql +14 -0
- package/drizzle/0013_brainstorm_sessions_v170.sql +10 -0
- package/drizzle/0014_bug_module_v180.sql +4 -0
- package/drizzle/0015_project_config_v190.sql +23 -0
- package/drizzle/0016_cicd_v2100.sql +22 -0
- package/drizzle/meta/_journal.json +42 -0
- package/package.json +1 -1
- package/plugin/README.md +2 -0
- package/plugin/SKILLS.md +417 -350
- package/plugin/skills/conductor-feature-develop/SKILL.md +1 -1
- package/plugin/skills/conductor-feature-prepare/SKILL.md +23 -3
- package/plugin/skills/depgen-k8s/SKILL.md +1 -1
- package/plugin/skills/tracegen-matrix/SKILL.md +1 -1
- package/plugin/skills/util-gencicdscript/SKILL.md +91 -0
- package/plugin/skills/util-gencicdscript/references/cicd-app-template.md +414 -0
- package/plugin/skills/util-plancicd/SKILL.md +130 -0
- package/plugin/skills/util-projectinit/SKILL.md +1 -1
- package/plugin/skills/util-projectsync/SKILL.md +1 -1
- package/plugin/skills/util-updprd/SKILL.md +85 -15
- package/plugin/skills/util-usanalyzer/SKILL.md +1 -1
- package/plugin/skills/util-ustagger/SKILL.md +1 -1
- package/static/assets/{abnfDiagram-VRR7QNED-DvPuwyCV.js → abnfDiagram-VRR7QNED-CN4J6j_u.js} +1 -1
- package/static/assets/{arc-DRKw7sjs.js → arc-N4fM3ZWM.js} +1 -1
- package/static/assets/{architectureDiagram-ZJ3FMSHR-CaNcxUbC.js → architectureDiagram-ZJ3FMSHR-uch_sUft.js} +1 -1
- package/static/assets/{blockDiagram-677ZJIJ3-Demd4AOK.js → blockDiagram-677ZJIJ3-B8ciSHmI.js} +1 -1
- package/static/assets/{c4Diagram-LMCZKHZV-PVKUfc2x.js → c4Diagram-LMCZKHZV-CQcm-uRR.js} +1 -1
- package/static/assets/channel-D1lJ_RG1.js +1 -0
- package/static/assets/{chunk-2Q5K7J3B-CExf8ww_.js → chunk-2Q5K7J3B-CKyyGyMe.js} +1 -1
- package/static/assets/{chunk-32BRIVSS-CFPaoONF.js → chunk-32BRIVSS-BUvhUzbF.js} +1 -1
- package/static/assets/{chunk-5VM5RSS4-DE2esM67.js → chunk-5VM5RSS4-DeYPG9gz.js} +1 -1
- package/static/assets/{chunk-EX3LRPZG-SON_YdDG.js → chunk-EX3LRPZG-M5DW3NIE.js} +1 -1
- package/static/assets/{chunk-JWPE2WC7-Qxah8LNr.js → chunk-JWPE2WC7-DdUQGRCV.js} +1 -1
- package/static/assets/{chunk-MOJQB5TN-BcWGQV3j.js → chunk-MOJQB5TN-BjTWQfa-.js} +1 -1
- package/static/assets/{chunk-RYQCIY6F-DkDj-OR5.js → chunk-RYQCIY6F-B6eqE68w.js} +1 -1
- package/static/assets/{chunk-V7JOEXUC-BlI1Mrah.js → chunk-V7JOEXUC-31fpjmeh.js} +1 -1
- package/static/assets/{chunk-VR4S4FIN-Agf__duj.js → chunk-VR4S4FIN-DIdhgHxJ.js} +1 -1
- package/static/assets/{chunk-XXDRQBXY-B5CfAh85.js → chunk-XXDRQBXY-CwmvQsRd.js} +1 -1
- package/static/assets/classDiagram-OUVF2IWQ-B2LV6Dty.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-B2LV6Dty.js +1 -0
- package/static/assets/{cose-bilkent-JH36ORCC-DO0cQkcy.js → cose-bilkent-JH36ORCC-0fxMNvxl.js} +1 -1
- package/static/assets/{cynefin-VYW2F7L2-CfOnsnXc.js → cynefin-VYW2F7L2-CykGMMcJ.js} +1 -1
- package/static/assets/{cynefinDiagram-TSTJHNR4-BJ8tULAv.js → cynefinDiagram-TSTJHNR4-CXA1U39p.js} +1 -1
- package/static/assets/{dagre-VKFMJZFB-DfjwzHqP.js → dagre-VKFMJZFB-Bw5XMtDI.js} +1 -1
- package/static/assets/{diagram-FQU43EPY-CndSOIIM.js → diagram-FQU43EPY-xte6N075.js} +1 -1
- package/static/assets/{diagram-G47NLZAW-Czf5hN0Z.js → diagram-G47NLZAW-CHoRtp3z.js} +1 -1
- package/static/assets/{diagram-NH7WQ7WH-COAyojGh.js → diagram-NH7WQ7WH-CEFlTlPg.js} +1 -1
- package/static/assets/{diagram-OA4YK3LP-H8PoiN2m.js → diagram-OA4YK3LP-DJ0JX9s2.js} +1 -1
- package/static/assets/{diagram-WEI45ONY-DzcaVzjp.js → diagram-WEI45ONY-BRfCVtnm.js} +1 -1
- package/static/assets/{ebnfDiagram-CCIWWBDH-DPazGYLJ.js → ebnfDiagram-CCIWWBDH-Cc-dcKTE.js} +1 -1
- package/static/assets/{erDiagram-Q63AITRT-Bo-fubgF.js → erDiagram-Q63AITRT-DLZsu__O.js} +1 -1
- package/static/assets/{flowDiagram-23GEKE2U-CSvxUDwh.js → flowDiagram-23GEKE2U-Cczq8RCz.js} +1 -1
- package/static/assets/{ganttDiagram-NO4QXBWP-DnBveWX6.js → ganttDiagram-NO4QXBWP-DOXGNB5h.js} +1 -1
- package/static/assets/{gitGraphDiagram-IHSO6WYX-B3rVL8c-.js → gitGraphDiagram-IHSO6WYX-BM-SCSXa.js} +1 -1
- package/static/assets/{index-y7XDSgMG.js → index-CBLEvyTD.js} +172 -171
- package/static/assets/{index-LZSQkLE2.css → index-Dn_JY-18.css} +1 -1
- package/static/assets/{infoDiagram-FWYZ7A6U-CvWV9yy2.js → infoDiagram-FWYZ7A6U-dJDRFcA8.js} +1 -1
- package/static/assets/{ishikawaDiagram-FXEZZL3T-DF28a1N6.js → ishikawaDiagram-FXEZZL3T-VxLIIU05.js} +1 -1
- package/static/assets/{journeyDiagram-5HDEW3XC-CB6CugYk.js → journeyDiagram-5HDEW3XC-EvtzKlzP.js} +1 -1
- package/static/assets/{kanban-definition-HUTT4EX6-ResSLF6d.js → kanban-definition-HUTT4EX6-yO62k9Tr.js} +1 -1
- package/static/assets/{linear-CLO--ryk.js → linear-DZzMubPw.js} +1 -1
- package/static/assets/{mindmap-definition-LN4V7U3C-BvW3EbxX.js → mindmap-definition-LN4V7U3C-BRO6imMR.js} +1 -1
- package/static/assets/{pegDiagram-2B236MQR-BHCgqrrM.js → pegDiagram-2B236MQR-DJR3fTw9.js} +1 -1
- package/static/assets/{pieDiagram-ENE6RG2P-DVmCQ5FA.js → pieDiagram-ENE6RG2P-9mFM-_RT.js} +1 -1
- package/static/assets/{quadrantDiagram-ABIIQ3AL-B3e5KR52.js → quadrantDiagram-ABIIQ3AL-BEsVn0Kx.js} +1 -1
- package/static/assets/{railroadDiagram-RFXS5EU6-Csv5ysLc.js → railroadDiagram-RFXS5EU6-D9Mp7Mil.js} +1 -1
- package/static/assets/{requirementDiagram-TGXJPOKE-DOMVzvnB.js → requirementDiagram-TGXJPOKE-CRYcxiHN.js} +1 -1
- package/static/assets/{sankeyDiagram-HTMAVEWB-B1dcTOvy.js → sankeyDiagram-HTMAVEWB-9MJPBNY-.js} +1 -1
- package/static/assets/{sequenceDiagram-DBY2YBRQ-BY414yeO.js → sequenceDiagram-DBY2YBRQ-CSAt7g1a.js} +1 -1
- package/static/assets/{sizeCapture-X5ZJPWSS-CuJfrfBN.js → sizeCapture-X5ZJPWSS-DQ7t3sAl.js} +1 -1
- package/static/assets/{stateDiagram-2N3HPSRC-FX4rJkzV.js → stateDiagram-2N3HPSRC-B314IVUq.js} +1 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-C5_r7hRR.js +1 -0
- package/static/assets/{swimlanes-5IMT3BWC-Sfy58mmv.js → swimlanes-5IMT3BWC-gCXbGpn-.js} +2 -2
- package/static/assets/swimlanesDiagram-G3AALYLV-BkEgKJGN.js +8 -0
- package/static/assets/{timeline-definition-FHXFAJF6-CmV7by8p.js → timeline-definition-FHXFAJF6-Ypa-zz8f.js} +1 -1
- package/static/assets/{vennDiagram-L72KCM5P-FqIyFZFt.js → vennDiagram-L72KCM5P-C07T1tIJ.js} +1 -1
- package/static/assets/{wardleyDiagram-EHGQE667-BZ06O45i.js → wardleyDiagram-EHGQE667-DwsMVlwx.js} +1 -1
- package/static/assets/{xychartDiagram-FW5EYKEG-BOkffR06.js → xychartDiagram-FW5EYKEG-BCtXbdXL.js} +1 -1
- package/static/index.html +2 -2
- package/plugin/skills/util-preparek8senv/SKILL.md +0 -422
- package/static/assets/channel-C2nVbuwv.js +0 -1
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +0 -1
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +0 -1
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +0 -1
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +0 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: conductor-feature-prepare
|
|
3
|
-
model: claude-sonnet-
|
|
3
|
+
model: claude-sonnet-5
|
|
4
4
|
effort: high
|
|
5
5
|
description: >
|
|
6
6
|
Context artifacts preparation orchestrator — generates all context artifacts (module models,
|
|
@@ -551,8 +551,28 @@ to already exist):
|
|
|
551
551
|
1. Set the top-level `**Status**:` in `<app_folder>/context/prepare/PREPARE_MASTER.md` to
|
|
552
552
|
`COMPLETED`, and verify every step and every version row is `COMPLETED` or `SKIPPED` (no
|
|
553
553
|
`PENDING` / `IN PROGRESS` rows remain)
|
|
554
|
-
2.
|
|
555
|
-
|
|
554
|
+
2. Append an umbrella entry to `CHANGELOG.md` in the application folder
|
|
555
|
+
(`<app_folder>/CHANGELOG.md`) — **one entry per version processed**. This is the
|
|
556
|
+
phase-level summary row (mirroring `conductor-feature-develop` / `conductor-defect`); the
|
|
557
|
+
individual sub-skills — `util-ustagger`, `modelgen-*`, `mockgen-*`, `specgen-*`,
|
|
558
|
+
`testgen-functional` — have already appended their own rows during the run, so this row sits
|
|
559
|
+
**on top of** them, it does not replace them:
|
|
560
|
+
- Read `<app_folder>/CHANGELOG.md`. If it does not exist, create it with the standard context
|
|
561
|
+
header (normally the sub-skills already created it earlier in the run).
|
|
562
|
+
- For EACH version in the resolved version list (ascending order):
|
|
563
|
+
- Search for a `## {version}` heading matching this version.
|
|
564
|
+
- If the section **exists**: append a new row to its table (it will exist — the sub-skills
|
|
565
|
+
created it).
|
|
566
|
+
- If the section **does not exist**: insert a new section after the `---` below the context
|
|
567
|
+
header and before any existing `## vX.Y.Z` section (newest-first ordering), with a new
|
|
568
|
+
table header and the first row.
|
|
569
|
+
- Row format: `| {YYYY-MM-DD} | {application_name} | conductor-feature-prepare | {module or "All"} | Prepared context artifacts (model, mockup, spec, test) for {version} — {count} modules |`
|
|
570
|
+
- `{count}` = the number of modules whose artifacts were generated/updated for this version
|
|
571
|
+
(from that version's Artifact Generation Status table in PREPARE_MASTER.md).
|
|
572
|
+
- `{module or "All"}` = the `module` filter if one was active for this run, else `All`.
|
|
573
|
+
- **Never modify or delete existing rows** (including the sub-skills' own rows).
|
|
574
|
+
3. Output a summary of what was generated/skipped per version (sourced from PREPARE_MASTER.md)
|
|
575
|
+
4. Output `<promise>ALL ARTIFACTS GENERATED</promise>` to signal Ralph Loop completion
|
|
556
576
|
|
|
557
577
|
**IMPORTANT — Version loop completion**: The completion promise is ONLY output after the LAST
|
|
558
578
|
version in the resolved list has been fully processed. Do NOT output it after completing an
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: util-gencicdscript
|
|
3
|
+
model: claude-sonnet-5
|
|
4
|
+
effort: high
|
|
5
|
+
description: >
|
|
6
|
+
Generate the self-contained CI/CD build/run/deploy app for a CO2 project from an APPROVED
|
|
7
|
+
cicd/CICD_PLAN.md. Refuses to run unless the plan has `**Status**: COMPLETED` AND an
|
|
8
|
+
`**Approved**:` line (stamped by Compound Context Studio's Approve button). Produces
|
|
9
|
+
cicd/app/ — a zero-npm-dependency node:http app (server.js + cicd.config.json) rendering a
|
|
10
|
+
bare light-themed page (no header/sidebar/footer, iframe-embeddable) with one nav tab per
|
|
11
|
+
application: a controls bar (status badge, clickable app URL opening a new tab, Build /
|
|
12
|
+
Start / Stop / Deploy buttons) above that application's log streamed live over a built-in
|
|
13
|
+
zero-dependency WebSocket (bounded tail + backpressure drop server-side, trimmed DOM buffer
|
|
14
|
+
client-side — no leaks/OOM). Build and Deploy commands come from the approved plan (inferred
|
|
15
|
+
from ENVIRONMENT.md/DEVTOOL.md — e.g. Spring Boot jar vs Docker image; deploy via web-server
|
|
16
|
+
copy, docker run or kubectl apply, or none). Controls are disabled for any application that
|
|
17
|
+
has never completed a conductor-develop workflow run (checked against the studio's
|
|
18
|
+
/api/cicd-guard endpoint using the spawn-env token). Health/port probing marks apps already
|
|
19
|
+
running — even ones started outside this app. No version selector: only the latest built
|
|
20
|
+
applications are operated. Writes cicd/CICD_GENERATION.md with `**Status**:` IN PROGRESS →
|
|
21
|
+
COMPLETED so the studio watcher detects completion. Idempotent — re-running regenerates
|
|
22
|
+
cicd/app in place. Trigger on keywords: "generate cicd", "gen cicd script", "generate ci/cd
|
|
23
|
+
app", "cicd app", "generate deployment app". Accepts one MANDATORY argument: the
|
|
24
|
+
project-relative plan path (e.g., /util-gencicdscript cicd/CICD_PLAN.md).
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# Util Generate CI/CD Script
|
|
28
|
+
|
|
29
|
+
## Inputs
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
/util-gencicdscript cicd/CICD_PLAN.md
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Phase 0 — Gate (hard requirements, check in order)
|
|
36
|
+
|
|
37
|
+
1. The argument file exists — else STOP: "run /util-plancicd first".
|
|
38
|
+
2. It contains a top-level `**Status**: COMPLETED` — else STOP: "the plan is not complete".
|
|
39
|
+
3. It contains an `**Approved**:` line — else STOP: "approve the plan in the studio's CI/CD
|
|
40
|
+
screen first". NEVER proceed without approval.
|
|
41
|
+
|
|
42
|
+
## Phase 1 — Write the generation tracking file
|
|
43
|
+
|
|
44
|
+
Write `cicd/CICD_GENERATION.md`:
|
|
45
|
+
|
|
46
|
+
```markdown
|
|
47
|
+
# CI/CD App Generation
|
|
48
|
+
|
|
49
|
+
**Started**: <ISO date>
|
|
50
|
+
**Status**: IN PROGRESS
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Phase 2 — Generate cicd/app/
|
|
54
|
+
|
|
55
|
+
Read `references/cicd-app-template.md` for the complete file templates. Produce:
|
|
56
|
+
|
|
57
|
+
1. `cicd/app/cicd.config.json` — from the plan's tables:
|
|
58
|
+
`{ "port": <CI/CD app port>, "apps": [{ "name", "folder", "method", "port", "build",
|
|
59
|
+
"start", "stop", "deployMethod", "deploy", "health" }] }` — `deployMethod` is the plan's
|
|
60
|
+
Deploy Method column (`none|web-server|docker|kubernetes`) and `deploy` its command
|
|
61
|
+
(`null` when the method is `none`).
|
|
62
|
+
2. `cicd/app/server.js` — the template verbatim (it is generic; all project specifics live in
|
|
63
|
+
cicd.config.json). Zero npm dependencies.
|
|
64
|
+
3. Ensure `cicd/logs/` and `cicd/state.json` are ignored: append `cicd/logs/` and
|
|
65
|
+
`cicd/state.json` to the project `.gitignore` if absent.
|
|
66
|
+
|
|
67
|
+
## Phase 3 — Complete
|
|
68
|
+
|
|
69
|
+
Flip `cicd/CICD_GENERATION.md` `**Status**:` to `COMPLETED`. Print a summary (app folder,
|
|
70
|
+
port, application count) and STOP — the studio starts/embeds the app.
|
|
71
|
+
|
|
72
|
+
## Important Rules
|
|
73
|
+
|
|
74
|
+
- Zero npm dependencies — plain node:http only (the WebSocket log stream is hand-rolled in the
|
|
75
|
+
template); no install step may be required.
|
|
76
|
+
- The page is bare: no header, sidebar or footer (it is iframed by the studio). Light theme,
|
|
77
|
+
one nav tab per application; the tab body is that application's live log.
|
|
78
|
+
- Build/Start/Stop/Deploy run the plan's commands verbatim, each with the APPLICATION FOLDER
|
|
79
|
+
(`<project>/<folder>`) as working directory — command paths are relative to the application
|
|
80
|
+
folder (e.g. `java -jar target\app.jar`), never to the project root. Deploy renders only
|
|
81
|
+
when the plan assigned a deploy method other than `none`. No version selector — only the
|
|
82
|
+
latest built applications are operated.
|
|
83
|
+
- Develop gate: controls stay disabled for an application that never completed a
|
|
84
|
+
conductor-develop run — checked via `${CO2_STUDIO_URL}/api/cicd-guard/${CO2_PROJECT_ID}`
|
|
85
|
+
with the CO2_GUARD_TOKEN from the spawn env (refreshed every 30 s, last answer cached).
|
|
86
|
+
- Log streaming must stay bounded: per-app log file on disk, bounded tail on connect, live
|
|
87
|
+
append broadcast only, slow subscribers dropped on backpressure, client DOM buffer trimmed.
|
|
88
|
+
- Already-running detection: `/api/state` probes each application's health URL (falling back
|
|
89
|
+
to its port) so an app started outside this page still shows as running; a Stop on an
|
|
90
|
+
externally started process is refused with a hint instead of killed.
|
|
91
|
+
- Never touch PRD.md/CLAUDE.md; never run builds or deploys yourself during generation.
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# CI/CD App Template
|
|
2
|
+
|
|
3
|
+
`cicd/app/server.js` — generic; reads `cicd.config.json`. Copy verbatim.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
// Generated by util-gencicdscript — zero-dependency CI/CD build/run/deploy app.
|
|
7
|
+
// Env (set by Compound Context Studio at spawn): PORT, CO2_STUDIO_URL, CO2_PROJECT_ID,
|
|
8
|
+
// CO2_GUARD_TOKEN. Bare page — designed to be embedded in the studio as an iframe.
|
|
9
|
+
// One nav tab per application; the tab body streams that application's log live over a
|
|
10
|
+
// hand-rolled WebSocket (server→client push only). Memory safety: logs live on disk, the
|
|
11
|
+
// server retains nothing per subscriber beyond the socket itself (bounded initial tail,
|
|
12
|
+
// slow clients are dropped on backpressure), and the client trims its DOM log buffer.
|
|
13
|
+
const http = require('node:http')
|
|
14
|
+
const { spawn, execFileSync } = require('node:child_process')
|
|
15
|
+
const { createHash } = require('node:crypto')
|
|
16
|
+
const { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, openSync, readSync, closeSync, createWriteStream } = require('node:fs')
|
|
17
|
+
const { join, resolve } = require('node:path')
|
|
18
|
+
|
|
19
|
+
const APP_DIR = __dirname
|
|
20
|
+
const PROJECT_DIR = resolve(APP_DIR, '..', '..')
|
|
21
|
+
const LOG_DIR = join(PROJECT_DIR, 'cicd', 'logs')
|
|
22
|
+
const STATE_FILE = join(PROJECT_DIR, 'cicd', 'state.json')
|
|
23
|
+
const CONFIG = JSON.parse(readFileSync(join(APP_DIR, 'cicd.config.json'), 'utf8'))
|
|
24
|
+
const PORT = Number(process.env.PORT || CONFIG.port || 4100)
|
|
25
|
+
const STUDIO = process.env.CO2_STUDIO_URL || 'http://127.0.0.1:3001'
|
|
26
|
+
const PROJECT_ID = process.env.CO2_PROJECT_ID || ''
|
|
27
|
+
const TOKEN = process.env.CO2_GUARD_TOKEN || ''
|
|
28
|
+
|
|
29
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
30
|
+
const running = new Map() // app name -> ChildProcess (only processes THIS app spawned)
|
|
31
|
+
const activity = new Map() // app name -> 'building' | 'deploying' (transient, exclusive)
|
|
32
|
+
const failed = new Map() // app name -> true after a nonzero run exit
|
|
33
|
+
const stopping = new Set() // app names whose exit was requested — not a failure
|
|
34
|
+
const lastError = new Map() // app name -> string
|
|
35
|
+
|
|
36
|
+
function saveState() {
|
|
37
|
+
const s = {}
|
|
38
|
+
for (const a of CONFIG.apps) s[a.name] = running.has(a.name) ? 'running' : 'stopped'
|
|
39
|
+
try { writeFileSync(STATE_FILE, JSON.stringify(s)) } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function currentBranch() {
|
|
43
|
+
try {
|
|
44
|
+
return execFileSync('git', ['branch', '--show-current'], { cwd: PROJECT_DIR }).toString().trim()
|
|
45
|
+
} catch { return null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- Develop gate (Compound Context Studio) --------------------------------------------
|
|
49
|
+
// An application that never completed a conductor-develop run has nothing to build or run —
|
|
50
|
+
// its tab controls are disabled. Refreshed every 30 s; the last known answer is kept when the
|
|
51
|
+
// studio is briefly unreachable, and `null` (never fetched) disables controls with a hint.
|
|
52
|
+
let guardApps = null // Map lower(name|folder) -> developedOnce
|
|
53
|
+
async function refreshGuard() {
|
|
54
|
+
if (!PROJECT_ID) return
|
|
55
|
+
try {
|
|
56
|
+
const r = await fetch(`${STUDIO}/api/cicd-guard/${PROJECT_ID}?token=${TOKEN}`, { signal: AbortSignal.timeout(3000) })
|
|
57
|
+
if (!r.ok) return
|
|
58
|
+
const g = await r.json()
|
|
59
|
+
if (Array.isArray(g.applications)) {
|
|
60
|
+
guardApps = new Map()
|
|
61
|
+
for (const a of g.applications) guardApps.set(String(a.name).toLowerCase(), !!a.developedOnce)
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
function developedOnce(app) {
|
|
66
|
+
if (!guardApps) return null // unknown — controls stay disabled until the studio answers
|
|
67
|
+
const byName = guardApps.get(app.name.toLowerCase())
|
|
68
|
+
if (byName !== undefined) return byName
|
|
69
|
+
const byFolder = guardApps.get(String(app.folder || '').toLowerCase())
|
|
70
|
+
return byFolder === undefined ? null : byFolder
|
|
71
|
+
}
|
|
72
|
+
refreshGuard()
|
|
73
|
+
setInterval(refreshGuard, 30_000).unref?.()
|
|
74
|
+
|
|
75
|
+
// ---- Log streaming ---------------------------------------------------------------------
|
|
76
|
+
// One log file per application (build + run + deploy share it — the tab is one stream).
|
|
77
|
+
// Subscribers get a bounded tail on connect, then live appends; nothing is buffered
|
|
78
|
+
// server-side, and a subscriber that cannot keep up is dropped instead of queuing forever.
|
|
79
|
+
const subscribers = new Map() // app name -> Set<net.Socket>
|
|
80
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
81
|
+
|
|
82
|
+
function wsFrame(data) {
|
|
83
|
+
const payload = Buffer.from(data)
|
|
84
|
+
const len = payload.length
|
|
85
|
+
let header
|
|
86
|
+
if (len < 126) header = Buffer.from([0x81, len])
|
|
87
|
+
else if (len < 65_536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(len, 2) }
|
|
88
|
+
else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) }
|
|
89
|
+
return Buffer.concat([header, payload])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function broadcast(name, chunk) {
|
|
93
|
+
const subs = subscribers.get(name)
|
|
94
|
+
if (!subs || subs.size === 0) return
|
|
95
|
+
const frame = wsFrame(chunk)
|
|
96
|
+
for (const s of subs) {
|
|
97
|
+
if (s.writableLength > 1_000_000) { subs.delete(s); try { s.destroy() } catch {} ; continue }
|
|
98
|
+
s.write(frame)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function logPath(name) { return join(LOG_DIR, `${name}.log`) }
|
|
103
|
+
|
|
104
|
+
function logTail(name, bytes = 16_384) {
|
|
105
|
+
try {
|
|
106
|
+
const p = logPath(name)
|
|
107
|
+
const size = statSync(p).size
|
|
108
|
+
const start = Math.max(0, size - bytes)
|
|
109
|
+
const buf = Buffer.alloc(size - start)
|
|
110
|
+
const fd = openSync(p, 'r')
|
|
111
|
+
readSync(fd, buf, 0, buf.length, start)
|
|
112
|
+
closeSync(fd)
|
|
113
|
+
return buf.toString('utf8')
|
|
114
|
+
} catch { return '' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function logLine(app, text) {
|
|
118
|
+
const line = `\n--- ${new Date().toISOString()} ${text}\n`
|
|
119
|
+
try { createWriteStream(logPath(app.name), { flags: 'a' }).end(line) } catch {}
|
|
120
|
+
broadcast(app.name, line)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runLogged(app, command, onExit) {
|
|
124
|
+
const log = createWriteStream(logPath(app.name), { flags: 'a' })
|
|
125
|
+
log.write(`\n--- ${new Date().toISOString()} $ ${command}\n`)
|
|
126
|
+
broadcast(app.name, `\n--- ${new Date().toISOString()} $ ${command}\n`)
|
|
127
|
+
const child = spawn(command, { cwd: join(PROJECT_DIR, app.folder), shell: true, env: process.env })
|
|
128
|
+
const tap = (d) => { log.write(d); broadcast(app.name, d) }
|
|
129
|
+
child.stdout.on('data', tap)
|
|
130
|
+
child.stderr.on('data', tap)
|
|
131
|
+
child.on('exit', (code) => { log.end(); onExit(code) })
|
|
132
|
+
return child
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- App lifecycle ---------------------------------------------------------------------
|
|
136
|
+
async function probe(app) {
|
|
137
|
+
const url = app.health || `http://127.0.0.1:${app.port}/`
|
|
138
|
+
try {
|
|
139
|
+
await fetch(url, { signal: AbortSignal.timeout(800) })
|
|
140
|
+
return true
|
|
141
|
+
} catch { return false }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildApp(app) {
|
|
145
|
+
if (activity.has(app.name)) return
|
|
146
|
+
activity.set(app.name, 'building'); lastError.delete(app.name)
|
|
147
|
+
runLogged(app, app.build, (code) => {
|
|
148
|
+
activity.delete(app.name)
|
|
149
|
+
if (code !== 0) lastError.set(app.name, `build exited ${code} — see log`)
|
|
150
|
+
else logLine(app, 'build OK')
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deployApp(app) {
|
|
155
|
+
if (activity.has(app.name) || !app.deploy) return
|
|
156
|
+
activity.set(app.name, 'deploying'); lastError.delete(app.name)
|
|
157
|
+
runLogged(app, app.deploy, (code) => {
|
|
158
|
+
activity.delete(app.name)
|
|
159
|
+
if (code !== 0) lastError.set(app.name, `deploy exited ${code} — see log`)
|
|
160
|
+
else logLine(app, `deploy OK (${app.deployMethod})`)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function startApp(app) {
|
|
165
|
+
if (running.get(app.name) || activity.has(app.name)) return
|
|
166
|
+
failed.delete(app.name); lastError.delete(app.name)
|
|
167
|
+
if (app.method === 'container') {
|
|
168
|
+
runLogged(app, app.start, () => {})
|
|
169
|
+
saveState(); return
|
|
170
|
+
}
|
|
171
|
+
const child = runLogged(app, app.start, (code) => {
|
|
172
|
+
running.delete(app.name)
|
|
173
|
+
// A user-requested Stop exits nonzero (killed) — that is not a failure.
|
|
174
|
+
if (code !== 0 && !stopping.delete(app.name)) {
|
|
175
|
+
failed.set(app.name, true)
|
|
176
|
+
lastError.set(app.name, `process exited ${code} — see log`)
|
|
177
|
+
} else stopping.delete(app.name)
|
|
178
|
+
saveState()
|
|
179
|
+
})
|
|
180
|
+
running.set(app.name, child)
|
|
181
|
+
saveState()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// shell:true spawns a shell whose grandchildren survive child.kill() on Windows — kill the
|
|
185
|
+
// whole tree there (taskkill /T); plain kill elsewhere.
|
|
186
|
+
function killTree(child) {
|
|
187
|
+
if (process.platform === 'win32' && child.pid) {
|
|
188
|
+
try { execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) } catch {}
|
|
189
|
+
} else {
|
|
190
|
+
try { child.kill() } catch {}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stopApp(app) {
|
|
195
|
+
if (app.method === 'container' && app.stop && app.stop !== 'process-kill') {
|
|
196
|
+
try { execFileSync(app.stop.split(' ')[0], app.stop.split(' ').slice(1), { cwd: PROJECT_DIR }) } catch {}
|
|
197
|
+
} else {
|
|
198
|
+
const child = running.get(app.name)
|
|
199
|
+
if (!child) {
|
|
200
|
+
lastError.set(app.name, 'running outside this CI/CD app — stop it from where it was started')
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
stopping.add(app.name)
|
|
204
|
+
killTree(child)
|
|
205
|
+
}
|
|
206
|
+
running.delete(app.name)
|
|
207
|
+
saveState()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function json(res, code, body) {
|
|
211
|
+
res.writeHead(code, { 'content-type': 'application/json' })
|
|
212
|
+
res.end(JSON.stringify(body))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---- Page ------------------------------------------------------------------------------
|
|
216
|
+
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>CI/CD</title><style>
|
|
217
|
+
body{font-family:system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c2128;display:flex;flex-direction:column;height:100vh}
|
|
218
|
+
.top{display:flex;gap:8px;align-items:center;padding:10px 12px 6px;font-size:13px}
|
|
219
|
+
.meta{font-size:11px;color:#5b6572}
|
|
220
|
+
.tabs{display:flex;gap:2px;padding:0 12px;border-bottom:1px solid #d9dee5;flex-wrap:wrap}
|
|
221
|
+
.tab{border:1px solid #d9dee5;border-bottom:none;border-radius:8px 8px 0 0;background:#eef1f5;padding:6px 14px;font-size:13px;cursor:pointer;display:flex;gap:6px;align-items:center}
|
|
222
|
+
.tab.active{background:#fff;font-weight:600}
|
|
223
|
+
.tab .dot{width:8px;height:8px;border-radius:99px;background:#8a94a0}
|
|
224
|
+
.dot.running{background:#1c8a4a}.dot.failed{background:#c23b3b}.dot.building,.dot.deploying{background:#c9922a}
|
|
225
|
+
.bar{display:flex;gap:8px;align-items:center;padding:10px 12px;background:#fff;border-bottom:1px solid #e1e5ea;flex-wrap:wrap}
|
|
226
|
+
.badge{font-size:10px;text-transform:uppercase;border-radius:99px;padding:2px 8px;color:#fff;background:#8a94a0}
|
|
227
|
+
.badge.running{background:#1c8a4a}.badge.failed{background:#c23b3b}.badge.building,.badge.deploying{background:#c9922a}
|
|
228
|
+
.url{font-size:12px;color:#1a66d0;text-decoration:underline;word-break:break-all}
|
|
229
|
+
button{background:#fff;color:#1c2128;border:1px solid #c6cdd6;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer}
|
|
230
|
+
button:hover:enabled{background:#eef1f5}
|
|
231
|
+
button:disabled{opacity:.4;cursor:default}
|
|
232
|
+
.notice{padding:4px 12px;font-size:11px;color:#8a5a00;background:#fff7e6;display:none}
|
|
233
|
+
.err{padding:4px 12px;font-size:11px;color:#c23b3b;display:none;white-space:pre-wrap}
|
|
234
|
+
#log{flex:1;margin:0;padding:10px 12px;background:#0f1216;color:#d7dde4;font-size:11px;line-height:1.5;overflow:auto;white-space:pre-wrap}
|
|
235
|
+
</style></head><body>
|
|
236
|
+
<div class="top"><strong>CI/CD</strong><span id="branch" class="meta"></span></div>
|
|
237
|
+
<div class="tabs" id="tabs"></div>
|
|
238
|
+
<div class="bar" id="bar"></div>
|
|
239
|
+
<div class="notice" id="notice"></div>
|
|
240
|
+
<div class="err" id="err"></div>
|
|
241
|
+
<pre id="log"></pre>
|
|
242
|
+
<script>
|
|
243
|
+
let state = { apps: [] }
|
|
244
|
+
let active = null
|
|
245
|
+
let ws = null, wsTimer = null
|
|
246
|
+
|
|
247
|
+
function appOf(name){ return state.apps.find(a => a.name === name) }
|
|
248
|
+
|
|
249
|
+
function renderTabs(){
|
|
250
|
+
document.getElementById('tabs').innerHTML = state.apps.map(a => \`
|
|
251
|
+
<div class="tab \${a.name===active?'active':''}" onclick="setActive('\${a.name}')">
|
|
252
|
+
<span class="dot \${a.phase}"></span>\${a.name}\${a.developedOnce===false?' 🔒':''}</div>\`).join('')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderBar(){
|
|
256
|
+
const a = appOf(active)
|
|
257
|
+
if (!a) return
|
|
258
|
+
const url = 'http://'+location.hostname+':'+a.port+'/'
|
|
259
|
+
const gate = a.developedOnce === true
|
|
260
|
+
const busy = a.phase==='building'||a.phase==='deploying'
|
|
261
|
+
document.getElementById('bar').innerHTML = \`
|
|
262
|
+
<span class="badge \${a.phase}">\${a.phase}\${a.external?' (external)':''}</span>
|
|
263
|
+
<span class="meta">\${a.method} · port \${a.port}</span>
|
|
264
|
+
<a class="url" href="\${url}" target="_blank" rel="noreferrer">\${url}</a>
|
|
265
|
+
<span style="flex:1"></span>
|
|
266
|
+
<button onclick="act('build')" \${!gate||busy||a.phase==='running'?'disabled':''}>Build</button>
|
|
267
|
+
<button onclick="act('start')" \${!gate||busy||a.phase==='running'?'disabled':''}>Start</button>
|
|
268
|
+
<button onclick="act('stop')" \${a.phase!=='running'||(a.external&&a.method!=='container')?'disabled':''}>Stop</button>
|
|
269
|
+
\${a.deploy?\`<button onclick="act('deploy')" \${!gate||busy?'disabled':''}>Deploy (\${a.deployMethod})</button>\`:''}\`
|
|
270
|
+
const notice = document.getElementById('notice')
|
|
271
|
+
if (a.developedOnce === false) {
|
|
272
|
+
notice.textContent = 'This application has not been developed through the workflow yet — run conductor-feature-develop first.'
|
|
273
|
+
notice.style.display = 'block'
|
|
274
|
+
} else if (a.developedOnce === null) {
|
|
275
|
+
notice.textContent = 'Develop status not verified yet (studio unreachable) — controls stay disabled.'
|
|
276
|
+
notice.style.display = 'block'
|
|
277
|
+
} else notice.style.display = 'none'
|
|
278
|
+
const err = document.getElementById('err')
|
|
279
|
+
if (a.error) { err.textContent = a.error; err.style.display = 'block' } else err.style.display = 'none'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function setActive(name){
|
|
283
|
+
if (active === name) return
|
|
284
|
+
active = name
|
|
285
|
+
closeWs()
|
|
286
|
+
document.getElementById('log').textContent = ''
|
|
287
|
+
openWs(name)
|
|
288
|
+
renderTabs(); renderBar()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Live log stream. One socket at a time (closed on tab switch); auto-reconnect while the tab
|
|
292
|
+
// stays active; the DOM buffer is trimmed so long sessions never grow the page unbounded.
|
|
293
|
+
function openWs(name){
|
|
294
|
+
ws = new WebSocket('ws://'+location.host+'/ws?app='+encodeURIComponent(name))
|
|
295
|
+
ws.onmessage = e => appendLog(e.data)
|
|
296
|
+
ws.onclose = () => { ws = null; if (active === name) wsTimer = setTimeout(() => openWs(name), 2000) }
|
|
297
|
+
}
|
|
298
|
+
function closeWs(){
|
|
299
|
+
if (wsTimer) { clearTimeout(wsTimer); wsTimer = null }
|
|
300
|
+
if (ws) { ws.onclose = null; try { ws.close() } catch {} ws = null }
|
|
301
|
+
}
|
|
302
|
+
function appendLog(text){
|
|
303
|
+
const el = document.getElementById('log')
|
|
304
|
+
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40
|
|
305
|
+
el.textContent += text
|
|
306
|
+
if (el.textContent.length > 400000) el.textContent = el.textContent.slice(-250000)
|
|
307
|
+
if (atBottom) el.scrollTop = el.scrollHeight
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function refresh(){
|
|
311
|
+
const s = await fetch('/api/state').then(r => r.json()).catch(() => null)
|
|
312
|
+
if (!s) return
|
|
313
|
+
state = s
|
|
314
|
+
document.getElementById('branch').textContent = s.currentBranch ? 'branch: '+s.currentBranch : ''
|
|
315
|
+
if (!active && s.apps.length) setActive(s.apps[0].name)
|
|
316
|
+
renderTabs(); renderBar()
|
|
317
|
+
}
|
|
318
|
+
async function act(op){
|
|
319
|
+
await fetch('/api/apps/'+encodeURIComponent(active)+'/'+op, { method: 'POST' })
|
|
320
|
+
refresh()
|
|
321
|
+
}
|
|
322
|
+
refresh(); setInterval(refresh, 3000)
|
|
323
|
+
</script></body></html>`
|
|
324
|
+
|
|
325
|
+
// ---- HTTP ------------------------------------------------------------------------------
|
|
326
|
+
const server = http.createServer(async (req, res) => {
|
|
327
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
328
|
+
res.writeHead(200, { 'content-type': 'text/html' })
|
|
329
|
+
return res.end(PAGE)
|
|
330
|
+
}
|
|
331
|
+
if (req.method === 'GET' && req.url === '/api/state') {
|
|
332
|
+
const apps = await Promise.all(CONFIG.apps.map(async (a) => {
|
|
333
|
+
const healthy = await probe(a)
|
|
334
|
+
const phase = activity.get(a.name) || (healthy ? 'running' : failed.has(a.name) ? 'failed' : 'stopped')
|
|
335
|
+
return {
|
|
336
|
+
name: a.name, method: a.method, port: a.port, phase,
|
|
337
|
+
deploy: !!a.deploy, deployMethod: a.deployMethod || 'none',
|
|
338
|
+
external: healthy && !running.has(a.name),
|
|
339
|
+
developedOnce: developedOnce(a),
|
|
340
|
+
error: lastError.get(a.name) || null,
|
|
341
|
+
}
|
|
342
|
+
}))
|
|
343
|
+
return json(res, 200, { currentBranch: currentBranch(), apps })
|
|
344
|
+
}
|
|
345
|
+
const m = req.url && req.url.match(/^\/api\/apps\/([^/]+)\/(build|start|stop|deploy)$/)
|
|
346
|
+
if (m && req.method === 'POST') {
|
|
347
|
+
const app = CONFIG.apps.find((a) => a.name === decodeURIComponent(m[1]))
|
|
348
|
+
if (!app) return json(res, 404, { error: 'unknown app' })
|
|
349
|
+
if (m[2] !== 'stop' && developedOnce(app) === false) {
|
|
350
|
+
return json(res, 409, { error: 'application has not completed a develop workflow run' })
|
|
351
|
+
}
|
|
352
|
+
if (m[2] === 'build') buildApp(app)
|
|
353
|
+
else if (m[2] === 'start') startApp(app)
|
|
354
|
+
else if (m[2] === 'deploy') deployApp(app)
|
|
355
|
+
else stopApp(app)
|
|
356
|
+
return json(res, 200, { ok: true })
|
|
357
|
+
}
|
|
358
|
+
json(res, 404, { error: 'not found' })
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
// WebSocket endpoint /ws?app=<name> — push-only log stream (see "Log streaming" above).
|
|
362
|
+
server.on('upgrade', (req, socket) => {
|
|
363
|
+
let name = ''
|
|
364
|
+
let isWsPath = false
|
|
365
|
+
try {
|
|
366
|
+
const url = new URL(req.url, 'http://x')
|
|
367
|
+
isWsPath = url.pathname === '/ws'
|
|
368
|
+
name = url.searchParams.get('app') || ''
|
|
369
|
+
} catch {}
|
|
370
|
+
const key = req.headers['sec-websocket-key']
|
|
371
|
+
const app = CONFIG.apps.find((a) => a.name === name)
|
|
372
|
+
if (!app || !key || !isWsPath) { socket.destroy(); return }
|
|
373
|
+
const accept = createHash('sha1').update(key + WS_GUID).digest('base64')
|
|
374
|
+
socket.write(
|
|
375
|
+
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
376
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
|
377
|
+
)
|
|
378
|
+
let subs = subscribers.get(name)
|
|
379
|
+
if (!subs) { subs = new Set(); subscribers.set(name, subs) }
|
|
380
|
+
subs.add(socket)
|
|
381
|
+
const cleanup = () => { subs.delete(socket); try { socket.destroy() } catch {} }
|
|
382
|
+
socket.on('close', cleanup)
|
|
383
|
+
socket.on('error', cleanup)
|
|
384
|
+
// Client frames are only ever close (0x8) — anything else is ignored, never buffered.
|
|
385
|
+
socket.on('data', (buf) => { if ((buf[0] & 0x0f) === 0x8) cleanup() })
|
|
386
|
+
socket.write(wsFrame(logTail(name)))
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
server.listen(PORT, () => console.log(`cicd app on :${PORT}`))
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`cicd/app/cicd.config.json` shape — one entry per application from the plan's Deployment
|
|
393
|
+
Decisions table. `deployMethod`/`deploy` come from the plan's Deploy columns (`deploy` is
|
|
394
|
+
`null` when the method is `none`):
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
{
|
|
398
|
+
"port": 4100,
|
|
399
|
+
"apps": [
|
|
400
|
+
{
|
|
401
|
+
"name": "my-app",
|
|
402
|
+
"folder": "my-app",
|
|
403
|
+
"method": "process",
|
|
404
|
+
"port": 3000,
|
|
405
|
+
"build": "pnpm install && pnpm build",
|
|
406
|
+
"start": "node dist/index.js",
|
|
407
|
+
"stop": "process-kill",
|
|
408
|
+
"deployMethod": "kubernetes",
|
|
409
|
+
"deploy": "kubectl apply -f my-app/k8s/",
|
|
410
|
+
"health": "http://127.0.0.1:3000/"
|
|
411
|
+
}
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
```
|