diff --git a/.tekton/unidesk-host-pac.yaml b/.tekton/unidesk-host-pac.yaml new file mode 100644 index 00000000..1d4b30c0 --- /dev/null +++ b/.tekton/unidesk-host-pac.yaml @@ -0,0 +1,111 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + generateName: unidesk-host- + annotations: + pipelinesascode.tekton.dev/on-event: "[push]" + pipelinesascode.tekton.dev/on-target-branch: "[master]" + pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'master' && node == 'NC01'" + labels: + app.kubernetes.io/name: unidesk-host-pac + app.kubernetes.io/part-of: unidesk-host + unidesk.ai/source-commit: "{{revision}}" +spec: + timeouts: + pipeline: 300s + taskRunTemplate: + serviceAccountName: default + podTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + securityContext: + fsGroup: 1000 + pipelineSpec: + tasks: + - name: publish + taskSpec: + volumes: + - name: workspace + emptyDir: + sizeLimit: 4Gi + - name: buildkit-state + emptyDir: + sizeLimit: 4Gi + - name: tmp + emptyDir: {} + steps: + - name: source + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + workingDir: /workspace + env: + - name: SOURCE_COMMIT + value: "{{revision}}" + script: | + #!/bin/sh + set -eu + rm -rf source release + git clone --filter=blob:none --no-checkout http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git source + cd source + git fetch --depth=1 --filter=blob:none origin "+refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01/$SOURCE_COMMIT:refs/remotes/origin/unidesk-source-snapshot" + git checkout --detach "$SOURCE_COMMIT" + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + bun scripts/native/cicd/prepare-unidesk-host-release.mjs --config config/unidesk-host-k8s.yaml --output-dir /workspace/release + volumeMounts: + - name: workspace + mountPath: /workspace + - name: prepare-buildkit-state + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + script: | + #!/bin/sh + set -eu + mkdir -p /home/user/.local/share/buildkit + chown -R 1000:1000 /home/user/.local/share/buildkit + securityContext: + runAsUser: 0 + runAsGroup: 0 + volumeMounts: + - name: buildkit-state + mountPath: /home/user/.local/share/buildkit + - name: image-build + image: 127.0.0.1:5000/hwlab/buildkit:rootless + imagePullPolicy: IfNotPresent + workingDir: /workspace/source + env: + - name: SOURCE_COMMIT + value: "{{revision}}" + - name: BUILDKITD_FLAGS + value: --oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host + script: | + #!/bin/sh + exec scripts/native/cicd/build-unidesk-host-image.sh + securityContext: + privileged: true + runAsUser: 1000 + runAsGroup: 1000 + volumeMounts: + - name: workspace + mountPath: /workspace + - name: buildkit-state + mountPath: /home/user/.local/share/buildkit + - name: tmp + mountPath: /tmp + - name: gitops-publish + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + workingDir: /workspace/source + env: + - name: SOURCE_COMMIT + value: "{{revision}}" + script: | + #!/bin/sh + exec bun scripts/native/cicd/publish-unidesk-host-gitops.mjs \ + --config config/unidesk-host-k8s.yaml \ + --source-root /workspace/source \ + --metadata /workspace/build-metadata.json \ + --source-commit "$SOURCE_COMMIT" \ + --worktree /workspace/unidesk-host-gitops + volumeMounts: + - name: workspace + mountPath: /workspace diff --git a/.worktreecopy b/.worktreecopy index 11ec4c04..c7485c1c 100644 --- a/.worktreecopy +++ b/.worktreecopy @@ -1,9 +1,5 @@ -# Local runtime hints copied by `bun scripts/cli.ts dev-env worktree add`. -# Keep this file to path patterns only; never store secret values here. -.env/trans.ymal -.env/*.local.yaml -.env/*.local.yml -.env/*.local.json +# Non-secret local runtime hints copied by `bun scripts/cli.ts dev-env worktree add`. +# Canonical credentials live under /root/.unidesk/.env and are never copied. config/*.local.yaml config/*.local.yml config/*.local.json diff --git a/AGENTS.md b/AGENTS.md index a3367a96..ae12100b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ UniDesk 以主 server 为统一入口。本文件只做自动加载的顶级索 - P0: UniDesk 自有配置优先使用 YAML;节点/lane、namespace、endpoint、版本、容量、退避窗口、Secret sourceRef 等可调项必须从 YAML 进入受控 CLI,不得写成隐藏默认。细则见 `docs/reference/yaml-first-ops.md` 和 `$unidesk-ymalops`。 - P0: node/lane 必须作为 YAML 的变量值、selector、target key 或 CLI 参数参与渲染;禁止把具体 node/lane 写入 YAML 文件名、函数名、helper/module 名、长期 schema 名或隐藏默认。 - P0: Secret、API key、JWT/encryption key、`DATABASE_URL` 等凭据只能通过 YAML 声明 sourceRef/targetKey 和受控 CLI 下发;输出只披露对象名、key 名、presence、fingerprint 和摘要,不得打印完整凭据。 +- P0: Credentials: `docs/reference/nc01.md`. - P0: `hyueapi.com` / `.hyueapi.com` 是 Codex API 直连域名,必须保留在 `NO_PROXY` / `no_proxy` 中。 ## P0: 受控入口与技能路由 diff --git a/CI.json b/CI.json index 0e5d1c7f..6ec46d4a 100644 --- a/CI.json +++ b/CI.json @@ -125,15 +125,15 @@ "serviceId": "todo-note", "kind": "source-build", "status": "supported", - "producer": "ci publish-user-service", + "producer": "platform-infra pipelines-as-code", "source": { - "repo": "https://gitee.com/Lyon1998/todo_note", - "dockerfile": "Dockerfile" + "repo": "https://github.com/pikasTech/unidesk", + "dockerfile": "src/components/microservices/todo-note/Dockerfile" }, "image": { "repository": "unidesk/todo-note" }, - "notes": "External Gitee source repo. CI must fetch https://gitee.com/Lyon1998/todo_note at the requested commit and build that repo's Dockerfile; UniDesk only owns the runtime consumer metadata and Compose integration." + "notes": "NC01 PaC/Tekton producer for the vendored Todo Note service. GitOps/Argo consumes the digest-pinned image through config/unidesk-host-k8s.yaml; the legacy external Gitee/Compose producer is retired." }, { "serviceId": "code-queue-mgr", diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 8d478e6f..c515ce8b 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -396,7 +396,7 @@ controlPlane: secrets: githubPrRawToken: id: tool-github-pr-token - sourceRef: /root/unidesk/.env/gh_token.txt + sourceRef: /root/.unidesk/.env/gh_token.txt sourceKey: GH_TOKEN sourceFormat: raw-token targetRef: diff --git a/config/hwlab-node-control-plane.yaml b/config/hwlab-node-control-plane.yaml index 2aea6f8f..7fd4bbec 100644 --- a/config/hwlab-node-control-plane.yaml +++ b/config/hwlab-node-control-plane.yaml @@ -851,7 +851,7 @@ targets: username: x-access-token tokenSecretName: git-mirror-github-token tokenSecretKey: GITHUB_TOKEN - tokenSourceRef: .env/gh_token.txt + tokenSourceRef: /root/.unidesk/.env/gh_token.txt tokenSourceKey: GH_TOKEN tekton: install: diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 27b2f501..c7e6fad1 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -171,9 +171,9 @@ lanes: bootstrapAdmin: username: admin displayName: HWLAB v0.3 Admin - usernameSourceRef: .env/hwlab.auth + usernameSourceRef: /root/.unidesk/.env/hwlab.auth usernameSourceLine: 1 - passwordSourceRef: .env/hwlab.auth + passwordSourceRef: /root/.unidesk/.env/hwlab.auth passwordSourceKey: HWLAB_ADMIN_PASSWORD passwordSourceLine: 2 passwordHashTransform: hwlab-sha256 diff --git a/config/hwlab-web-probe-sentinel/profiles.yaml b/config/hwlab-web-probe-sentinel/profiles.yaml index 965e9c7a..5e389f96 100644 --- a/config/hwlab-web-probe-sentinel/profiles.yaml +++ b/config/hwlab-web-probe-sentinel/profiles.yaml @@ -153,7 +153,7 @@ baselines: secrets: jd01BootstrapSource: purpose: bootstrap-admin - sourceRef: .env/HWLAB_admin.txt + sourceRef: /root/.unidesk/.env/HWLAB_admin.txt sourceKey: HWLAB_ADMIN_PASSWORD sourceLine: 2 dsflashPromptSource: @@ -189,17 +189,17 @@ nodes: sources: - sourceLine: 2 sourceKey: HWLAB_ADMIN_PASSWORD - sourceRef: .env/HWLAB_admin.txt + sourceRef: /root/.unidesk/.env/HWLAB_admin.txt purpose: bootstrap-admin - sourceKey: DSFLASH_GO_TOOL_CALL_10X_PROMPTS_JSON sourceRef: hwlab/web-probe-sentinel-dsflash-go.env purpose: prompt-set - purpose: account-a - sourceRef: .env/HWLAB_admin.txt + sourceRef: /root/.unidesk/.env/HWLAB_admin.txt sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD sourceLine: 2 format: web-account-json - usernameSourceRef: .env/HWLAB_admin.txt + usernameSourceRef: /root/.unidesk/.env/HWLAB_admin.txt usernameSourceLine: 1 - purpose: account-b sourceRef: "hwlab/${nodeLower}-${lane}-preset-users.env" @@ -516,4 +516,4 @@ templates: failedJobsHistoryLimit: 5 activeDeadlineSlackSeconds: 60 ttlSecondsAfterFinished: 86400 - backoffLimit: 0 \ No newline at end of file + backoffLimit: 0 diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 03994c3e..28df0e38 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -32,9 +32,9 @@ sourceAuthority: statusAuthority: gitea-repository-refs-plus-snapshot-ref firstCiConsumer: agentrun-jd01-v02 credentials: - sourceRoot: /root/unidesk + sourceRoot: /root/.unidesk/.env admin: - sourceRef: .env/gitea.auth + sourceRef: gitea.auth format: line-pair usernameLine: 1 passwordLine: 2 @@ -44,7 +44,7 @@ sourceAuthority: - snapshot-create github: transport: https-token - sourceRef: .env/gh_token.txt + sourceRef: gh_token.txt sourceKey: GH_TOKEN format: raw-token requiredFor: @@ -65,7 +65,7 @@ sourceAuthority: events: - push secret: - sourceRef: .env/gitea.github-webhook.env + sourceRef: gitea.github-webhook.env sourceKey: GITHUB_WEBHOOK_SECRET createIfMissing: true bridge: diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 898cbe13..09389759 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -41,15 +41,15 @@ gitea: configRef: "config/platform-infra/gitea.yaml#sourceAuthority.repositories" internalBaseUrl: http://gitea-http.devops-infra.svc.cluster.local:3000 admin: - sourceRoot: /root/unidesk - sourceRef: .env/gitea.auth + sourceRoot: /root/.unidesk/.env + sourceRef: gitea.auth format: line-pair usernameLine: 1 passwordLine: 2 apiUsername: unidesk-admin webhook: - secretRoot: /root/unidesk - secretSourceRef: .env/pipelines-as-code.webhook + secretRoot: /root/.unidesk/.env + secretSourceRef: pipelines-as-code.webhook events: - push branch: params.source_branch @@ -74,6 +74,17 @@ repositories: NODE: NC01 LANE: v03 sourceSnapshotPrefix: refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01 + params: + unidesk_host_image_repository: 127.0.0.1:5000/unidesk/todo-note + unidesk_host_registry_probe_base: http://127.0.0.1:5000 + unidesk_host_gitops_branch: unidesk-host-gitops + unidesk_host_gitops_manifest_path: deploy/gitops/unidesk-host/todo-note.yaml + unidesk_host_runtime_namespace: unidesk + unidesk_host_runtime_deployment: todo-note + unidesk_host_runtime_service: todo-note + unidesk_host_runtime_service_port: "4288" + unidesk_host_health_path: /health + unidesk_host_health_url: http://todo-note.unidesk.svc.cluster.local:4288/health - extends: templates.repositories.hwlabV03 variables: NODE: JD01 @@ -100,6 +111,22 @@ consumers: variables: NODE: NC01 LANE: v03 + - id: unidesk-host + repositoryRef: sentinel-nc01-v03 + node: NC01 + lane: host-k8s + namespace: devops-infra + pipeline: unidesk-host-pac + pipelineRunPrefix: unidesk-host- + argoNamespace: argocd + argoApplication: unidesk-host + argoBootstrap: + project: default + repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + targetRevision: unidesk-host-gitops + path: deploy/gitops/unidesk-host + destinationNamespace: unidesk + automated: true - extends: templates.consumers.hwlabV03 variables: NODE: JD01 diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index db16c3ac..b379cdef 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -10,8 +10,8 @@ github: tokenSource: enabled: true priority: before-env - root: . - sourceRef: .env/gh_token.txt + root: /root/.unidesk/.env + sourceRef: gh_token.txt sourceKey: GH_TOKEN format: raw-token prMerge: diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index 1ae8ea51..75b16cd2 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -17,11 +17,41 @@ runtime: publicHost: 152.53.229.148 noProxy: localhost,127.0.0.1,::1,.svc,.svc.cluster.local,hyueapi.com,.hyueapi.com +delivery: + enabled: false + serviceRef: services.todoNote + build: + networkMode: host + proxy: + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 + noProxy: + - localhost + - 127.0.0.1 + - "::1" + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + image: + repository: 127.0.0.1:5000/unidesk/todo-note + gitops: + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + branch: unidesk-host-gitops + manifestPath: deploy/gitops/unidesk-host/todo-note.yaml + author: + name: UniDesk Host CI + email: unidesk-host-ci@unidesk.local + images: postgres: postgres:16-alpine backendCore: unidesk-backend-core:host-k8s frontend: unidesk-frontend:host-k8s decisionCenter: unidesk-decision-center:host-k8s + todoNote: unidesk-todo-note:host-k8s database: mode: host-native @@ -69,6 +99,7 @@ services: limits: memory: 512Mi decisionCenter: + replicas: 1 deploymentName: decision-center serviceName: decision-center containerName: decision-center @@ -117,6 +148,72 @@ services: memory: 96Mi limits: memory: 512Mi + todoNote: + serviceId: todo-note + imageRef: images.todoNote + storageEnvPrefix: TODO_NOTE + replicas: 1 + deploymentName: todo-note + serviceName: todo-note + containerName: todo-note + containerPort: 4288 + healthPath: /health + livePath: /live + logFile: /var/log/unidesk/todo-note.jsonl + database: + sourceRef: + path: .state/secrets/decision-center/nc01-db.env + key: DATABASE_URL + secretName: decision-center-runtime-secrets + secretKey: DATABASE_URL + storage: + primary: github-repo + github: + repo: pikasTech/decision-center-data + sshUrl: git@github.com:pikasTech/decision-center-data.git + branch: main + basePath: todo-note + worktreePath: /var/lib/unidesk/todo-note/repo + commitMessagePrefix: "todo-note:" + author: + name: UniDesk Todo Note + email: todo-note@unidesk.local + ssh: + secretName: decision-center-github-ssh + mountPath: /var/lib/unidesk/todo-note/ssh + privateKey: + sourceRef: + path: /root/.ssh/id_ed25519_github_codex + secretKey: ssh-privatekey + knownHosts: + sourceRef: + path: /root/.ssh/known_hosts + secretKey: known_hosts + repository: + url: https://github.com/pikasTech/unidesk + dockerfile: src/components/microservices/todo-note/Dockerfile + worktreePath: /root/unidesk + frontend: + route: /apps/todo-note + integrated: true + reminder: + enabled: true + leadMinutes: 10 + scanIntervalMs: 30000 + transport: + type: claudeqq + baseUrl: http://backend-core:8080/api/microservices/claudeqq/proxy + targetType: private + userId: "645275593" + groupId: "" + timeoutMs: 15000 + sendAttempts: 3 + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + memory: 512Mi runtimeConfig: heartBeatTimeoutMs: "30000" @@ -128,7 +225,7 @@ runtimeConfig: runtimeAuth: sourceRef: - path: .env/unidesk.auth + path: /root/.unidesk/.env/unidesk.auth format: linePair usernameLine: 1 passwordLine: 2 diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index 41065ddd..a15306b9 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -159,7 +159,7 @@ docker compose -p unidesk-artifact-registry -f /home/ubuntu/.unidesk/artifact-re 6. Compose runtime retag 为 Compose 使用的镜像名,并执行 `docker compose up -d --no-build --no-deps --force-recreate `;k3s runtime 设置 Deployment image/env/annotations 并等待 rollout。 7. 部署后通过 image label、runtime env、health payload 验证 live commit。 -Baidu Netdisk is the first main-server direct user-service sample in this flow. Its dev validation command and prod CD command both consume `127.0.0.1:5000/unidesk/baidu-netdisk:` and must not build source on the master server. Backend-core is the Rust artifact sample: CI publishes `127.0.0.1:5000/unidesk/backend-core:`, dev CD consumes it into D601 native k3s `backend-core-dev`, and prod CD consumes it into the master-server Compose `backend-core` service. Frontend is the standard UniDesk UI artifact sample: CI publishes `127.0.0.1:5000/unidesk/frontend:`, production CD consumes that artifact into the master-server Compose `frontend` service, and dev CD consumes the same artifact into D601 native k3s `frontend-dev`. Project Manager, OA Event Flow and Todo Note use the same master-server Compose artifact-consumer shape as Baidu Netdisk, with `project-manager-backend`, `oa-event-flow-backend` and `todo-note-backend` as the runtime containers; Todo Note keeps its external source repository and requires a pre-existing `127.0.0.1:5000/unidesk/todo-note:` artifact. Its consumer injects `UNIDESK_TODO_NOTE_DEPLOY_*` runtime metadata and the container health probe combines `/api/health` with that metadata before enforcing `deploy.commit` / `deploy.requestedCommit`. Code Queue Manager is supported as an artifact consumer for validation, but prod live apply is supervisor-gated. Neither path may use `server rebuild frontend`, dirty source, mutable `latest`, or target-side frontend source builds as release truth. MDTODO and ClaudeQQ follow the same artifact-consumer pattern in both dev and prod, except the runtime target is native k3s on D601 instead of the master-server Compose stack. Decision Center is the exception: its current runtime target is NC01 k8s and its durable state is GitHub repo `pikasTech/decision-center-data`, so D601 registry artifact evidence is not production closeout for that service. Dev `code-queue` is a deliberately narrower consumer: it validates only the `unidesk-dev` Code Queue execution slice and has no prod target. The k3s consumer must check the registry manifest, pull the commit-pinned image, import it into `/run/k3s/containerd/containerd.sock`, set the Deployment image to the commit tag, stamp `UNIDESK_DEPLOY_*` env/annotations, verify health through the Kubernetes API service proxy, and reject an old healthy revision if the live commit or requested commit does not match. +Baidu Netdisk is the first main-server direct user-service sample in this flow. Its dev validation command and prod CD command both consume `127.0.0.1:5000/unidesk/baidu-netdisk:` and must not build source on the master server. Backend-core is the Rust artifact sample: CI publishes `127.0.0.1:5000/unidesk/backend-core:`, dev CD consumes it into D601 native k3s `backend-core-dev`, and prod CD consumes it into the master-server Compose `backend-core` service. Frontend is the standard UniDesk UI artifact sample: CI publishes `127.0.0.1:5000/unidesk/frontend:`, production CD consumes that artifact into the master-server Compose `frontend` service, and dev CD consumes the same artifact into D601 native k3s `frontend-dev`. Project Manager and OA Event Flow use the same master-server Compose artifact-consumer shape as Baidu Netdisk. Code Queue Manager is supported as an artifact consumer for validation, but prod live apply is supervisor-gated. Neither path may use `server rebuild frontend`, dirty source, mutable `latest`, or target-side frontend source builds as release truth. MDTODO and ClaudeQQ follow the same artifact-consumer pattern in both dev and prod, except the runtime target is native k3s on D601 instead of the master-server Compose stack. Decision Center and Todo Note are outside the D601 artifact-registry consumer: both run on NC01 k8s and store durable state in GitHub repo `pikasTech/decision-center-data`; Todo Note additionally uses `platform-infra pipelines-as-code --target NC01 --consumer unidesk-host`, digest-pinned GitOps and Argo CD. Dev `code-queue` is a deliberately narrower consumer: it validates only the `unidesk-dev` Code Queue execution slice and has no prod target. The k3s consumer must check the registry manifest, pull the commit-pinned image, import it into `/run/k3s/containerd/containerd.sock`, set the Deployment image to the commit tag, stamp `UNIDESK_DEPLOY_*` env/annotations, verify health through the Kubernetes API service proxy, and reject an old healthy revision if the live commit or requested commit does not match. Main-server direct production dry-runs for the user-service matrix must be enough to review the blast radius before any live apply. `artifact-registry deploy-service --env prod --service --commit --dry-run` is non-mutating and reports this fixed target shape: @@ -167,11 +167,10 @@ Main-server direct production dry-runs for the user-service matrix must be enoug | --- | --- | --- | --- | --- | --- | | `project-manager` | `ci publish-user-service`, UniDesk repo `src/components/microservices/project-manager/Dockerfile` | `127.0.0.1:5000/unidesk/project-manager:` | `project-manager` / `project-manager-backend` | `docker compose up -d --no-build --no-deps --force-recreate project-manager` | image labels plus `/health.deploy.commit` and `deploy.requestedCommit` | | `oa-event-flow` | `ci publish-user-service`, UniDesk repo `src/components/microservices/oa-event-flow/Dockerfile` | `127.0.0.1:5000/unidesk/oa-event-flow:` | `oa-event-flow` / `oa-event-flow-backend` | `docker compose up -d --no-build --no-deps --force-recreate oa-event-flow` | image labels plus `/health.deploy.commit` and `deploy.requestedCommit` | -| `todo-note` | `ci publish-user-service`, external `https://gitee.com/Lyon1998/todo_note` `Dockerfile` | `127.0.0.1:5000/unidesk/todo-note:` | `todo-note` / `todo-note-backend` | `docker compose up -d --no-build --no-deps --force-recreate todo-note` | image labels plus synthetic health deploy metadata from `/api/health` and `UNIDESK_TODO_NOTE_DEPLOY_*` | | `baidu-netdisk` | `ci publish-user-service`, UniDesk repo `src/components/microservices/baidu-netdisk/Dockerfile` | `127.0.0.1:5000/unidesk/baidu-netdisk:` | `baidu-netdisk` / `baidu-netdisk-backend` | `docker compose up -d --no-build --no-deps --force-recreate baidu-netdisk` | image labels, `/health.deploy.commit`, `deploy.requestedCommit`, redacted `runtimeSecrets` source contract, secret presence preflight and auth health gate on live apply | | `frontend` | `ci publish-user-service`, UniDesk repo `src/components/frontend/Dockerfile` | `127.0.0.1:5000/unidesk/frontend:` | `frontend` / `unidesk-frontend` | `docker compose up -d --no-build --no-deps --force-recreate frontend` | image labels plus `/health.deploy.commit` and `deploy.requestedCommit` | -The dry-run matrix intentionally excludes production backend-core and Code Queue execution-plane mutation. `code-queue-mgr` may be used as a read-only reference for the same dry-run output style, but its prod live apply remains supervisor-gated and its plan must state that scheduler, runner, queued task, interrupt and cancellation state are outside the target set. +The dry-run matrix intentionally excludes production backend-core, Todo Note, Decision Center and Code Queue execution-plane mutation. `code-queue-mgr` may be used as a read-only reference for the same dry-run output style, but its prod live apply remains supervisor-gated and its plan must state that scheduler, runner, queued task, interrupt and cancellation state are outside the target set. 这个 CD 路径必须满足: diff --git a/docs/reference/cicd-standardization.md b/docs/reference/cicd-standardization.md index be8c66b3..274ee10f 100644 --- a/docs/reference/cicd-standardization.md +++ b/docs/reference/cicd-standardization.md @@ -120,7 +120,8 @@ The successful `artifactSummary` must contain `serviceId`, `sourceCommit`, `sour Supported source-build artifact producers: - `backend-core` through `ci publish-backend-core`; -- `frontend`, `baidu-netdisk`, `decision-center`, `project-manager`, `oa-event-flow`, `todo-note`, `code-queue-mgr`, `findjob`, `pipeline`, `met-nonlinear`, `k3sctl-adapter`, `mdtodo`, `claudeqq` through `ci publish-user-service`; +- `frontend`, `baidu-netdisk`, `decision-center`, `project-manager`, `oa-event-flow`, `code-queue-mgr`, `findjob`, `pipeline`, `met-nonlinear`, `k3sctl-adapter`, `mdtodo`, `claudeqq` through `ci publish-user-service`; +- `todo-note` through `platform-infra pipelines-as-code --target NC01 --consumer unidesk-host`; the generic D601 publisher rejects it before remote execution; - `code-queue` through `ci publish-user-service` for dev image validation only. Cataloged but blocked: @@ -177,6 +178,7 @@ This matrix is the single review surface for the remaining D601 service lane. It | `k3sctl-adapter` | UniDesk-managed D601 direct Compose control bridge, outside the native k3s fault domain; it is the control path for k3s-managed services and must not be moved into k3s. | `CI.json` source-build supported through `ci publish-user-service --service k3sctl-adapter`; artifact is `127.0.0.1:5000/unidesk/k3sctl-adapter:` from the UniDesk Dockerfile. | Artifact consumer exposes plan/dry-run only for service/container `k3sctl-adapter`; live replacement is supervisor-only because replacing the bridge can remove the repair path for k3s. | No normal dev target. DEV acceptance is read-only bridge health, service catalog/proxy checks and dry-run contract review only. | Dry-run/read-only only in this lane. Real prod replacement requires explicit supervisor confirmation, rollback proof and out-of-band recovery access. | Must remain recoverable while k3s may be broken; worker automation must not self-replace or k3s-manage the bridge. | Write a supervised bridge-upgrade runbook with rollback and out-of-band access checks; keep CLI dry-run as the standard preflight. | | `code-queue` | Production execution plane is D601 native k3s (`unidesk` namespace) behind `k3sctl-adapter`; dev execution plane is `unidesk-dev` scheduler/read/write/provider-egress-proxy. Main-server `code-queue-mgr` is a separate control-plane sidecar. | `CI.json` source-build supported through `ci publish-user-service --service code-queue` for dev image validation only; artifact is `127.0.0.1:5000/unidesk/code-queue:`. | Reviewed dev-only k3s artifact consumer updates only `unidesk-dev` Code Queue objects. `deploy plan --env prod --service code-queue` and `artifact-registry deploy-service --env prod --service code-queue` must stay unsupported. | Allowed only as dry-run/source evidence here; a later human-approved dev live apply may consume the artifact into `unidesk-dev` outside the running Code Queue task. | Not implemented and not authorized. No production artifact deploy, manifest mutation, scheduler/runner restart, interrupt or cancel is allowed. | Production still has hostPath/source and active-run safety boundaries; self-deploy would couple the deployment actor to the target being replaced. | Keep dev dry-run coverage; design a separate supervisor-approved production CD consumer before any prod mutation is considered. | | `decision-center` | NC01 k8s user service; prod runs `unidesk/deployment/decision-center` and `unidesk/service/decision-center` selected by `config/unidesk-host-k8s.yaml`. | Source image may still be built from the UniDesk Dockerfile, but production release evidence is YAML-first NC01 k8s state plus GitHub repo storage, not D601 registry artifact consumption. | D601 k3s artifact consumer is retired for the current production path. Desired state must include NC01 target, namespace, GitHub repo `pikasTech/decision-center-data`, base path `data`, and Secret sourceRef for `decision-center-github-ssh`. | Dev gate remains focused product validation: record CRUD, diary lifecycle, doc-number uniqueness and frontend visibility. It must not require a D601 `decision-center-dev` runtime as current truth. | Prod acceptance verifies NC01 `/health`, records, diary editor, frontend page, no public business ports, GitHub storage read/write and Secret presence/fingerprint. | Product completeness and manual UI acceptance can remain open, but PostgreSQL is index/cache only and D601 artifact evidence is not storage/deploy truth. | Keep YAML-first NC01 deploy and GitHub storage checks sufficient so future desired-state edits cannot reintroduce old D601/PostgreSQL truth. | +| `todo-note` | NC01 k8s user service; prod runs private `unidesk/deployment/todo-note` and `unidesk/service/todo-note` selected by `config/unidesk-host-k8s.yaml`. | `CI.json` assigns production to `platform-infra pipelines-as-code`; consumer `unidesk-host` reuses Repository `sentinel-nc01-v03`, builds the vendored Dockerfile and publishes a digest-pinned image. | GitOps/Argo CD owns the Todo Note Service/Deployment. The full host renderer supplies shared catalog and Secret objects but skips Todo Note runtime objects to avoid dual ownership. | PaC plan, render and health checks must prove the NC01 namespace, digest pin, private ClusterIP and no Secret material in GitOps output. | Prod acceptance verifies six migrated lists, 1167 history rows, action/undo/redo writes, GitHub `todo-note/` read/write, PostgreSQL cache alignment, frontend and CLI access. | CC01 old runtime and legacy Compose metadata remain only until import and live write verification pass. | Retire CC01 and legacy `deploy.json`/Compose Todo ownership after the verified migration; keep PaC/Argo and GitHub storage as sole truth. | Minimum evidence for this lane is: @@ -230,7 +232,8 @@ This table freezes the boundary between standard artifact services, upstream ima | `server rebuild backend-core` | `bootstrap-keep` | Local main-server Compose build/recreate, serialized by Compose lock and validated after up. Docs forbid using it for Rust iteration or standard prod CD. | Recovery-only path; standard production path is D601 CI artifact plus `deploy apply --env prod --service backend-core`. | Backend-core artifact CD rollback proven under outage conditions; recovery runbook can repair a broken core without local Rust build. | Removing too early can block recovery when core or registry relay is degraded. | Keep; not removable next stage. | | `server rebuild frontend` | `bootstrap-keep` | Local main-server Compose build/recreate for production frontend; maintenance-only and not release evidence. | Standard frontend release is CI `unidesk/frontend:` plus dev/prod artifact consumer and health/image-label verification. | Equivalent pull-only repair command, registry rollback, and frontend health verification runbook. | Local dirty bundle can become accidental production truth; deleting too early removes emergency UI repair. | Keep for now; degrade only after pull-only repair is proven. | | `server rebuild baidu-netdisk` | `bootstrap-keep` | Local main-server Compose build/recreate for Baidu Netdisk; maintenance-only. | D601 CI source-build artifact plus dev/prod pull-only consumer. | Pull-only repair and token/staging persistence validation. | Dirty rebuild can hide token/staging regressions; premature deletion can block storage service repair. | Keep for now; degrade after pull-only repair is proven. | -| `server rebuild todo-note`, `code-queue-mgr`, `project-manager`, `oa-event-flow` | `bootstrap-keep` | Local main-server Compose rebuild for internal or direct main-server services; some have artifact consumer validation, while live prod apply may remain gated. | Convert each to CI artifact plus pull-only consumer and live health commit verification where supported; keep recovery entry until equivalent repair exists. | Per-service artifact consumer, runtime deploy metadata, rollback, and persistence validation. | Dirty local rebuilds can bypass `deploy.json`; deleting before rollback can break main-server service recovery. | Keep; reassess service by service. | +| `server rebuild todo-note` | `allowed-remove-later` after cutover | Legacy main-server/CC01 Compose recovery path used only until the NC01 import is verified. | NC01 PaC/Argo delivery plus GitHub-backed rollback and storage verification. | Six lists, 1167 history rows, temporary write/undo/redo/delete lifecycle, GitHub revision and PostgreSQL cache alignment are verified on NC01. | Removing before import proof can lose the only recoverable old runtime; retaining it afterward can reintroduce split-brain writes. | Remove the catalog/deploy/Compose entry and stop the old container immediately after cutover evidence passes. | +| `server rebuild code-queue-mgr`, `project-manager`, `oa-event-flow` | `bootstrap-keep` | Local main-server Compose rebuild for internal or direct main-server services; some have artifact consumer validation, while live prod apply may remain gated. | Convert each to CI artifact plus pull-only consumer and live health commit verification where supported; keep recovery entry until equivalent repair exists. | Per-service artifact consumer, runtime deploy metadata, rollback, and persistence validation. | Dirty local rebuilds can bypass `deploy.json`; deleting before rollback can break main-server service recovery. | Keep; reassess service by service. | | `server rebuild provider-gateway` | `bootstrap-keep` | Local Compose rebuild for the main-server provider gateway. | Keep as bridge repair; normal upgrades should use protected provider upgrade or artifact path when available. | Same provider-gateway recovery proof as above. | May strand host-level observability and maintenance dispatch. | Keep. | | `server rebuild filebrowser*`, `database`, `code-queue`, `k3sctl-adapter`, unknown services | `allowed-remove-later` for silent fallback only | Historically the CLI threw a generic usage error; no valid main-server Compose rebuild exists for these objects. | Structured `unsupported-server-rebuild` result; no job creation, no source build, no Compose mutation. | Operators must use documented upstream digest, k3s artifact, bridge repair, or manual-risk path instead. | Generic errors can lead operators to invent hand-built fallback commands. | Silent/generic fallback is removed; do not delete the real upstream/bootstrap runtime paths. | | `deploy apply --env prod` unsupported services | `needs-manual` | Environment-ref prod apply reads `origin/master:deploy.json`; services without reviewed prod artifact consumers return structured unsupported. | Add service-specific CI producer, D601 registry artifact, prod consumer, and live commit/digest verification before enabling. | Silent target-side builds would bypass registry, dirty-worktree, and live verification guardrails. | Not removable as a class; enable per service only after review. | @@ -251,7 +254,8 @@ This table freezes the boundary between standard artifact services, upstream ima | `artifact-registry deploy-backend-core` | Legacy compatibility entry | returns structured deprecated result; replacement is `deploy apply --env dev|prod --service backend-core` | remove after callers and docs use only `deploy apply --env dev|prod` | | `server rebuild frontend` and `server rebuild baidu-netdisk` | Maintenance / non-standard | docs classify as maintenance-only; standard release requires CI artifact plus dev/prod artifact consumer | keep until recovery runbooks have equivalent pull-only repair commands | | `server rebuild backend-core` | Diagnostic/recovery only; not Rust iteration or prod CD | docs forbid Rust iteration and prod backend-core artifact CD through this command | keep for bootstrap/recovery until backend-core artifact CD and rollback are proven under outage conditions | -| `server rebuild dev-frontend-proxy`, `code-queue-mgr`, `project-manager`, `oa-event-flow`, `todo-note`, `provider-gateway` on main-server Compose | Bootstrap / recovery / diagnostic | still serialized through Compose lock and post-up validation | reassess service by service after artifact consumers exist | +| `server rebuild dev-frontend-proxy`, `code-queue-mgr`, `project-manager`, `oa-event-flow`, `provider-gateway` on main-server Compose | Bootstrap / recovery / diagnostic | still serialized through Compose lock and post-up validation | reassess service by service after artifact consumers exist | +| `server rebuild todo-note` on the legacy runtime | Migration-only, then remove | allowed only until NC01 import and GitHub read/write verification complete | remove after cutover evidence and stop the old runtime to prevent split-brain writes | | `provider.upgrade mode=schedule` for provider-gateway | Must be retained | protected upgrade path with validation; Host SSH self-rebuild remains forbidden | do not delete; it is the provider-gateway recovery path | | D601 direct `docker build`, `kubectl apply`, `docker compose up --build` used by deploy executor for native k3s setup or approved recovery | Bootstrap / recovery | limited to native k3s initialization or documented recovery; dev backend-core CD is now artifact-only; Code Queue prod is not enabled | convert only after artifact consumers or native bootstrap replacements exist | | D601 direct Code Queue / old `codex deploy` | Must stay disabled/degraded | compatibility command throws; docs classify direct deployment as forbidden | wait for controlled Code Queue dev/prod CD worker | @@ -315,7 +319,7 @@ This matrix describes the next promotion stage after dry-run coverage is in plac | `baidu-netdisk` | `master` | source-build supported | dev + prod artifact consumer | pull-only dev validation is diagnosable through `runtimeSecrets.secretSource`, `requiredSecretsPresent`, `missingSecretKeys` and `recommendedAction`; live dev apply waits for the secret source condition | prod artifact and health are aligned; further prod action is only focused post-apply proxy/auth verification after operator approval | canonical Compose env secret source and `/health.auth` gate; no manual secret operation in worker tasks | `GPT-5.5` | | `project-manager` | `master` | source-build supported | dev + prod artifact consumer | dev artifact validation with `/api/projects` | prod artifact validation with live commit proof | none beyond standard artifact/CD checks | `MiniMax` for dry-run/reporting, `GPT-5.5` for release sign-off | | `oa-event-flow` | `master` | source-build supported | dev + prod artifact consumer | dev artifact validation with `/api/diagnostics` | prod artifact validation with live commit proof | none beyond standard artifact/CD checks | `MiniMax` for dry-run/reporting, `GPT-5.5` for release sign-off | -| `todo-note` | `master` | external source-build supported | dev + prod Compose artifact consumer | consumer dry-run is ready; live dev remains blocked until the desired artifact exists | prod behavior is healthy, but runtime commit proof is absent until the no-build recreate lands image labels and health deploy metadata | registry artifact, runtime commit proof and health deploy metadata | `DeepSeek` for digesting external-source evidence, `GPT-5.5` for final gate | +| `todo-note` | `master` | vendored source build owned by NC01 `platform-infra pipelines-as-code` | GitOps/Argo CD consumes a digest-pinned image into `unidesk`; durable state is GitHub `pikasTech/decision-center-data/todo-note` | PaC plan/render proves private Service, digest pin, Secret-free GitOps output and shared Repository parameter isolation | NC01 health, six-list/1167-history migration, action/undo/redo write lifecycle, GitHub verify, PostgreSQL cache, frontend and CLI proof | cutover must finish before removing CC01; dual runtime writes are forbidden | `GPT-5.5` | | `decision-center` | `master` | source-build supported | NC01 YAML-first k8s + GitHub repo storage | dev acceptance is focused record CRUD, diary lifecycle, doc-number uniqueness and frontend visibility, without D601 runtime as current truth | prod acceptance is NC01 health, GitHub read/write, frontend and CLI proof | doc-management completeness, GitHub storage migration/backfill and UI acceptance; PostgreSQL is index/cache only | `GPT-5.5` | | `mdtodo` | `master` | source-build supported | dev + prod k3s consumer | dev service is absent until the health-metadata-capable desired artifact is published and `unidesk-dev/mdtodo-dev` is created/verified | prod is healthy at the previous annotated commit, but the desired artifact now points at the commit that added `/health.deploy` and `/live.deploy`; prod replacement remains blocked until dev proof exists | registry artifact, dev service, runtime health metadata proof and prod runtime commit drift; no NodePort/hostPort/public backend exposure | `MiniMax` for prompt prep, `GPT-5.5` for approval | | `claudeqq` | `master` | source-build supported | dev + prod k3s consumer | dev service is absent until the desired artifact is published and `unidesk-dev/claudeqq-dev` is created/verified | prod health reports the desired commit and NapCat login, but artifact and event API proof remain open | registry artifact, dev service, event API surface; NapCat/backend port exposure must stay private | `MiniMax` for prompt prep, `GPT-5.5` for approval | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8a4fbb95..b4b8716c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -62,7 +62,7 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。实现必须只读取文件末尾字节,不得为了 tail 先把巨大日志完整读入 CLI 内存。 - `server cleanup plan|run --confirm [--min-age-hours N] [--limit N]` 是主 server Docker 镜像高水位治理入口。`plan` 生成 dry-run 计划,不执行删除;`run --confirm` 只删除同一 classifier 选出的 stale Docker images,高风险候选必须额外 `--include-high-risk` 才会执行。默认 `--min-age-hours 24`,避免把刚发布或刚验证的镜像列为 stale。输出必须包含 active containers/images、受保护镜像、candidate stale images、估算释放空间、风险等级、执行/跳过结果和人工审批线索。计划必须保守白名单:保留 running containers 使用的 image ID,保留 stopped containers 引用的 image ID 直到人工先复核容器,保留 `deploy.json`/`CI.json` 当前 commit-pinned artifact、Compose stable image、上游 digest pin 和 provider-gateway runner image;`protectedStorage` 必须显式列出 PostgreSQL named volume、Baidu Netdisk `.state`、D601 registry storage 和 Docker volumes/host data policy。该入口禁止 `docker system prune`、`docker image prune`、`docker builder prune`、`docker volume rm`、`docker compose down -v`、数据库清理或 host data `rm` 命令。 - `gc plan|run --confirm|db-trace|policy|remote` 是主 server 和受控 provider 的磁盘高水位一次性缓解与长期防膨胀入口。`plan` 只读输出候选、风险、估算收益和保护对象;`run` 必须显式 `--confirm`;`gc remote ...` 通过 UniDesk SSH 透传执行远端 GC,`--target-use-percent N` 会在 `summary.target` 中报告目标水位所需释放量、候选估算、预计水位、缺口和 safe-stop 决策。默认只包含 allowlisted `/tmp` 诊断目录;非 allowlist stale `/tmp` 直接子项必须显式 `--include-stale-tmp`,并只允许删除 `/tmp` 一级子项且避开系统 socket/session 前缀。G14/HWLAB registry retention、受限 core dump、保护对象、safe-stop 线和长期收益表的权威规则见 `docs/reference/gc.md`。 -- `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 和 `oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 +- `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`。`todo-note` 仅是 CC01/旧 Compose 迁移回退入口,NC01 导入和 GitHub 写验证完成后必须从该 allowlist 删除;正式 Todo Note 发布走 PaC/Argo。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 - `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL。`plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL;`apply --confirm` 默认创建本地异步 job,`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。输出不得打印密码或完整 `DATABASE_URL`。跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 - `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601` 或 `G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows route 中 `win` 只负责定位,operation 直接写 `ps`、`cmd`、`git` 或 fs helper,不要写成 `trans D601:win/... win ps`。Windows operation 必须显式区分:`ps` 执行 Windows PowerShell heredoc 或一行 PowerShell 命令,`cmd` 执行 cmd.exe/batch,`skills` 发现 Windows skill 目录。需要 Windows cwd 时用 `trans D601:win/c/test ps`、`trans D601:win/c/test cmd cd`、`trans D601:win/c/test git diff --check` 或 `trans D601:win/c/test git commit -m 'message'`,CLI 自动设置 UTF-8/Python 编码默认值;`cmd` 额外设置 `chcp 65001`。非交互远端命令优先使用 `trans argv ...`;需要 POSIX shell 脚本、管道、变量或循环时必须在 operation 位置显式写 `sh` 或 `bash`,例如 `trans G14 sh <<'SH'`、`trans G14:k3s sh <<'SH'`、`trans G14:k3s:: sh <<'SH'` 或 `trans D601:/workspace bash <<'BASH'`。`sh` 明确表示目标 `/bin/sh`,`bash` 明确表示目标 Bash;`script` 和 `shell` operation 已移除并会失败,避免隐藏 shell 方言。Windows PowerShell 必须写 `trans :win ps <<'PS'`。一行远端 shell 逻辑使用 `sh -- '<单个字符串>'` 或 `bash -- '<单个字符串>'`;顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 ` apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 ` upload ` 和 ` download `,CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `:k3s:: apply-patch-v1` 或 ` apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示改用 `sh`/`bash` stdin heredoc、argv 或 provider triage 交叉验证。 @@ -161,9 +161,9 @@ UniDesk/HWLAB Web 开发、HWLAB `web-probe run|script|observe|screenshot`、fak 定点状态查询优先使用后台 job 输出的稳定对象名:PipelineRun 用 `hwlab g14 control-plane status --lane v02 --pipeline-run `,已知 source commit 用 `--source-commit `。不要在 source branch 可能继续推进时用默认最新 head status 判定历史 run 成败;默认最新 head 口径只适合判断当前 lane 是否整体最新。`control-plane status` 会汇总 source、mirror、Tekton、Argo、runtime workload 和公网探针,可能比普通只读命令慢;高频轮询应先用 `job status` 或更窄的 status,完整 status 留给阶段收口和异常定位。 -`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 只保留为维护/非标准路径;标准 frontend 发布必须先运行 `ci publish-user-service --service frontend --commit `,再运行 `deploy apply --env dev --service frontend` 和 `deploy apply --env prod --service frontend`,并验证 `/health.deploy.commit`。重建 dev 入口薄代理使用 `bun scripts/cli.ts server rebuild dev-frontend-proxy`,随后验证 `server status` 的 `urls.devFrontend` 和 `http://127.0.0.1:18083/health`;重建 Todo Note 后端使用 `bun scripts/cli.ts server rebuild todo-note`,随后用 `microservice health todo-note` 和 `microservice proxy todo-note /api/instances` 验证;重建 Code Queue Manager 使用 `bun scripts/cli.ts server rebuild code-queue-mgr`,随后用 `microservice health code-queue-mgr`、`microservice health code-queue`、`codex tasks --view commander --limit 5` 和冻结的 `codex submit` 返回 `legacy-code-queue-frozen` 验证主 server 控制面路径;重建 Project Manager 后端使用 `bun scripts/cli.ts server rebuild project-manager`,随后用 `microservice health project-manager` 和 `microservice proxy project-manager /api/projects` 验证;重建 Baidu Netdisk 后端使用 `bun scripts/cli.ts server rebuild baidu-netdisk`,随后用 `microservice health baidu-netdisk` 和 `microservice proxy baidu-netdisk /api/transfers` 验证,但该命令只保留为维护/非标准路径;重建 OA Event Flow 后端使用 `bun scripts/cli.ts server rebuild oa-event-flow`,随后用 `microservice health oa-event-flow` 和 `microservice proxy oa-event-flow /api/diagnostics` 验证。D601 Code Queue 执行面由 D601 k3s/k8s 控制面代管;Decision Center 由 NC01 k8s 代管并写入 GitHub repo storage;persistent dev backend-core/frontend 仍走 artifact consumer,当前 Code Queue 仍不得通过维护通道直连 D601 做部署。不得把 `docker rm` 手工兜底当成正式交付步骤。 +`server rebuild` 与 `server start`、`server stop` 一样必须通过返回的 job id 确认结果;不要把连续 `server rebuild` 命令理解成“前一个重建已完成”,因为两个命令只是在快速创建异步 job。重建 frontend 只保留为维护/非标准路径;标准 frontend 发布必须先运行 `ci publish-user-service --service frontend --commit `,再运行 `deploy apply --env dev --service frontend` 和 `deploy apply --env prod --service frontend`,并验证 `/health.deploy.commit`。重建 dev 入口薄代理使用 `bun scripts/cli.ts server rebuild dev-frontend-proxy`。Todo Note 正式发布使用 `platform-infra pipelines-as-code --target NC01 --consumer unidesk-host`;`server rebuild todo-note` 只在迁移验收前用于旧运行面回退。重建 Code Queue Manager、Project Manager、Baidu Netdisk 和 OA Event Flow 后端仍分别使用各自 `server rebuild` 与 `microservice health/proxy` 验证。D601 Code Queue 执行面由 D601 k3s/k8s 控制面代管;Decision Center 与 Todo Note 由 NC01 k8s 代管并写入同一个 GitHub repo 的独立 base path;persistent dev backend-core/frontend 仍走 artifact consumer,当前 Code Queue 仍不得通过维护通道直连 D601 做部署。不得把 `docker rm` 手工兜底当成正式交付步骤。 -新部署入口优先使用受控 deploy path。`deploy apply --env dev --service backend-core` 是 D601 k3s artifact consumer,消费 `ci publish-backend-core` 产出的成品镜像;`deploy apply --env dev|prod --service frontend` 和 `deploy apply --env dev|prod --service baidu-netdisk` 是 artifact-consumer 样板;Decision Center 当前样板是 NC01 YAML-first k8s + GitHub repo storage;旧的 `codex deploy` 已禁用;后续 Code Queue 等 D601 服务部署应另行收敛到同一类受控 CD 路径,部署后用 live commit 校验证明不是旧服务。 +新部署入口优先使用受控 deploy path。`deploy apply --env dev --service backend-core` 是 D601 k3s artifact consumer,消费 `ci publish-backend-core` 产出的成品镜像;`deploy apply --env dev|prod --service frontend` 和 `deploy apply --env dev|prod --service baidu-netdisk` 是 artifact-consumer 样板;Decision Center 是 NC01 YAML-first + GitHub storage 样板,Todo Note 是 NC01 PaC/Argo + 同仓独立 base path 样板;旧的 `codex deploy` 已禁用。部署后必须用 live commit/digest 与存储验证证明不是旧服务。 ## Output Contract @@ -196,7 +196,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/instances//redo --metho 看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时**第一反应**是路径错(用了不存在的 REST 端点被 catch-all 兜底),不是 mode 锁了写;详见 `docs/reference/microservices.md` 的 Todo Note 段。 -**Todo Note 错路径结构化诊断(issue #198)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all handler 当前把任何未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。backend-core 的 `/api/microservices/todo-note/proxy/...` 同源代理看到这个特征时会把响应 body 改写成结构化诊断;CLI 侧保留相同改写作为本地/旧 runtime 兜底: +**Todo Note 错路径结构化诊断(issue #198)**:旧 Gitee/CC01 runtime 的 catch-all handler 会把未注册路径回成 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。当前 vendored NC01 服务直接返回明确 route-not-found;backend-core/CLI 仍保留旧特征改写作为迁移兼容兜底: ```json { diff --git a/docs/reference/config.md b/docs/reference/config.md index 3f0b5440..937ce020 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -24,9 +24,9 @@ TypeScript 运行时固定为 Bun for CLI, frontend, provider-gateway and TypeSc ## User Services -`microservices` 定义挂载在计算节点或主 server Docker 中的非核心用户服务。用户服务是挂在 UniDesk 核心服务上的用户业务能力,缺少这些服务时 UniDesk 核心仍必须能运行。该数组只保存业务仓库 URL、commit id、业务仓库自身 Dockerfile/docker-compose 引用、provider 映射、节点后端端口和 UniDesk frontend 集成入口;不得把业务全量代码复制进 UniDesk。`backend.public` 必须为 `false`,`backend.frontendOnly` 必须为 `true`,`backend.allowedPathPrefixes` 必须限制到业务 API 前缀,`backend.allowedMethods` 必须显式列出允许代理的 HTTP 方法;浏览器只能通过 frontend 同源代理访问这些后端。详细规则见 `docs/reference/microservices.md`。 +`microservices` 定义挂载在计算节点或主 server Docker 中的非核心用户服务。用户服务是挂在 UniDesk 核心服务上的用户业务能力,缺少这些服务时 UniDesk 核心仍必须能运行。该数组保存外部业务仓库引用、provider 映射、节点后端端口和 UniDesk frontend 集成入口;NC01 host-k8s 用户服务改由 `config/unidesk-host-k8s.yaml` 声明并渲染到 backend-core catalog。`backend.public` 必须为 `false`,`backend.frontendOnly` 必须为 `true`,`backend.allowedPathPrefixes` 必须限制到业务 API 前缀,`backend.allowedMethods` 必须显式列出允许代理的 HTTP 方法;浏览器只能通过 frontend 同源代理访问这些后端。详细规则见 `docs/reference/microservices.md`。 -主 server 承载的 Todo Note 用户服务使用 `providerId=main-server`、`nodeBaseUrl=http://todo-note:4211` 和 `allowedMethods=["GET","HEAD","POST","DELETE"]`,数据库使用主 PostgreSQL;Code Queue 使用 `deployment.mode=k3sctl-managed`、`backend.proxyMode=k3sctl-adapter-http` 和 `nodeBaseUrl=k3s://code-queue`,只允许通过 frontend/backend-core -> `k3sctl-adapter` -> Kubernetes API service proxy -> k3s/k8s Service 单一路径访问,不能配置业务容器直连、NodePort 或 provider-gateway direct path;D601 的 FindJob 只允许 `GET/HEAD` 展示型读取路径;D601 的 Pipeline 允许 `GET/HEAD/POST`,其中 `POST` 只用于 Pipeline 后端 `/api/node-control/...` 的 append prompt、guide 和 redo/restart 等受控 node 操作。 +Todo Note 的正式配置位于 `config/unidesk-host-k8s.yaml`:backend-core 访问 NC01 私有 ClusterIP `todo-note:4288`,允许 `GET/HEAD/POST/DELETE`,GitHub `pikasTech/decision-center-data/todo-note` 是权威存储,PostgreSQL 是 cache。旧 `config.json` Todo 条目只在 CC01 迁移回退期存在,验收后必须删除。Code Queue 使用 `deployment.mode=k3sctl-managed`、`backend.proxyMode=k3sctl-adapter-http` 和 `nodeBaseUrl=k3s://code-queue`,只允许通过 frontend/backend-core -> `k3sctl-adapter` -> Kubernetes API service proxy -> k3s/k8s Service 单一路径访问,不能配置业务容器直连、NodePort 或 provider-gateway direct path;D601 的 FindJob 只允许 `GET/HEAD` 展示型读取路径;D601 的 Pipeline 允许 `GET/HEAD/POST`,其中 `POST` 只用于 Pipeline 后端 `/api/node-control/...` 的 append prompt、guide 和 redo/restart 等受控 node 操作。 ## Compose Env Generation diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 5ea97bfb..5e6f6599 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -52,17 +52,17 @@ The root `deploy.json` is the single desired-state source for both prod and dev. The optional non-service execution declaration under `environments.dev` is intentionally not specified here. The only currently allowed declaration is `ci`, and its authoritative `repo`, `scriptPath`, `timeoutMs`, short launcher, host fetch boundary and no-CD rules are defined only in `docs/reference/dev-ci-runner.md`. -Environment mode never reads the local dirty working tree manifest. `deploy check --env ...`, `deploy plan --env ...` and `deploy apply --env ...` fetch `origin/master`, read `origin/master:deploy.json`, select `environments.`, and report the manifest commit/blob, service commit IDs, target namespace, database fingerprint and Provider identity. `deploy apply --env dev` is currently enabled for reviewed artifact consumers `backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq`, dev-only `code-queue`, `project-manager`, `oa-event-flow`, `code-queue-mgr`, `todo-note`, `findjob`, `pipeline` and `met-nonlinear`. `deploy apply --env prod` exposes reviewed registry artifact consumers (`backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq`, `project-manager`, `oa-event-flow`, `todo-note`, `findjob`, `pipeline` and `met-nonlinear`), while Decision Center uses the NC01 YAML-first k8s path and GitHub storage checks, `code-queue` must report unsupported, `code-queue-mgr` remains supervisor-gated and `k3sctl-adapter` is plan/dry-run only. Backend-core artifact CD is a pull-only consumer in both dev and prod; the build target is D601 CI, while dev runtime target is D601 native k3s and prod runtime target is the master server Compose stack. The default user-service delivery policy, including CI build, registry publication, dev validation, production CD and manual acceptance, is documented in `docs/reference/user-service-delivery.md`. +Environment mode never reads the local dirty working tree manifest. `deploy check --env ...`, `deploy plan --env ...` and `deploy apply --env ...` fetch `origin/master`, read `origin/master:deploy.json`, select `environments.`, and report the manifest commit/blob, service commit IDs, target namespace, database fingerprint and Provider identity. `deploy apply --env dev` is currently enabled for reviewed artifact consumers `backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq`, dev-only `code-queue`, `project-manager`, `oa-event-flow`, `code-queue-mgr`, `findjob`, `pipeline` and `met-nonlinear`. `deploy apply --env prod` exposes reviewed registry artifact consumers (`backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq`, `project-manager`, `oa-event-flow`, `findjob`, `pipeline` and `met-nonlinear`), while Decision Center and Todo Note use NC01 YAML-first k8s plus GitHub storage checks. Todo Note delivery is owned by `platform-infra pipelines-as-code --target NC01 --consumer unidesk-host` and Argo CD, not this deploy reconciler. `code-queue` must report unsupported, `code-queue-mgr` remains supervisor-gated and `k3sctl-adapter` is plan/dry-run only. Backend-core artifact CD is a pull-only consumer in both dev and prod; the build target is D601 CI, while dev runtime target is D601 native k3s and prod runtime target is the master server Compose stack. The default user-service delivery policy, including CI build, registry publication, dev validation, production CD and manual acceptance, is documented in `docs/reference/user-service-delivery.md`. `--commit ` is allowed only with `--env dev|prod --service ` for reviewed artifact consumers. It overrides the selected service commit for that one artifact consumer while still using the Git-backed environment manifest for target, namespace, repo, deploy ref and guardrails. It is the supported temporary shape for release-line frontend/backend-core validation and rollback when the artifact was produced from a pushed `release/v1` commit but `origin/master:deploy.json#environments..services..commitId` has not been repinned. It must not be used for local-manifest mode, multi-service apply, or target-side source-build services. -For services with reviewed production artifact consumers, local-manifest `deploy apply --file ...` is not a production fallback. The CLI blocks `backend-core`, `frontend`, `baidu-netdisk`, `decision-center`, `mdtodo`, `claudeqq` and other reviewed pull-only consumers before source materialization or Docker build and directs operators to `deploy apply --env prod --service --commit `. This prevents a dirty worktree, local manifest or target-side source build from bypassing the pull-only artifact CD guardrails. The broader precheck and legacy-path classification live in `docs/reference/cicd-standardization.md`. +For services with reviewed production artifact consumers, local-manifest `deploy apply --file ...` is not a production fallback. The CLI blocks `backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq` and other reviewed pull-only consumers before source materialization or Docker build and directs operators to `deploy apply --env prod --service --commit `. Decision Center and Todo Note are outside this reconciler and must use their NC01 YAML-first controlled paths. This prevents a dirty worktree, local manifest or target-side source build from bypassing the pull-only artifact CD guardrails. The broader precheck and legacy-path classification live in `docs/reference/cicd-standardization.md`. The current implementation has not yet enabled separate stable and integration dev lanes. Future lane names such as `dev-v1` and `dev-master`, or an equivalent nested schema, must be added as explicit `deploy.json` and CLI semantics before use. A deploy command must print the manifest ref it used and must not infer `release/v1` from a local branch, a dirty file, or an undocumented environment alias. For frontend-only release-line validation, the current bridge is an explicit commit override on the reviewed artifact consumer: first publish the artifact with `ci publish-user-service --service frontend --commit `, then run `deploy apply --env dev --service frontend --commit `; production uses the same shape with `--env prod` only after dev evidence is accepted. CI/CD server and control-plane services are normal deployable services for versioning purposes: production runtime must be pinned by `deploy.json` to a known commit. A CLI built from `master` may orchestrate the pinned server only through backward-compatible APIs and server-reported capabilities; it must not bypass server-side deploy policy when the pinned server does not support a requested operation. -The only D601 direct-service exception in local manifest mode is `k3sctl-adapter`, because it is the UniDesk-managed control bridge outside the k3s fault domain and owns the Kubernetes service catalog used by the dev public frontend path. Its artifact consumer path is plan/dry-run only and never performs real prod deployment without supervisor confirmation. D601 Code Queue, MDTODO, ClaudeQQ and future k3s-managed workloads remain blocked from maintenance-channel direct deploy. Decision Center is also blocked from maintenance-channel direct deploy, but its current runtime lane is NC01 k8s rather than D601. +The only D601 direct-service exception in local manifest mode is `k3sctl-adapter`, because it is the UniDesk-managed control bridge outside the k3s fault domain and owns the Kubernetes service catalog used by the dev public frontend path. Its artifact consumer path is plan/dry-run only and never performs real prod deployment without supervisor confirmation. D601 Code Queue, MDTODO, ClaudeQQ and future k3s-managed workloads remain blocked from maintenance-channel direct deploy. Decision Center and Todo Note are also blocked from maintenance-channel direct deploy; their current runtime lane is NC01 k8s rather than D601. `config.json.microservices[].repository.commitId` is retained for catalog compatibility, but `deploy.json` is the deployment version authority for the reconciler. @@ -99,7 +99,7 @@ Phase 3 introduces the dev backend/frontend manifest at `src/components/microser `backend-core-dev` must use `unidesk-dev-runtime-config` and `unidesk-dev-runtime-secrets`, connect to `postgres-dev.../unidesk_dev`, expose HTTP on 8080 and provider ingress on 8081, and write logs under `/var/log/unidesk-dev`. `frontend-dev` must set `CORE_INTERNAL_URL=http://backend-core-dev.unidesk-dev.svc.cluster.local:8080` and must not proxy to production backend-core. -The manifest keeps placeholder image tags and deploy commit values in source control. The controlled `deploy apply --env dev --service backend-core` path consumes the existing D601 registry artifact `127.0.0.1:5000/unidesk/backend-core:` produced by `ci publish-backend-core`; it does not compile Rust or build a Docker image during CD. Backend-core and frontend use the same selected dev core manifest objects: CD verifies the commit-pinned registry image and labels, imports the artifact into native k3s containerd, applies only the selected `unidesk-dev` objects, stamps the Deployment, and verifies live commit/requestedCommit through the Kubernetes API service proxy. MDTODO and ClaudeQQ use the same dev namespace and D601 registry artifact consumer path. Decision Center is governed by the NC01 YAML-first k8s lane and GitHub repo storage checks instead of this D601 dev namespace. `project-manager`, `oa-event-flow`, `code-queue-mgr`, `todo-note`, `findjob`, `pipeline` and `met-nonlinear` consume existing D601 registry artifacts for direct Docker/Compose validation rather than separate parallel k3s dev instances; `code-queue-mgr` live prod apply remains supervisor-gated. Client dry-run and static validation remain useful checks before controlled apply: +The manifest keeps placeholder image tags and deploy commit values in source control. The controlled `deploy apply --env dev --service backend-core` path consumes the existing D601 registry artifact `127.0.0.1:5000/unidesk/backend-core:` produced by `ci publish-backend-core`; it does not compile Rust or build a Docker image during CD. Backend-core and frontend use the same selected dev core manifest objects: CD verifies the commit-pinned registry image and labels, imports the artifact into native k3s containerd, applies only the selected `unidesk-dev` objects, stamps the Deployment, and verifies live commit/requestedCommit through the Kubernetes API service proxy. MDTODO and ClaudeQQ use the same dev namespace and D601 registry artifact consumer path. Decision Center and Todo Note are governed by NC01 YAML-first k8s lanes and GitHub repo storage checks instead of this D601 dev namespace; Todo Note additionally uses the NC01 `unidesk-host` PaC/Argo lane. `project-manager`, `oa-event-flow`, `code-queue-mgr`, `findjob`, `pipeline` and `met-nonlinear` consume existing D601 registry artifacts for direct Docker/Compose validation rather than separate parallel k3s dev instances; `code-queue-mgr` live prod apply remains supervisor-gated. Client dry-run and static validation remain useful checks before controlled apply: - `bun scripts/cli.ts dev-env validate --manifest src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` - `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply --dry-run=client --validate=false -f src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` @@ -128,7 +128,7 @@ Environment plan output must be sufficient to review the artifact matrix without For `--env dev --service code-queue`, the environment plan must also expose a `boundary` block that separates the CI producer from the dev CD consumer. CI is allowed to publish only `127.0.0.1:5000/unidesk/code-queue:` plus digest/label evidence. DEV CD may consume that artifact only for `unidesk-dev` Code Queue scheduler/read/write/provider-egress-proxy objects after an operator reviews the dry-run; the plan must set `artifactConsumer.dryRunOnly=true`, `liveApply.allowed=false`, `requiresSupervisorApproval=true`, and expose a `selfBootstrapGuard` so a running Code Queue task cannot authorize its own replacement. For `--env prod --service code-queue`, the service item must remain `deploymentPath=unsupported`, `artifactConsumer.consumerKind=unsupported`, `target.deployCommandShape=none` and `liveApply.allowed=false`; it must not expose production k3s as an executable target. The prod boundary must state that production Code Queue CD needs a future supervisor-approved design and that this runner cannot self-deploy, mutate the production namespace, restart scheduler/runner, or interrupt/cancel tasks. -`bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service ] [--commit ] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` redeploys even when the live commit matches. Environment apply is not the dev e2e trigger; use `bun scripts/cli.ts ci run-dev-e2e` for the Git-controlled temporary namespace smoke flow. `--env dev` apply is enabled for `backend-core`/`frontend`/`baidu-netdisk`/`mdtodo`/`claudeqq`/dev-only `code-queue` dry-run/authorized apply, `project-manager`/`oa-event-flow`/`code-queue-mgr`/`todo-note`/`findjob`/`pipeline`/`met-nonlinear` artifact consumers. `--env prod` apply exposes the D601 registry artifact consumer for `backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq`, `project-manager`, `oa-event-flow`, `todo-note`, `findjob`, `pipeline` and `met-nonlinear`; Decision Center production uses NC01 YAML-first k8s + GitHub storage, `code-queue-mgr` prod live apply is supervisor-gated and `k3sctl-adapter` is plan/dry-run only. `--commit` may override one selected reviewed artifact consumer in either dev or prod, for example `deploy apply --env dev --service backend-core --commit ` or `deploy apply --env dev --service frontend --commit `, and the image must already exist as `127.0.0.1:5000/unidesk/:`. Unsupported prod services, especially `code-queue`, return a structured `unsupported` payload instead of silently falling back to a maintenance-channel source build. +`bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service ] [--commit ] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` redeploys even when the live commit matches. Environment apply is not the dev e2e trigger; use `bun scripts/cli.ts ci run-dev-e2e` for the Git-controlled temporary namespace smoke flow. `--env dev` apply is enabled for `backend-core`/`frontend`/`baidu-netdisk`/`mdtodo`/`claudeqq`/dev-only `code-queue` dry-run/authorized apply, `project-manager`/`oa-event-flow`/`code-queue-mgr`/`findjob`/`pipeline`/`met-nonlinear` artifact consumers. `--env prod` apply exposes the D601 registry artifact consumer for `backend-core`, `frontend`, `baidu-netdisk`, `mdtodo`, `claudeqq`, `project-manager`, `oa-event-flow`, `findjob`, `pipeline` and `met-nonlinear`; Decision Center and Todo Note production use NC01 YAML-first k8s + GitHub storage, with Todo Note delivered through `platform-infra pipelines-as-code` and Argo CD. `code-queue-mgr` prod live apply is supervisor-gated and `k3sctl-adapter` is plan/dry-run only. `--commit` may override one selected reviewed artifact consumer in either dev or prod, for example `deploy apply --env dev --service backend-core --commit ` or `deploy apply --env dev --service frontend --commit `, and the image must already exist as `127.0.0.1:5000/unidesk/:`. Unsupported prod services, especially `code-queue`, return a structured `unsupported` payload instead of silently falling back to a maintenance-channel source build. All deploy commands output JSON. Long operations must use `.state/jobs/` and bounded log tails; no deploy path may succeed with missing progress output. diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 4f9dff6e..fc3e5c9c 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -11,7 +11,7 @@ Production runtime and CI/CD control-plane versioning follow `docs/reference/rel - `frontend` 是 production 公开 Web 控制台,提供登录、从 TSX 转译出的 React 应用资产和到 backend-core 的同源代理。 - `dev-frontend-proxy` 是主 server 上的 18083 dev UI 薄入口,代理到 D601 `unidesk-dev/frontend-dev`,详细边界见 `docs/reference/dev-environment.md`。 - `provider-gateway` 是当前主 server 的本机计算节点代理,通过 WebSocket 主动连到 provider ingress,挂载 `/var/run/docker.sock` 作为自动任务执行主路径,使用 `pid: "host"` 读取节点级进程资源,并周期性上报系统资源指标、进程占用与 Docker daemon 状态;计算节点 provider-gateway 还必须把 egress proxy 仅发布到宿主 loopback `127.0.0.1:18789` 供本节点执行环境出网,维护用 Host SSH / WSL SSH 私钥目录只读挂载到 `/run/host-ssh`,不得作为自动任务调度主路径。 -- `todo-note` 是主 server 承载的 Todo Note 纯后端用户服务,容器名 `todo-note-backend`,只在 Compose 内网暴露 `4211/tcp`,使用主 PostgreSQL 存储迁移后的 Todo Note 数据。 +- `todo-note` 是 NC01 k8s 承载的纯后端用户服务,运行对象为 `unidesk/deployment/todo-note` 和 `unidesk/service/todo-note`,只暴露 ClusterIP `4288/tcp`;GitHub `pikasTech/decision-center-data/todo-note` 是权威存储,PostgreSQL 是索引/cache。 - `code-queue-mgr` 是主 server 承载的轻量 Code Queue 控制面用户服务,容器名 `code-queue-mgr-backend`,只在 Compose 内网暴露 `4278/tcp`,只连接主 PostgreSQL,负责 `code-queue` 稳定代理路径下的队列管理、任务提交、历史摘要、已读状态和轻量 Trace 读取;不得引入 Codex/OpenCode/Playwright/Chromium/Docker socket/runner 依赖。生产镜像是 Rust slim runtime,Compose healthcheck 必须调用镜像内 `code-queue-mgr --healthcheck` 或等价 Rust 探针,不得依赖 Bun runtime。 - `k3sctl-adapter` 是 D601 上由 UniDesk 直管的 k3s 控制面适配微服务,容器名 `k3sctl-adapter`,只绑定 `127.0.0.1:4266`,由 UniDesk frontend/backend-core 通过用户服务代理访问并提供 `/api/control-plane` 可见性。 - `code-queue` 是稳定对外用户服务 ID。backend-core 对 `/api/microservices/code-queue/proxy/...` 做内部职责分流:控制/读取路径默认转到主 server `code-queue-mgr`,D601 k3s `code-queue-read`/`code-queue-write`/`code-queue-scheduler` 只承担执行面兼容、scheduler/runner、dev-container、judge 和 running task control。D601 执行面通过 `src/components/microservices/k3sctl-adapter/k3s/code-queue.k3s.json` 声明,运行对象通过 `code-queue.k8s.yaml` 创建 Kubernetes Deployment/ClusterIP Service;任务、queue、未读状态、控制状态和通知 outbox 一律写入主 PostgreSQL,不保留本地状态文件 fallback。 @@ -30,7 +30,7 @@ CLI 会优先使用 `docker compose` v2 plugin;当 v2 plugin 不存在时才 Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生命周期用 `server start` / `server stop`,单服务重建用 `server rebuild `。不要因为 v2 可用就直接在生产栈上手工执行未纳入 CLI 的 `up --build`、`down -v` 或跨项目清理命令;所有会影响容器的动作都应保持 job 可观测、Compose project 固定、database named volume 保留。主 server Compose 命令必须从 `providerGateway.upgrade.hostProjectRoot` 指定的 canonical UniDesk 根目录运行,临时 worktree、Code Queue 导出目录或实验分支不得复用生产 `-p unidesk` 和固定 `container_name` 去替换生产容器。 -版本化用户服务部署优先使用受控路径;D601 persistent dev apply 当前支持 `backend-core`/`frontend`/`baidu-netdisk`/`mdtodo`/`claudeqq` artifact consumer、dev-only `code-queue` artifact consumer,以及 `project-manager`、`oa-event-flow`、`code-queue-mgr`、`todo-note`、`findjob`、`pipeline` 和 `met-nonlinear` 的 artifact consumer validation,dev desired-state smoke 使用 `ci run-dev-e2e`。Decision Center 是当前例外:生产部署和存储 truth 已迁移到 NC01 YAML-first k8s + GitHub repo storage,不再把 D601 artifact consumer 作为 closeout。`deploy.json` 只声明服务 `id`、`repo` 和 `commitId`;目标节点、Dockerfile、Compose、Kubernetes manifest、健康检查和代理路径继续来自 `config.json` 与现有 manifest。主 server 直管微服务和内部 sidecar,例如 `code-queue-mgr`,也必须支持这一路径:`deploy apply --service code-queue-mgr` 从 `deploy.json` 指定 commit 导出源码、构建镜像、替换固定 Compose service 并验证运行中镜像/健康信息的 commit,但 prod live apply 仍需 supervisor 确认。部署默认遵循 target-side build:服务部署到哪台 target,就在哪台 target 从 remote commit 导出源码、一次性代理构建镜像并部署;不得把中心构建镜像作为默认分发路径,也不得用 `docker commit` 或脏 worktree 作为部署输入。backend-core 是明确例外;`frontend` 是用户服务 UI / 前端镜像化样板,`baidu-netdisk` 是主 server 直管微服务的镜像化样板,`mdtodo` 和 `claudeqq` 是 k3s-managed artifact consumer 样板,`findjob` 和 `pipeline` 是 D601 direct Docker/Compose pull-only 样板:D601 CI 构建并推送 commit-pinned 镜像到 D601 artifact registry,CD 只拉取、retag 或导入、recreate/rollout 和验证,不在 master server 或 runtime target 执行 Compose build。Dev backend-core consumes `127.0.0.1:5000/unidesk/backend-core:` into `unidesk-dev/backend-core-dev` and must not compile Rust during CD. `met-nonlinear` 当前只允许 dry-run/plan,`k3sctl-adapter` 只允许 plan/dry-run 且真实 prod 部署需要 supervisor 确认。`code-queue` 只允许 dev artifact consumer,prod artifact deploy/rollout/manifest mutation 必须 unsupported。完整规则见 `docs/reference/deploy.md`,D601 dev/Rust 边界见 `docs/reference/dev-environment.md`,artifact registry 见 `docs/reference/artifact-registry.md`。 +版本化用户服务部署优先使用受控路径;D601 persistent dev apply 当前支持 `backend-core`/`frontend`/`baidu-netdisk`/`mdtodo`/`claudeqq` artifact consumer、dev-only `code-queue` artifact consumer,以及 `project-manager`、`oa-event-flow`、`code-queue-mgr`、`findjob`、`pipeline` 和 `met-nonlinear` 的 artifact consumer validation,dev desired-state smoke 使用 `ci run-dev-e2e`。Decision Center 和 Todo Note 已迁移到 NC01 YAML-first k8s + GitHub repo storage,不再把 D601 artifact consumer 作为 closeout;Todo Note 由 `platform-infra pipelines-as-code --target NC01 --consumer unidesk-host` 构建,GitOps/Argo CD 消费 digest-pinned manifest。`deploy.json` 只声明通用 deploy reconciler 拥有的服务;NC01 服务使用对应 YAML。主 server 直管微服务和内部 sidecar,例如 `code-queue-mgr`,继续使用其已审查路径。部署不得用 `docker commit` 或脏 worktree 作为输入。backend-core 是明确例外;`frontend` 是用户服务 UI / 前端镜像化样板,`baidu-netdisk` 是主 server 直管微服务的镜像化样板,`mdtodo` 和 `claudeqq` 是 k3s-managed artifact consumer 样板,`findjob` 和 `pipeline` 是 D601 direct Docker/Compose pull-only 样板。Dev backend-core consumes `127.0.0.1:5000/unidesk/backend-core:` into `unidesk-dev/backend-core-dev` and must not compile Rust during CD. `met-nonlinear` 当前只允许 dry-run/plan,`k3sctl-adapter` 只允许 plan/dry-run 且真实 prod 部署需要 supervisor 确认。`code-queue` 只允许 dev artifact consumer,prod artifact deploy/rollout/manifest mutation 必须 unsupported。完整规则见 `docs/reference/deploy.md`,D601 dev/Rust 边界见 `docs/reference/dev-environment.md`,artifact registry 见 `docs/reference/artifact-registry.md`。 ## Host Codex Profiles @@ -60,7 +60,7 @@ swap 管理不能被强塞进所有热路径。`server start/status` 可以暴 ## Single Service Rebuild -前端、本机 provider-gateway、dev-frontend-proxy 或主 server 承载的 Todo Note/Code Queue Manager/Project Manager/Baidu Netdisk/OA Event Flow 用户服务需要非版本化本地重建时,统一使用 `bun scripts/cli.ts server rebuild `,其中 `` 只能是 `backend-core`、`frontend`、`dev-frontend-proxy`、`provider-gateway`、`todo-note`、`code-queue-mgr`、`project-manager`、`baidu-netdisk` 或 `oa-event-flow`。对 `database`、`filebrowser`、`filebrowser-d601`、`code-queue`、`k3sctl-adapter` 或未知对象,CLI 必须返回结构化 `unsupported-server-rebuild`,不得创建 job、不得执行 Compose mutation,也不得把对象强行转成源码构建服务。需要按 commit 上线或恢复到 desired-state 时必须改用受控 deploy path;Decision Center 必须走 NC01 YAML-first k8s + GitHub storage path,不能再引用 D601 artifact consumer 作为生产路径;`deploy apply --env dev|prod --service frontend|baidu-netdisk|mdtodo|claudeqq` 和 dev-only `deploy apply --env dev --service code-queue` 仍是对应服务的 artifact consumer 路径。直管微服务也不能把脏工作树或手工重建作为部署真相。`server rebuild frontend`、`server rebuild baidu-netdisk`、`server rebuild project-manager`、`server rebuild oa-event-flow`、`server rebuild todo-note` 和 `server rebuild code-queue-mgr` 都只作为维护/非标准路径保留,不得作为标准发布完成证据。Rust backend-core 常规迭代和 dev artifact CD 不得在 master server 用 `server rebuild backend-core` 替代 D601 CI;唯一例外是已提交的 backend-core 修复需要上线到主 server Compose runtime 时,可以用 `server rebuild backend-core` 受控编译并替换该服务,要求限流并发、异步 job/status 轮询、容器 health 验证和 issue 证据回写。D601 Code Queue 执行面、File Browser、FindJob、Pipeline、MET Nonlinear 和 ClaudeQQ 部署在计算节点,不属于主 server Compose 可重建服务;其中 D601 Code Queue 执行面不得再通过 `codex deploy` 或维护通道直连 D601 部署,且本阶段 prod artifact deploy/rollout/manifest mutation 仍 unsupported。特例冻结清单和下一阶段删除判断见 `docs/reference/cicd-standardization.md`。 +前端、本机 provider-gateway、dev-frontend-proxy 或主 server 承载的 Code Queue Manager/Project Manager/Baidu Netdisk/OA Event Flow 用户服务需要非版本化本地重建时,统一使用 `bun scripts/cli.ts server rebuild `。Todo Note 已由 NC01 PaC/Argo 代管;`server rebuild todo-note` 只在 CC01 数据迁移验收完成前作为旧运行面回退入口,完成后必须从 CLI 和 Compose 删除。Decision Center 和 Todo Note 的正式路径都是 NC01 YAML-first k8s + GitHub storage;`deploy apply --env dev|prod --service frontend|baidu-netdisk|mdtodo|claudeqq` 和 dev-only `deploy apply --env dev --service code-queue` 仍是对应服务的 artifact consumer 路径。直管微服务不能把脏工作树或手工重建作为部署真相。Rust backend-core 常规迭代和 dev artifact CD 不得在 master server 用 `server rebuild backend-core` 替代 D601 CI;唯一例外是已提交的 backend-core 修复需要上线到主 server Compose runtime 时,可以用 `server rebuild backend-core` 受控编译并替换该服务,要求限流并发、异步 job/status 轮询、容器 health 验证和 issue 证据回写。D601 Code Queue 执行面、File Browser、FindJob、Pipeline、MET Nonlinear 和 ClaudeQQ 部署在计算节点,不属于主 server Compose 可重建服务;其中 D601 Code Queue 执行面不得再通过 `codex deploy` 或维护通道直连 D601 部署,且本阶段 prod artifact deploy/rollout/manifest mutation 仍 unsupported。特例冻结清单和下一阶段删除判断见 `docs/reference/cicd-standardization.md`。 backend-core 的 Dockerfile 必须把 Rust 依赖构建和业务源码构建拆开:先只复制 `Cargo.toml` / `Cargo.lock`,用临时 `src/main.rs` 填充 release 依赖层;再复制真实 `src/` 并编译最终 `backend-core` 二进制。这样同一 builder 上只改业务源码时可以复用依赖层,避免每次重新编译全部 crate。该缓存策略只改善后续源码迭代;全新 builder 的第一次冷编译仍然需要下载和编译依赖,若要把第一次也做快,长期方案应是预构建依赖 builder image、BuildKit registry cache 或由 CI 发布 commit-pinned runtime image 后让部署侧只 pull 成品镜像。 @@ -82,7 +82,7 @@ frontend 的 artifact 上线顺序为:先运行必要的轻量本地校验或 ## Health Criteria -服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,dev frontend proxy 公网 `/health` 在 D601 dev frontend 已部署后返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,Todo Note 后端 `/api/health` 返回 `storage=postgres`,`code-queue-mgr` `/health` 返回 `role=master-control-plane`、`schemaReady=true` 和资源预算字段,`bun scripts/cli.ts microservice health code-queue` 与 `/api/tasks/overview` 默认经主 server `code-queue-mgr` 返回 PostgreSQL 队列总览,`k3sctl-adapter` `/api/control-plane` 可见 `unidesk-k3s` Kubernetes API service proxy 状态、D601 scheduler serving healthy、D601 read/write Service healthy、`presentNodeIds` 包含 `D601`、`missingNodeIds=[]` 和 no-fallback 路径,D601 Code Queue scheduler `/health` 返回轻量 readiness、默认模型、`queue.storage` 和 `egressProxy.connected=true`,提交类入口经 `code-queue-mgr` 入库后由 D601 `code-queue-scheduler` 轮询并执行,Project Manager `/health` 返回 `storage.primary=postgres` 和项目数量,backend-core `/api/performance` 返回性能指标,`/api/nodes` 中出现 `main-server`、`D601` 和 `D518` provider 且状态为 `online`,`/api/nodes/system-status` 中出现对应 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中能看到主 server、D601 与 D518 Docker 快照。D601/D518 上不得存在 active `rancher/k3s` 容器;D518 只有在原生 k3s-agent 与稳定 Kubernetes 网络完成验证后才可加入 Code Queue expected nodes。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 +服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,dev frontend proxy 公网 `/health` 在 D601 dev frontend 已部署后返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `pg_isready` 可用,Todo Note `/health` 返回 GitHub primary 与 PostgreSQL cache healthy,`/api/storage/verify` 对齐且六个迁移清单/1167 条历史可见,`code-queue-mgr` `/health` 返回 `role=master-control-plane`、`schemaReady=true` 和资源预算字段,`bun scripts/cli.ts microservice health code-queue` 与 `/api/tasks/overview` 默认经主 server `code-queue-mgr` 返回 PostgreSQL 队列总览,`k3sctl-adapter` `/api/control-plane` 可见 `unidesk-k3s` Kubernetes API service proxy 状态、D601 scheduler serving healthy、D601 read/write Service healthy、`presentNodeIds` 包含 `D601`、`missingNodeIds=[]` 和 no-fallback 路径,D601 Code Queue scheduler `/health` 返回轻量 readiness、默认模型、`queue.storage` 和 `egressProxy.connected=true`,提交类入口经 `code-queue-mgr` 入库后由 D601 `code-queue-scheduler` 轮询并执行,Project Manager `/health` 返回 `storage.primary=postgres` 和项目数量,backend-core `/api/performance` 返回性能指标,`/api/nodes` 中出现 `main-server`、`D601` 和 `D518` provider 且状态为 `online`,`/api/nodes/system-status` 中出现对应 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中能看到主 server、D601 与 D518 Docker 快照。D601/D518 上不得存在 active `rancher/k3s` 容器;D518 只有在原生 k3s-agent 与稳定 Kubernetes 网络完成验证后才可加入 Code Queue expected nodes。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。 ## Code Queue Control/Execution Resource Budget diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index c2e8d0e4..73e472af 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -2,21 +2,21 @@ UniDesk 用户服务是挂载到 UniDesk 核心服务上的、面向用户使用的非核心业务服务;底层配置、API、CLI 和 E2E check 名称仍保留 `microservice` 兼容命名。UniDesk 核心服务(frontend、backend-core、database、provider-gateway、主 server 控制入口)不得依赖某个用户服务存在;缺少部分或全部用户服务时,核心仍必须能启动、运行和完成基础运维。 -用户服务容器可运行在计算节点 Docker 或主 server Docker 中,主 server 只保存仓库引用、commit id、Dockerfile/docker-compose 引用、provider 映射和前端集成配置,不把业务仓库整体复制进 UniDesk。 +用户服务可运行在计算节点 Docker、主 server Docker 或受控 k8s 中。外部业务服务只登记仓库引用和运行映射;UniDesk 自有服务可以在 `src/components/microservices/` 维护源码,并由对应 YAML 声明运行、Secret、持久化和交付责任。 ## Boundary - 用户服务后端端口默认只绑定计算节点本机地址,例如 `127.0.0.1:`,不得直接暴露公网。 -- 浏览器只访问 UniDesk frontend;frontend 通过同源 `/api/microservices/*` 代理到 backend-core。`deployment.mode=unidesk-direct` 的用户服务由 backend-core 通过目标 provider-gateway 的 `microservice.http` 能力访问计算节点本机后端;`deployment.mode=internal-sidecar` 的主 server 内置控制面服务由 backend-core 直接访问同一 Compose 网络内的显式服务名;`deployment.mode=k3sctl-managed` 的用户服务只允许经 `k3sctl-adapter` 微服务进入 k3s 标准服务路由,backend-core 不得直接向业务容器所在 provider 下发 `microservice.http`。 +- 浏览器只访问 UniDesk frontend;frontend 通过同源 `/api/microservices/*` 代理到 backend-core。`deployment.mode=unidesk-direct` 的用户服务由 backend-core 通过目标 provider-gateway 的 `microservice.http` 能力访问计算节点本机后端;`deployment.mode=internal-sidecar` 的主 server 内置控制面服务由 backend-core 直接访问同一 Compose 网络内的显式服务名;`deployment.mode=k3sctl-managed` 的用户服务只允许经 `k3sctl-adapter` 微服务进入 k3s 标准服务路由。NC01 host-k8s 用户服务由 `config/unidesk-host-k8s.yaml` 渲染到 backend-core catalog,并由 backend-core 直接访问同 namespace ClusterIP Service,不经过 provider-gateway。 - backend-core REST API、database 和计算节点用户服务后端都不得新增公网端口;公网入口仅限 production frontend、dev frontend proxy 和 provider ingress。dev frontend proxy 的唯一权威规则见 `docs/reference/dev-environment.md`。 -- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这类节点本地地址,或明确登记为同一私有 Docker network 内的服务名;主 server 内置用户服务可使用同一 Compose 网络内的显式服务名,例如 `todo-note:4211`。k3s 代管服务不得把业务容器地址登记成 provider-gateway 直连目标,`backend.proxyMode` 必须使用 `k3sctl-adapter-http`,`backend.nodeBaseUrl` 可使用 `k3s://` 这类逻辑服务名。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 +- `microservice.http` 只允许 provider-gateway 访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这类节点本地地址,或明确登记为同一私有 Docker network 内的服务名;主 server 内置用户服务可使用同一 Compose 网络内的显式服务名,例如 `oa-event-flow:4255`。k3s 代管服务不得把业务容器地址登记成 provider-gateway 直连目标。backend-core 还必须用 `allowedPathPrefixes` 和 `allowedMethods` 同时限制可代理路径和 HTTP 方法。 ## Config Contract -`config.json` 的 `microservices` 是用户服务的唯一登记来源。每个条目必须包含: +`config.json.microservices` 是计算节点和主 server Compose 用户服务的登记来源。NC01 host-k8s 用户服务以 `config/unidesk-host-k8s.yaml` 为真相,renderer 生成等价 backend-core catalog;不得同时让两个来源拥有同一运行对象。`config.json` 条目通常包含: - `id`、`name`、`providerId` 和 `description`,用于 CLI、backend-core 和 frontend 统一识别。 -- `repository.url` 与 `repository.commitId`,用于记录业务代码的外部权威来源;UniDesk 不 vendoring 业务全量代码。 +- `repository.url` 与 `repository.commitId`,用于记录外部业务代码的权威来源;UniDesk 自有服务可以 vendoring 到 `src/components/microservices/`。 - `repository.dockerfile`、`repository.composeFile`、`repository.composeService` 和 `repository.containerName`,用于说明部署应复用业务仓库自身维护的 Dockerfile/docker-compose。 - `backend.nodeBaseUrl`、`backend.nodeBindHost`、`backend.nodePort`、`backend.proxyMode`、`backend.public=false`、`backend.frontendOnly=true`、`backend.allowedMethods`、`backend.allowedPathPrefixes` 和 `backend.healthPath`,用于定义计算节点端口到 UniDesk frontend-only 代理的映射。 - `deployment.mode`,用于明确部署责任边界;`unidesk-direct` 表示 UniDesk 直接登记和探测目标 provider 上的容器,`internal-sidecar` 表示主 server Compose 内的轻量控制面/基础设施服务,`k3sctl-managed` 表示 UniDesk 只登记逻辑服务并经 `deployment.adapterServiceId` 指向的 `k3sctl-adapter` 访问,代管条目还必须写明 `k3sServiceId`、`namespace`、`expectedNodeIds` 和当前 `activeNodeId`。 @@ -26,9 +26,9 @@ UniDesk 用户服务是挂载到 UniDesk 核心服务上的、面向用户使用 ## Runtime Configuration And Persistence Contract -每个长期运行的用户服务都必须把“登记配置、容器恢复、状态持久化、真实 health”作为同一交付项处理,不能只把容器拉起后登记到 `config.json`: +每个长期运行的用户服务都必须把“登记配置、运行面恢复、状态持久化、真实 health”作为同一交付项处理,不能只把容器拉起后登记到目录: -- 配置来源:`config.json` 只登记 UniDesk 代理与前端入口,业务运行配置必须保存在目标节点的业务仓库、`.state/`、Docker env-file、Windows 用户目录或主 PostgreSQL 中;不得把 token、登录态、运行日志或节点私钥提交到 UniDesk 仓库。 +- 配置来源:计算节点/Compose 服务由 `config.json` 登记代理与前端入口;YAML-first 服务由对应 YAML 声明运行面和 Secret sourceRef。不得把 token、登录态、运行日志或节点私钥提交到 UniDesk 仓库。 - 容器恢复:长驻业务后端必须使用业务仓库自己的 Compose 或显式 `docker run` 固化容器名、镜像、端口、环境变量、healthcheck、`restart` policy 和持久化挂载;`repository.composeService`、`repository.containerName` 与实际 Docker 容器必须一致,便于 `microservice list/status`、Docker 状态页和自动恢复脚本互相校验。 - 重启策略:provider-gateway 必须使用 `restart: always`;普通用户服务至少使用 `restart: unless-stopped`,如果服务是节点核心依赖或无人值守入口,可以提升为 `always`,但必须说明手动停止后的恢复语义。仅靠 restart policy 只能覆盖 Docker daemon 已重新启动后的容器恢复,不能替代 Windows 登录任务、systemd、Docker Desktop 自启动或 WSL keepalive。 - 持久化边界:任务、队列、账号会话、订阅、token、未读状态和业务数据不得只存在容器 writable layer 或浏览器本地存储;应写入主 PostgreSQL、业务数据库、Docker named volume 或节点 host bind mount。`.state/` 默认只能放可重建缓存、日志、归档和服务自有小状态;如果某服务把 `.state/` 作为权威状态目录,必须在本节或服务小节明确字段、备份和重启恢复方式。 @@ -40,37 +40,32 @@ UniDesk 用户服务是挂载到 UniDesk 核心服务上的、面向用户使用 主 server 本地开发边界固定为只开发 UniDesk frontend 与必要的 UniDesk 配置/代理登记;非 UniDesk 核心功能的后端、Dockerfile、GPU/训练容器、业务数据迁移和业务调试不得默认占用主 server 有限主机资源。涉及 findjob、pipeline、MET Nonlinear 这类业务功能时,应通过 `trans ...` 或 remote CLI SSH 透传进入计算节点,在计算节点本地业务仓库中开发、构建和调试;开发完成后,只把业务服务以用户服务形式登记到 UniDesk。 -业务仓库由业务系统自己维护,包括源码、Dockerfile、docker-compose、配置模板和业务测试。UniDesk 只引用业务仓库 URL、commit id、Dockerfile/docker-compose 路径和运行容器名;不得把业务全量代码复制到 `src/components/microservices/` 形成双维护。`src/components/microservices/` 只能放通用示例或 UniDesk 自有示例,不作为业务仓库镜像。 +外部业务仓库由业务系统自己维护,包括源码、Dockerfile、docker-compose、配置模板和业务测试。UniDesk 只引用其仓库 URL、commit id 和运行元数据,不得复制形成双维护。UniDesk 自有用户服务直接维护在 `src/components/microservices/`,不再保留第二份外部源码真相。 Code Queue runner 也是分布式开发执行面。runner 镜像必须内置 `tran`,让 runner 在执行任务时能通过公网 frontend 控制面访问 D601、G14、host workspace、k3s 控制面和目标 pod。runner 内应优先使用 `tran argv ...`、`tran :k3s kubectl ...`、`tran :k3s:: argv ...` 这类结构化命令;需要 stdin 的 `sh`/`bash`、`apply-patch`、`py` 操作同样通过 frontend `/ws/ssh` 流式通道执行,不应退回 `/api/dispatch` task polling。这个边界避免把 provider token、backend-core 内网 DNS 或长命令多层引号作为 runner 可用性的前提,也避免大 stdout 被 task JSON compact 截断。 -## Main Server User Services +## Managed User Services -主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的用户服务。该类服务仍遵守不暴露公网端口、前端统一 React 控件化展示的规则;业务持久状态必须写入主 PostgreSQL;`.state/` 只能保存日志归档、缓存或可重建工件,不能作为任务、队列、未读、通知 outbox 等权威状态来源。 +主 server 只承载对统一入口、状态迁移或控制面自动化有明确必要的用户服务;NC01 k8s 承载由 `config/unidesk-host-k8s.yaml` 管理的服务。两类服务都遵守不暴露公网业务端口、前端统一 React 控件化展示的规则。持久化真相由服务 YAML 明确声明;`.state/` 只能保存日志归档、缓存或可重建工件。 -### Todo Note On Main Server +### Todo Note On NC01 -当前 Todo Note 作为 `id=todo-note` 的用户服务登记在 `config.json`: +Todo Note 作为 `id=todo-note` 的 NC01 用户服务运行: -- 来源工作树:D518 的 `/mnt/d/work/todo_note`;主 server 工作树固定放在 `/root/todo_note`,用于 Docker build 和后端维护。 -- Provider:`main-server`,由本机 provider-gateway 通过 `microservice.http` 访问同一 Compose 网络内的 `http://todo-note:4211`。 -- 代码引用:`https://gitee.com/Lyon1998/todo_note` 与配置中的 `repository.commitId`;UniDesk 仓库只记录引用,不 vendoring Todo Note 全量业务代码。 -- 部署引用:`/root/todo_note/Dockerfile` 构建纯后端镜像,Compose service 为 `todo-note`,容器名为 `todo-note-backend`。 -- 数据库:Todo Note 不再使用 JSON 文件作为运行时权威存储;必须把 D518 `data/registry.json` 和 `data/instances/*.todo.json`、`*.history.jsonl` 迁移到主 server PostgreSQL 的 `todo_note_instances` 和 `todo_note_history` 表。 +- 源码:`src/components/microservices/todo-note`,由 UniDesk 统一维护。 +- 运行面:`config/unidesk-host-k8s.yaml` 声明 `unidesk/deployment/todo-note`、`unidesk/service/todo-note`、私有端口 `4288`、资源、健康探针、Secret sourceRef、GitHub 存储和 PaC delivery;禁止 NodePort、hostPort 和独立公开前端。 +- 交付:`CI.json` producer 为 `platform-infra pipelines-as-code`。NC01 consumer `unidesk-host` 复用 Repository `sentinel-nc01-v03`,Tekton 构建并推送镜像,GitOps 分支记录 digest,Argo CD 独占 Todo Note Service/Deployment。 +- 持久化:GitHub repo `pikasTech/decision-center-data` branch `main` 的 `todo-note/` 是权威数据;Decision Center 使用同仓 `data/`。PostgreSQL 表 `todo_note_instances` 和 `todo_note_history` 只作为索引/cache,启动时必须与 GitHub 真相协调。 - 代理路径:只允许 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`,用于保持 Todo Note 原有清单创建/删除、任务增删改、提醒、展开/收起、移动、撤销/重做等功能。 - **写操作端点形态(2026-06-01 复盘 [#188](https://github.com/pikasTech/unidesk/issues/188) 固化)**:Todo Note 不走 REST 集合(如 `/api/instances/:id/todos`),所有 task 写都走 **action 队列**模式 `POST /api/instances/:id/actions` + body `{action: {type, ...}}`。已注册 action type:`addTodo` / `updateTodoTitle` / `toggleTodoCompleted` / `toggleTodoExpanded` / `setAllTodosExpanded` / `moveTodo` / `deleteTodo` / `renameInstance` / `setTodoReminder`。其他写端点:`POST /api/instances`(body `{name}` 新建清单)、`DELETE /api/instances/:id`、`POST /api/instances/:id/undo`、`POST /api/instances/:id/redo`。 - **`TODO_NOTE_BACKEND_ONLY=1` 真实语义**:仅关闭 Todo Note 自带 Vite 前端 SPA,不阻挡任何已注册 API 路由(包括所有 POST/DELETE 写)。`/api/health` 暴露的 `backendOnly` 字段是观察用,不是读路径开关。看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时的第一反应是路径写错(用了不存在的 REST 端点被 catch-all 兜底),不是写被 mode 锁。CLI 写操作范式见 `docs/reference/cli.md` 的 `microservice proxy` 段。 - **Catch-all 误导文案 + proxy 结构化诊断(issue #198)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all 把所有未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,秘书/agent 看到这条第一反应容易误判成 "写被 mode 锁了"。UniDesk backend-core 在 `/api/microservices/todo-note/proxy/...` 同源代理层检测到该特征 body 时会改写为结构化诊断(`error: "Todo Note route not found"`、`writableApiEndpoints`、`actionTypes`、`backendOnly: true`、`method`、`path`、`issueReference`、`upstreamBody` 完整保留原始误导文案用于审计),并把 `bodyRewritten: true` 显式返回;CLI 侧保留相同改写作为本地/旧 runtime 兜底。上游 catch-all 修复 PR 合并后,该特征 body 不再出现,proxy/CLI 改写自动退化为 no-op。 - CLI 侧仍提供 `--check-path` 预检:未命中时返回同源结构化诊断并**不调用 upstream**;命中时返回 `ok: true, matched: true, endpoint: {...}`。当前 `--check-path` 只支持 `service=todo-note`,对其他服务返回结构化 `unsupported` 错误。命令样例见 `docs/reference/cli.md` 的 `microservice proxy` 段。 - - 验证门禁走 `scripts/src/e2e.ts` 的 `microservice:todo-note-route-diagnostic`(覆盖错路径 proxy/CLI rewrite、命中 / 未命中 check-path、非 todo-note 服务的 unsupported)。上游落地后由 `bun scripts/cli.ts server rebuild todo-note` 或版本化 artifact consumer 拉新 commit 镜像,并继续用 `microservice health todo-note` + `microservice proxy todo-note /api/instances//actions` 验证。 + - 该改写只用于兼容旧运行面响应;当前 vendored 服务直接返回明确的 route-not-found 诊断。版本发布必须走 NC01 PaC/Argo,并继续用 `microservice health todo-note` + `microservice proxy todo-note /api/instances//actions` 验证。 - UniDesk 前端:`用户服务 / Todo Note` React 页面负责展示清单列表、树形任务、筛选、提醒、拖放/上移下移、撤销/重做、字号控制和显式原始 JSON 按钮。 -Todo Note 在 UniDesk 语境中按纯后端服务管理:不得继续公开 Todo Note 自身 Vite/Web 前端,也不得把 `4211` 映射为公网端口。浏览器只能通过 UniDesk frontend 的 `/api/microservices/todo-note/...` 同源代理访问 Todo Note 后端。标准 artifact consumer 路径为 `bun scripts/cli.ts deploy apply --env dev|prod --service todo-note`;由于 Todo Note 源码仍在外部 Gitee 仓库,D601 registry 中必须先已有 `127.0.0.1:5000/unidesk/todo-note:`。Compose 在 recreate 时注入 `UNIDESK_TODO_NOTE_DEPLOY_*`,artifact consumer 的健康探针读取 `/api/health` 并合成 `deploy.commit` 和 `deploy.requestedCommit` 供强校验。 - -Todo Note 首次迁移或源 JSON 修复时,在主 server 通过 Docker 内网执行 `/root/todo_note/scripts/migrate-json-to-pg.ts`,并显式指向主 PostgreSQL:`docker run --rm --network unidesk_default -v /root/todo_note:/app -w /app -e DATABASE_URL='postgres://unidesk:unidesk_dev_password@database:5432/unidesk' oven/bun:1-alpine bun scripts/migrate-json-to-pg.ts`。迁移脚本必须输出 `importedInstances: 5`、`totalTodos: 100`、`completedTodos: 54` 这一类可审计摘要,不能只依赖前端页面观察。 - -Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/instances` 至少能看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务` 五个迁移清单,总任务数不低于源数据的 100 条;再通过代理创建临时清单、添加任务、切换完成、撤销并删除临时清单,证明写入路径走 PostgreSQL 且不会污染长期数据。 +Todo Note 首次迁移使用旧运行面的只读 exporter 生成完整 snapshot,通过 `POST /api/storage/import` 的 `confirm=true` 入口导入;大快照使用 `snapshotGzipBase64`,不得提高 backend-core 请求体上限。导入后 `/api/storage/status` 与 `/api/storage/verify` 必须证明 GitHub 和 PostgreSQL cache 对齐,并看到 `CONSTAR`、`大论文`、`找工作`、`小论文`、`事务`、`HWLAB` 六个清单和完整 1167 条历史。随后通过代理创建唯一临时清单、添加任务、切换完成、撤销/重做、删除临时清单,再次运行 GitHub storage verify;只有这些证据通过后才能停用 CC01 旧容器和删除旧 Compose/外部仓库部署元数据。 ### OA Event Flow On Main Server @@ -390,7 +385,7 @@ ClaudeQQ 的业务源码和持久化数据仍在 D601,但正式运行由 k3s - `bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=500'` 与 `bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects/config?path=projects/' --raw`:验证项目库文件树输入和结构化项目详情;详情应包含 config、progress、data、model、metrics 字段,供前端渲染训练状态、模型参数量和指标。 - `met-nonlinear` 的 CD 形态同样是 D601 direct Compose artifact consumer,但当前 live deploy 被 Dockerfile.contract 与 long-running service contract 不一致阻塞,只允许 dry-run/plan。 - `bun scripts/cli.ts microservice health claudeqq`、`bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login`、`bun scripts/cli.ts microservice proxy claudeqq /api/events/recent` 和 `bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`:验证 ClaudeQQ 后端、NapCat 容器登录、事件订阅和私有代理链路;消息推送使用 `POST /api/push/text`,不得开放 D601 `3290/3000/3001/6099` 公网端口。 -- `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`:验证主 server Todo Note 后端、PostgreSQL 存储和本机 provider-gateway 私有代理链路。 +- `bun scripts/cli.ts microservice health todo-note`、`bun scripts/cli.ts microservice proxy todo-note /api/instances` 与 `/api/storage/verify`:验证 frontend/backend-core 到 NC01 ClusterIP 服务的私有链路、GitHub 权威存储和 PostgreSQL cache。 - `bun scripts/cli.ts microservice health oa-event-flow`、`bun scripts/cli.ts microservice proxy oa-event-flow /api/diagnostics --raw` 与 `bun scripts/cli.ts microservice proxy oa-event-flow '/api/events?tags=service:code-queue&limit=20' --raw`:验证统一 OA 事件流、事件表、tag 查询和统计中心。 - `bun scripts/cli.ts microservice health k3sctl-adapter` 与 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw`:验证 D601 `unidesk-k3s` 控制面 adapter、manifest、D601 scheduler/read/write 实例状态、`presentNodeIds` 包含 `D601`、`missingNodeIds=[]` 和 no-fallback 运行路径。 - `bun scripts/cli.ts microservice health code-queue-mgr`:验证主 server 轻量 Code Queue 控制面,输出必须包含 `role=master-control-plane`、`schemaReady=true`、PostgreSQL pool 上限和 `noRunnerDependencies=true`。 @@ -422,7 +417,7 @@ ClaudeQQ 的业务源码和持久化数据仍在 D601,但正式运行由 k3s - 运行 `bun scripts/cli.ts microservice health pipeline` 与 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 Pipeline 后端,且 run/procedure 摘要包含甘特图所需时间字段。 - 运行 `bun scripts/cli.ts microservice health met-nonlinear`、`bun scripts/cli.ts microservice proxy met-nonlinear /api/queue`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=20'` 和 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 MET Nonlinear TS 后端。 - 运行 `bun scripts/cli.ts microservice health claudeqq`、`bun scripts/cli.ts microservice proxy claudeqq /api/napcat/login`、`bun scripts/cli.ts microservice proxy claudeqq /api/events/recent` 和 `bun scripts/cli.ts microservice proxy claudeqq /api/events/subscriptions`,确认真实链路经过 backend-core、k3sctl-adapter、Kubernetes API service proxy 和 D601 Kubernetes Service `claudeqq:3290`;health 应显示 `service=claudeqq`、`pureBackend=true`、`napcat.containerized=true`、NapCat HTTP/WS 状态、二维码状态和订阅计数。 -- 运行 `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `todo-note-backend` 后端;输出中必须包含五个迁移清单和 PostgreSQL 存储健康状态。 +- 运行 `bun scripts/cli.ts microservice health todo-note`、`bun scripts/cli.ts microservice proxy todo-note /api/instances` 与 `bun scripts/cli.ts microservice proxy todo-note /api/storage/verify --full`,确认真实链路经过公网 frontend、backend-core 和 NC01 `todo-note.unidesk.svc.cluster.local:4288`;输出中必须包含六个迁移清单、1167 条历史、GitHub revision/path 和 PostgreSQL cache 对齐状态。 - 运行 `bun scripts/cli.ts microservice health code-queue-mgr`、`bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue /api/tasks/overview` 和 `bun scripts/cli.ts codex submit --dry-run ...`,确认稳定 `code-queue` 控制/读取路径经 backend-core 分流到主 server `code-queue-mgr`,不依赖 D601 `code-queue-write` ready endpoint。再运行 `bun scripts/cli.ts microservice health k3sctl-adapter` 与 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw`,确认 D601 scheduler/read/write 三个内部 Service 的 ready endpoint 和 no-fallback 拓扑;adapter 验收还必须证明其作为 UniDesk 直管服务运行在 k3s 外部,Docker 形态下挂载宿主 `/etc/rancher/k3s/k3s.yaml` 与 `/run/host-ssh/id_ed25519`,通过容器内 SSH local tunnel 连接 WSL 原生 k3s API,且 D601/D518 上都没有 active `rancher/k3s` 容器。D601 scheduler `/health` 必须仍返回业务后端自己的 `role=scheduler`、`queue.storage.primary=postgres`、`queue.storage.postgresReady=true`、`queue.notifications.claudeqq.outbox.storage=postgres` 和 `egressProxy.connected=true`,不得被 adapter 聚合健康 JSON 替代。还必须在 active Code Queue Pod 内验证主 PostgreSQL 端口映射、主 OA Event Flow 端口映射、集群内 ClaudeQQ `http://claudeqq.unidesk.svc.cluster.local:3290/health` 和 `d601-provider-egress-proxy` 均可访问,并确认 `/workspace` 与 `/home/ubuntu` 指向同一 WSL home hostPath,`/workspace/cq-deploy` 这类绝对 symlink 可以进入真实目录。再通过公网 frontend 提交一个 `gpt-5.5` 小任务,确认任务先由 master `code-queue-mgr` 入库、D601 scheduler 轮询执行、输出实时更新、结束后有 judge 判定,且运行中可追加 prompt 或打断。Code Queue 的重启恢复必须作为验收项:运行中任务存在时重启或重建 scheduler 实例后,任务必须从 PostgreSQL 恢复到可继续执行状态,不能丢失 active task、`promptHistory`、后续 queued 任务、readAt/未读状态或已入 outbox 的 ClaudeQQ 通知。Code Queue 服务名、表名前缀或持久化目录发生迁移后,还必须运行 `bun scripts/cli.ts e2e run --only microservice:catalog-code-queue,microservice:code-queue-status,microservice:code-queue-health,microservice:code-queue-tasks`,证明 backend-core catalog、master mgr、k3s adapter 执行面、PostgreSQL 队列和任务列表都指向稳定 `code-queue`。批量验收必须通过公网 frontend 设置 `入队份数=5` 或使用多段 prompt 分隔,一次性入队 5 条任务,并确认 5 条任务按顺序进入 running/judging/succeeded,而不是只运行第一条。 - Code Queue 内存防回归验收:凡是改动 Code Queue 的持久化、scheduler、输出/Trace、health、列表/详情查询、日志导出或容器运行参数,交付前必须在 D601 用 `kubectl -n unidesk get deploy,pod,svc,endpoints -o wide`、`kubectl -n unidesk describe deploy/code-queue` 或等价 Docker inspect 确认 memory/swap 硬上限符合预算,运行 `kubectl -n unidesk top pod` 或 Docker stats 确认常驻内存、`OOMKilled=false` 和 `RestartCount` 未异常增长,再运行 `bun scripts/cli.ts microservice health code-queue` 确认 `/health` 是轻量 readiness 且暴露 PostgreSQL/notification/outbox 状态。验收还必须覆盖有历史任务存在时的 `/api/tasks/overview`、单任务详情和 output/transcript 查询,证明热状态裁剪不会丢历史输出、也不会重新把全部历史 `task_json` 缓存在进程内;涉及 TypeScript/frontend 验证的任务应能在 D601 Code Queue memory/swap 预算中完成 `bun run --cwd src/components/frontend check` 这类短时高内存命令,而不是被 memory watchdog 反复 SIGTERM。 - Code Queue 延迟防回归验收:凡是改动 Code Queue 列表、overview、readAt、Trace/summary 懒加载、实时 output/SSE 事件发布、frontend 请求策略、backend-core 用户服务代理或 frontend Code Queue 请求路径,交付前必须在有历史任务数据且有 active output 流动的 live 环境验证 `GET /api/tasks/overview`、`POST /api/tasks//read`、选定 task 的 `trace-step` 和前端 `/app/code-queue/` 首屏均低于 1s 目标;可运行 `bun scripts/src/code-queue-perf.ts --json --target-ms 1000` 采集公网 frontend 下的首屏耗时、最慢 API 和 DOM 完成指标,并用 `bun scripts/cli.ts microservice proxy code-queue /api/tasks/overview --raw`、D601 Pod `/health` 与 `/api/tasks/overview` curl、性能面板 `/api/performance` 与 `/api/frontend-performance` 失败/慢操作记录、`kubectl -n unidesk top pod` 或 Docker stats 补充后端耗时、代理 502 和内存/CPU 证据。验收结论必须同时说明是否使用了短 TTL cache、cache 如何被 mutation 或 archive append 失效、数据库索引/聚合是否命中、输出热路径是否只读增量指标,以及分页加载是否跳过 selected/active/stats;不能只展示 cache 命中后的单次快照。 @@ -432,4 +427,4 @@ ClaudeQQ 的业务源码和持久化数据仍在 D601,但正式运行由 k3s - 在 D601 上用 `trans D601 ...` 调试 `~/met_nonlinear`,确认 `curl http://127.0.0.1:3288/health` 可用;最终验收必须回到公网 UniDesk frontend,通过项目库选择、Fork、加入待启动队列和启动队列完成,不要把 MET Nonlinear 后端、Docker build 或训练任务部署到主 server。 - 在 D601 上用 `trans D601 ...` 调试 `~/.agents/skills/claudeqq`,可使用业务仓库 `docker-compose.unidesk.yml` 做本地诊断,但正式部署必须回到 `bun scripts/cli.ts deploy apply --service claudeqq`、k3s Deployment `claudeqq` 和 UniDesk `microservice health/proxy` 验证;不要把 ClaudeQQ 后端或 NapCat 调试服务部署到主 server,也不要把诊断 Compose 当作正式运行态。 - 运行 `bun scripts/cli.ts e2e run`,确认用户服务相关检查 passed;公网浏览器访问、截图和前端断言细则统一见 `$unidesk-webdev`。 -- 登录公网 frontend,进入 `用户服务 / 服务目录`、`用户服务 / Todo Note`、`用户服务 / FindJob`、`用户服务 / Pipeline`、`用户服务 / MET Nonlinear` 和 `用户服务 / ClaudeQQ`,确认能看到主 server 与 D601 provider、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图、epoch 列表、epoch 甘特图和运行材料索引、MET Nonlinear 队列/GPU/镜像/Project config/训练历史、ClaudeQQ NapCat 容器登录二维码/NapCat 状态/事件订阅/消息推送/最近 QQ 事件;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数、epoch 甘特图和结构化运行材料索引,MET Nonlinear 页面必须显示 `Health OK`、`Fork Project`、`启动队列`、`当前队列`、最大并发设置和 GPU/镜像面板,ClaudeQQ 页面必须显示 `Health OK`、`NapCat 容器登录`、`QQ 事件订阅`、`消息推送`、`事件缓存` 和私有代理说明,不能只停留在 loading 骨架;页面默认不得出现裸 JSON、JSONL 或逐行日志。 +- 登录公网 frontend,进入 `用户服务 / 服务目录`、`用户服务 / Todo Note`、`用户服务 / FindJob`、`用户服务 / Pipeline`、`用户服务 / MET Nonlinear` 和 `用户服务 / ClaudeQQ`,确认能看到 NC01 k8s、主 server 与 D601 provider 的受控运行信息、仓库引用、后端私有映射、Todo Note 迁移清单与树形任务、FindJob 指标和岗位预览、Pipeline 组件矩阵、React Flow 控制图、epoch 列表、epoch 甘特图和运行材料索引、MET Nonlinear 队列/GPU/镜像/Project config/训练历史、ClaudeQQ NapCat 容器登录二维码/NapCat 状态/事件订阅/消息推送/最近 QQ 事件;Todo Note 页面必须能创建临时清单、添加任务并删除临时清单,删除前必须按唯一临时清单名称重新选中对应行,禁止用未确认的当前 active 清单执行删除,FindJob 页面必须显示真实数字指标、`HEALTH OK` 和非空岗位预览,Pipeline 页面必须显示 `Pipeline v2 工作台`、`Health OK`、组件数、epoch 甘特图和结构化运行材料索引,MET Nonlinear 页面必须显示 `Health OK`、`Fork Project`、`启动队列`、`当前队列`、最大并发设置和 GPU/镜像面板,ClaudeQQ 页面必须显示 `Health OK`、`NapCat 容器登录`、`QQ 事件订阅`、`消息推送`、`事件缓存` 和私有代理说明,不能只停留在 loading 骨架;页面默认不得出现裸 JSON、JSONL 或逐行日志。 diff --git a/docs/reference/nc01.md b/docs/reference/nc01.md index 939327de..af5e1d4d 100644 --- a/docs/reference/nc01.md +++ b/docs/reference/nc01.md @@ -38,7 +38,7 @@ When exporting legacy PostgreSQL data, do not stream Markdown bodies as plain JS ## GitHub Token -The local GitHub token source is `.env/gh_token.txt`. It must be stored as a bare token and consumed through controlled YAML/sourceRef or temporary non-printing credential helpers. Do not place the token in command argv, logs, committed files, or rendered manifests. +The local GitHub token source is `/root/.unidesk/.env/gh_token.txt`. It must be stored as a bare token and consumed through controlled YAML/sourceRef or temporary non-printing credential helpers. Do not place the token in command argv, logs, committed files, or rendered manifests. `/root/unidesk/.env` is only a compatibility symlink to the canonical owner directory; worktrees must use the absolute source path instead of copying credentials. ## Public Exposure diff --git a/docs/reference/pk01.md b/docs/reference/pk01.md index b128ee6b..9ecd436b 100644 --- a/docs/reference/pk01.md +++ b/docs/reference/pk01.md @@ -14,7 +14,7 @@ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' SH ``` -When the PK01 provider-gateway channel is offline, operators may use the ignored break-glass SSH source `.env/PK01_ssh.txt`. The file format is two lines: line 1 is the SSH target such as `ubuntu@`, line 2 is the password. Do not print the second line, commit it, copy it into issue comments, or pass it through commands that echo typed input. Prefer an ephemeral `SSH_ASKPASS` wrapper so OpenSSH can handle `keyboard-interactive` or password prompts without exposing the value: +When the PK01 provider-gateway channel is offline, operators may use the owner-only break-glass SSH source `/root/.unidesk/.env/PK01_ssh.txt`. The file format is two lines: line 1 is the SSH target such as `ubuntu@`, line 2 is the password. Do not print the second line, commit it, copy it into issue comments, or pass it through commands that echo typed input. Prefer an ephemeral `SSH_ASKPASS` wrapper so OpenSSH can handle `keyboard-interactive` or password prompts without exposing the value: ```bash tmp=$(mktemp -d /tmp/pk01-askpass.XXXXXX) @@ -25,7 +25,7 @@ printf '%s\n' "$PK01_SSH_PASS" SH chmod 700 "$tmp/askpass.sh" -PK01_SSH_PASS="$(sed -n '2p' .env/PK01_ssh.txt)" \ +PK01_SSH_PASS="$(sed -n '2p' /root/.unidesk/.env/PK01_ssh.txt)" \ SSH_ASKPASS="$tmp/askpass.sh" \ SSH_ASKPASS_REQUIRE=force \ DISPLAY=none \ @@ -33,7 +33,7 @@ setsid -w ssh \ -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=8 \ -o PreferredAuthentications=keyboard-interactive,password,publickey \ - "$(sed -n '1p' .env/PK01_ssh.txt)" \ + "$(sed -n '1p' /root/.unidesk/.env/PK01_ssh.txt)" \ 'hostname; date -Is' rm -rf "$tmp" diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index bb45f4f5..e6978ec8 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -108,7 +108,7 @@ provider ingress 是唯一允许公网暴露的 provider 连接接口,当前 `microservice.http` 是 provider-gateway 给 `deployment.mode=unidesk-direct` 用户服务使用的私有后端访问能力。新 provider 必须同时声明 `microservice.http.tunnel`:backend-core 对 UI 高频读请求优先复用既有 provider WebSocket 发送 `http_tunnel_request` 并等待 `http_tunnel_response`,不再为每个轮询创建 `unidesk_tasks` 调度记录;旧 provider 未声明该能力时才回落到原 `dispatch` 任务路径。响应头会标记 `x-unidesk-proxy-mode=provider-ws-http-tunnel` 或旧 `provider-task`,用于性能验收。 -backend-core 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数;provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,但最终允许方法必须由每个用户服务的 `backend.allowedMethods` 显式配置。provider-gateway 只允许访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址;主 server 内置 Todo Note 后端可使用 Compose 服务名 `http://todo-note:4211`。`deployment.mode=k3sctl-managed` 的 Code Queue 不得通过 provider-gateway 直连业务容器,正式路径只能是 backend-core -> provider WebSocket HTTP tunnel -> `k3sctl-adapter` -> Kubernetes native Service/DNS,必要时显式 fallback 到 Kubernetes API service proxy -> k3s/k8s Service。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 +backend-core 下发目标 service id、节点本机 `targetBaseUrl`、path、query、method、request body、timeout 和可选 JSON 数组裁剪参数;provider-gateway 支持 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,但最终允许方法必须由每个用户服务的 `backend.allowedMethods` 显式配置。provider-gateway 只允许访问 `http://127.0.0.1`、`http://localhost`、`http://host.docker.internal` 这些节点本地地址;主 server 内置服务可使用明确登记的 Compose 服务名。`deployment.mode=k3sctl-managed` 的 Code Queue 不得通过 provider-gateway 直连业务容器,正式路径只能是 backend-core -> provider WebSocket HTTP tunnel -> `k3sctl-adapter` -> Kubernetes native Service/DNS,必要时显式 fallback 到 Kubernetes API service proxy -> k3s/k8s Service。NC01 Todo Note 由 backend-core 直接访问同 namespace ClusterIP Service,不经过 provider-gateway。该能力不打开 provider-gateway 入站端口,也不替代业务仓库自身 Dockerfile/docker-compose。 backend-core 必须把 provider WebSocket HTTP tunnel 的失败分类到响应 body 和 headers:失败响应至少包含 `requestId`、`providerId`、`serviceId`、`stage`、`failureReason` 或 provider result,并带 `x-unidesk-request-id` 与 `x-unidesk-tunnel-error`。`GET`/`HEAD` 非 stream 请求允许短超时分层重试;`POST`、`PATCH`、`PUT`、`DELETE` 这类可能产生副作用的请求不得自动重复。Provider 重连时 backend-core 必须先确认 close 事件来自当前 active socket,旧 socket 被新 socket 替换后的迟到 close 不得清理新连接上的 tunnel waiter,也不得把节点误标 offline。 diff --git a/docs/reference/repo-tree.md b/docs/reference/repo-tree.md index 610fcd84..d7fc26e0 100644 --- a/docs/reference/repo-tree.md +++ b/docs/reference/repo-tree.md @@ -1,8 +1,9 @@ - unidesk/ (Repository root: configuration, orchestration, CLI, and documentation) - AGENTS.md (Top-level agent index and `scripts/cli.ts` usage guide) - TEST.md (Manual CLI test plan following cli-spec expectations) - - config.json (Single source of truth for ports, tokens, runtime, paths, and provider identity) - - docker-compose.yml (Main server orchestration for database, backend-core, frontend, dev-frontend-proxy, provider-gateway, and managed main-server user services such as Todo Note) + - config.json (Main-server and provider-managed runtime catalog; YAML-first services use their config files) + - config/unidesk-host-k8s.yaml (NC01 host-k8s runtime, Secret, storage, and Todo Note delivery truth) + - docker-compose.yml (Main server orchestration for database, backend-core, frontend, dev-frontend-proxy, provider-gateway, and managed main-server user services) - package.json / bun.lock (Root Bun tooling for CLI checks) - .gitignore - .agents/skills/ (Repo-owned UniDesk agent skills. Skill directories named `unidesk-*` live here as the Git-backed source of truth; `~/.agents/skills` is reserved for non-UniDesk shared/external skills or local runtime copies.) diff --git a/docs/reference/user-service-delivery.md b/docs/reference/user-service-delivery.md index 22a8ea09..6d67e853 100644 --- a/docs/reference/user-service-delivery.md +++ b/docs/reference/user-service-delivery.md @@ -4,7 +4,7 @@ This document owns the default delivery path for UniDesk user services registere ## Scope -User services are non-core business services mounted onto the UniDesk platform. They must remain deliverable without changing the base platform strategy. The default policy here applies to user services that are expected to reach production after validation, including the UniDesk user-service UI artifact (`frontend`), main-server direct services such as Baidu Netdisk, NC01-managed k8s services such as Decision Center, D601-managed k3s services such as MDTODO and ClaudeQQ, and D601 direct Docker/Compose services such as FindJob and Pipeline. Code Queue is a special dev-only artifact consumer in this policy; its production execution plane must not be deployed by the user-service artifact consumer. +User services are non-core business services mounted onto the UniDesk platform. They must remain deliverable without changing the base platform strategy. The default policy here applies to user services that are expected to reach production after validation, including the UniDesk user-service UI artifact (`frontend`), main-server direct services such as Baidu Netdisk, NC01-managed k8s services such as Decision Center and Todo Note, D601-managed k3s services such as MDTODO and ClaudeQQ, and D601 direct Docker/Compose services such as FindJob and Pipeline. Code Queue is a special dev-only artifact consumer in this policy; its production execution plane must not be deployed by the user-service artifact consumer. This policy does not apply to: @@ -16,7 +16,7 @@ This policy does not apply to: - one-off infrastructure repair actions. - production Code Queue artifact deploy or rollout. -`todo-note` is a special-case main-server service because its source repository remains external. Its master-server Compose artifact consumer still follows the runtime contract: `UNIDESK_TODO_NOTE_DEPLOY_*` is injected at recreate time, and the consumer health probe combines the service `/api/health` response with that runtime metadata before checking `deploy.commit` and `deploy.requestedCommit`. +`todo-note` is a UniDesk-vendored NC01 service with a service-specific Pipelines-as-Code producer and Argo CD consumer. It is intentionally outside the generic D601 `ci publish-user-service` and `deploy apply --env ...` flow. ## Default Release Flow @@ -36,8 +36,8 @@ The default release flow for a user-service change is: - No production deploy may rebuild the source from a dirty worktree. - Commit-pinned image tags are the deployment truth; mutable `latest` tags are not. - Root `CI.json` is an artifact catalog only. It lists CI producer inputs such as `serviceId`, artifact kind, source repository, repo-relative Dockerfile, image repository naming, upstream image digest/mirror metadata and the required artifact summary fields; it must not carry runtime topology or replace `deploy.json`. -- `deploy.json` is the only release-intent source for desired commits, image pins and deployment-time parameters that must stay consistent across CI, dev CD and prod CD. Runtime manifests, health metadata and helper config may mirror those values only as derived copies; if they diverge, `deploy.json` wins. The consolidation track is [GitHub issue #60](https://github.com/pikasTech/unidesk/issues/60). -- The standard CI artifact producer is `bun scripts/cli.ts ci publish-user-service --service --commit `. It accepts only a pushed Git commit and a service id registered in `CI.json`, reads `source.repo` and `source.dockerfile` from that catalog, rejects ad hoc `--repo` overrides, and reports `serviceId`, `sourceCommit`, `sourceRepo`, `dockerfile`, `imageRef`, `tag`, `digest` and `digestRef`. +- `deploy.json` is the release-intent source for services owned by the generic deploy reconciler. NC01 YAML-first services use their service YAML and PaC/GitOps branch as desired-state truth instead; they must not be duplicated into `deploy.json` as an active consumer. The consolidation track is [GitHub issue #60](https://github.com/pikasTech/unidesk/issues/60). +- For artifacts whose `CI.json` producer is `ci publish-user-service`, the standard command is `bun scripts/cli.ts ci publish-user-service --service --commit `. It accepts only a pushed Git commit and a service id registered to that producer, reads `source.repo` and `source.dockerfile` from the catalog, rejects ad hoc `--repo` overrides, and reports `serviceId`, `sourceCommit`, `sourceRepo`, `dockerfile`, `imageRef`, `tag`, `digest` and `digestRef`. Services owned by another producer are rejected before any remote action and the CLI names the owning entrypoint. - The producer dry-run preflight is `bun scripts/cli.ts ci publish-user-service --service --commit --dry-run`. It is read-only and reports detailed `missingChannels` plus stable `missingControlChannels` for `backend-core`, `database`, `provider` and `registry`; any missing control channel is `runnerDisposition=infra-blocked`. The `controlledPublish` field names D601 `unidesk-ci` as the only controlled environment for the subsequent real publish. - The CI artifact producer is not a deploy executor. It must not mutate the production namespace, restart production services, or update `deploy.json`. - `CI.json` may list `blocked` source-build entries when the source input is known but the publish/CD boundary is not yet reviewed. It may also list `upstream-image` entries for image-only services such as File Browser; those entries pin upstream digest and mirror intent but must not be treated as Dockerfile builds. @@ -54,7 +54,7 @@ Some registered user services are intentionally upstream-image consumers instead - CD must be pull-only and must verify the image identity, OCI labels and service health through the UniDesk private proxy. - Until the upstream digest has been resolved and mirrored or pinned for a future mirror producer, File Browser remains a recovery/diagnostic image-only path rather than a standard release path. -The current catalog covers `frontend`, `baidu-netdisk`, `decision-center`, `project-manager`, `oa-event-flow`, `todo-note`, `code-queue-mgr`, `findjob`, `pipeline`, `met-nonlinear`, `k3sctl-adapter`, `mdtodo` and `claudeqq` as supported `publish-user-service` source-build services. `k3sctl-adapter` is cataloged for artifact visibility, but it is not a user-service live deploy target in this policy. `code-queue` is cataloged but blocked by the D601 dev/prod boundary. `filebrowser` and `filebrowser-d601` are cataloged as pinned upstream images, not source builds. +The current catalog covers `frontend`, `baidu-netdisk`, `decision-center`, `project-manager`, `oa-event-flow`, `code-queue-mgr`, `findjob`, `pipeline`, `met-nonlinear`, `k3sctl-adapter`, `mdtodo` and `claudeqq` as supported `publish-user-service` source-build services. Todo Note remains a supported source build, but its producer is `platform-infra pipelines-as-code`. `k3sctl-adapter` is cataloged for artifact visibility, but it is not a user-service live deploy target in this policy. `code-queue` is cataloged but blocked by the D601 dev/prod boundary. `filebrowser` and `filebrowser-d601` are cataloged as pinned upstream images, not source builds. ## Frontend Pairing @@ -118,14 +118,16 @@ OA Event Flow follows the same master-server Compose artifact flow as Project Ma ## Todo Note -Todo Note is an external-source main-server Compose service that follows the standard commit-pinned artifact consumer flow. +Todo Note follows the same NC01 YAML-first and GitHub-backed operating model as Decision Center, while retaining its existing action/history API contract. -- The expected source reference remains `https://gitee.com/Lyon1998/todo_note`. -- The standard producer is `bun scripts/cli.ts ci publish-user-service --service todo-note --commit --wait-ms 1200000`; it fetches the external Gitee repo at that commit and builds that repo's `Dockerfile`, rather than treating Todo Note as UniDesk-owned source. -- Dev/prod CD require the D601 registry artifact `127.0.0.1:5000/unidesk/todo-note:` published from that external source. -- Dev CD consumes the same artifact with `bun scripts/cli.ts deploy apply --env dev --service todo-note`; prod CD consumes it with `bun scripts/cli.ts deploy apply --env prod --service todo-note`. -- The Compose runtime injects `UNIDESK_TODO_NOTE_DEPLOY_REF`, `UNIDESK_TODO_NOTE_DEPLOY_SERVICE_ID`, `UNIDESK_TODO_NOTE_DEPLOY_REPO`, `UNIDESK_TODO_NOTE_DEPLOY_COMMIT`, and `UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT`; the consumer health probe must return matching `deploy.commit` and `deploy.requestedCommit`. -- `server rebuild todo-note` remains a maintenance/local rebuild path only. It is not the standard versioned release truth for Todo Note. +- Source is vendored at `src/components/microservices/todo-note`; `config/unidesk-host-k8s.yaml` is the runtime, Secret, storage and delivery source of truth. +- Production runs as private `unidesk/deployment/todo-note` and `unidesk/service/todo-note`. It has no NodePort, hostPort or separate public frontend. +- `CI.json` assigns the source build to `platform-infra pipelines-as-code`. `ci publish-user-service --service todo-note` must reject the request before contacting D601. +- The NC01 PaC consumer is `unidesk-host`, reusing Repository `sentinel-nc01-v03`; Tekton publishes a commit image, the GitOps branch pins its digest, and Argo CD owns the Todo Note Service/Deployment. +- Durable state is GitHub repo `pikasTech/decision-center-data`, branch `main`, base path `todo-note`. Decision Center keeps base path `data`, so the services share one repository without sharing object paths. PostgreSQL is an index/cache only. +- GitHub SSH credentials come only from the YAML-declared Secret sourceRef shared with Decision Center; CLI output exposes presence and fingerprints, never key material. +- Controlled release evidence is `platform-infra pipelines-as-code plan|apply|status|closeout --target NC01 --consumer unidesk-host`, followed by `microservice health todo-note` and storage diagnostics. +- CC01 migration uses `POST /api/storage/import` with an explicit confirmed snapshot, then `/api/storage/status` and `/api/storage/verify`. Acceptance must preserve all six lists, the complete undo/redo history and every reminder-notification ledger row before the old runtime is retired. ## D601 Direct Compose Consumers diff --git a/project-management/PJ2026-01/specs/PJ2026-01050105-yaml-first-users-nav-access.md b/project-management/PJ2026-01/specs/PJ2026-01050105-yaml-first-users-nav-access.md index 0af36508..b0e61cb0 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01050105-yaml-first-users-nav-access.md +++ b/project-management/PJ2026-01/specs/PJ2026-01050105-yaml-first-users-nav-access.md @@ -35,7 +35,7 @@ 用户导航ACL负责把 D601 HWLAB v0.3 的预置用户、密码来源、角色、状态、根导航可见性和后端访问兜底拒绝收敛到 YAML-first source of truth,使 `admin` 与运营预置普通用户可以通过同一 bootstrap/sync 流程进入本地用户表、OpenFGA 或应用 ACL,并让 Cloud Web 根导航、router guard 和后端 API guard 使用同一份访问摘要。 -本规格的目标状态是:`admin` 和 `huiggao@163.com` 都由 UniDesk/HWLAB owning YAML 声明;密码明文只存在于 gitignored owner-only `.env/...` sourceRef 和运行面 Secret;CLI plan/status 只输出 sourceRef、key、presence、fingerprint 和 mutation 摘要;Cloud API 在 `/v1/auth/session` 与 `/v1/users/me` 返回非敏感访问摘要;Cloud Web 只显示当前用户被授权的根导航;未授权用户直接访问前端路由或后端 API 时被稳定拒绝。 +本规格的目标状态是:`admin` 和 `huiggao@163.com` 都由 UniDesk/HWLAB owning YAML 声明;密码明文只存在于 owner-only `$HOME/.unidesk/.env/...` canonical sourceRef 和运行面 Secret;CLI plan/status 只输出 sourceRef、key、presence、fingerprint 和 mutation 摘要;Cloud API 在 `/v1/auth/session` 与 `/v1/users/me` 返回非敏感访问摘要;Cloud Web 只显示当前用户被授权的根导航;未授权用户直接访问前端路由或后端 API 时被稳定拒绝。 ### 2.2 范围内 @@ -213,7 +213,7 @@ users: status: active passwordSourceRef: kind: envFile - path: .env/hwlab/d601-v03-admin.env + path: /home/ubuntu/.unidesk/.env/hwlab/d601-v03-admin.env key: HWLAB_BOOTSTRAP_ADMIN_PASSWORD passwordHashTransform: hwlab-sha256 navProfile: admin-full @@ -225,7 +225,7 @@ users: status: active passwordSourceRef: kind: envFile - path: .env/hwlab/d601-v03-users.env + path: /home/ubuntu/.unidesk/.env/hwlab/d601-v03-users.env key: HWLAB_USER_HUIGGAO_PASSWORD passwordHashTransform: hwlab-sha256 navProfile: code-mdtodo-only diff --git a/scripts/cli.ts b/scripts/cli.ts index ff30c6a4..c802631d 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -32,6 +32,7 @@ import { runServerCleanupCommand } from "./src/server-cleanup"; import { runGcCommand } from "./src/gc"; import { runPlatformDbCommand } from "./src/platform-db"; import { runSecretsCommand } from "./src/secrets"; +import { readHostK8sPublicHost } from "./src/host-k8s-config"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; @@ -470,6 +471,18 @@ async function main(): Promise { } const config = readConfig(); + if (top === "microservice" && !args.includes("--check-path")) { + const host = readHostK8sPublicHost(); + if (host !== null) { + process.exitCode = await runRemoteCli({ + ...remoteOptions, + host, + transport: "frontend", + args, + }, config); + return; + } + } const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args); if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) { process.exitCode = await runRemoteCli({ diff --git a/scripts/native/cicd/build-unidesk-host-image.sh b/scripts/native/cicd/build-unidesk-host-image.sh new file mode 100755 index 00000000..ca4499be --- /dev/null +++ b/scripts/native/cicd/build-unidesk-host-image.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +release_dir=${UNIDESK_HOST_RELEASE_DIR:-/workspace/release} +source_dir=${UNIDESK_HOST_SOURCE_DIR:-/workspace/source} +digest_file=${UNIDESK_HOST_DIGEST_FILE:-/workspace/image-digest-ref} +metadata_file=${UNIDESK_HOST_BUILD_METADATA_FILE:-/workspace/build-metadata.json} +build_log=${UNIDESK_HOST_BUILD_LOG:-/workspace/image-build.log} +source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required} + +enabled=$(cat "$release_dir/enabled") +if [ "$enabled" != true ]; then + : >"$digest_file" + : >"$metadata_file" + printf '{"ok":true,"phase":"image-build","status":"skipped","reason":"delivery-disabled","sourceCommit":"%s","valuesPrinted":false}\n' "$source_commit" + exit 0 +fi + +image_repository=$(cat "$release_dir/image-repository") +dockerfile=$(cat "$release_dir/dockerfile") +network_mode=$(cat "$release_dir/network-mode") +http_proxy=$(cat "$release_dir/http-proxy") +https_proxy=$(cat "$release_dir/https-proxy") +all_proxy=$(cat "$release_dir/all-proxy") +no_proxy=$(cat "$release_dir/no-proxy") +image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)" + +rm -f "$metadata_file" "$build_log" "$digest_file" +env HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" ALL_PROXY="$all_proxy" NO_PROXY="$no_proxy" \ + http_proxy="$http_proxy" https_proxy="$https_proxy" all_proxy="$all_proxy" no_proxy="$no_proxy" \ + buildctl-daemonless.sh build \ + --allow network.host \ + --frontend dockerfile.v0 \ + --local context="$source_dir" \ + --local dockerfile="$source_dir" \ + --opt "filename=$dockerfile" \ + --opt "network=$network_mode" \ + --opt "build-arg:HTTP_PROXY=$http_proxy" \ + --opt "build-arg:HTTPS_PROXY=$https_proxy" \ + --opt "build-arg:ALL_PROXY=$all_proxy" \ + --opt "build-arg:NO_PROXY=$no_proxy" \ + --metadata-file "$metadata_file" \ + --output "type=image,name=$image_ref,push=true,registry.insecure=true" >"$build_log" 2>&1 +cat "$build_log" +test -s "$metadata_file" +printf '%s\n' "$image_ref" >"$digest_file" +printf '{"ok":true,"phase":"image-build","status":"built","sourceCommit":"%s","imageRef":"%s","metadataReady":true,"valuesPrinted":false}\n' "$source_commit" "$image_ref" diff --git a/scripts/native/cicd/prepare-unidesk-host-release.mjs b/scripts/native/cicd/prepare-unidesk-host-release.mjs new file mode 100755 index 00000000..ebcca4fe --- /dev/null +++ b/scripts/native/cicd/prepare-unidesk-host-release.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env bun +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function option(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + const value = process.argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function record(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value; +} + +function required(value, path) { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`); + return value; +} + +function valueAt(root, reference) { + return reference.split(".").reduce((value, key) => record(value, reference)[key], root); +} + +function writeValue(outputDir, name, value) { + writeFileSync(resolve(outputDir, name), `${value}\n`, { encoding: "utf8", mode: 0o644 }); +} + +function main() { +const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml"; +const outputDir = resolve(required(option("--output-dir"), "--output-dir")); +const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath); +const delivery = record(config.delivery, "delivery"); +const serviceRef = required(delivery.serviceRef, "delivery.serviceRef"); +const service = record(valueAt(config, serviceRef), serviceRef); +const build = record(delivery.build, "delivery.build"); +const proxy = record(build.proxy, "delivery.build.proxy"); +const image = record(delivery.image, "delivery.image"); +const enabled = delivery.enabled === true; +if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean"); +const noProxy = proxy.noProxy; +if (!Array.isArray(noProxy) || noProxy.some((value) => typeof value !== "string" || value.length === 0)) throw new Error("delivery.build.proxy.noProxy must be a non-empty string array"); +mkdirSync(outputDir, { recursive: true }); +writeValue(outputDir, "enabled", enabled ? "true" : "false"); +writeValue(outputDir, "dockerfile", required(record(service.repository, `${serviceRef}.repository`).dockerfile, `${serviceRef}.repository.dockerfile`)); +writeValue(outputDir, "image-repository", required(image.repository, "delivery.image.repository")); +writeValue(outputDir, "network-mode", required(build.networkMode, "delivery.build.networkMode")); +writeValue(outputDir, "http-proxy", required(proxy.http, "delivery.build.proxy.http")); +writeValue(outputDir, "https-proxy", required(proxy.https, "delivery.build.proxy.https")); +writeValue(outputDir, "all-proxy", required(proxy.all, "delivery.build.proxy.all")); +writeValue(outputDir, "no-proxy", noProxy.join(",")); +process.stdout.write(`${JSON.stringify({ ok: true, action: "unidesk-host-release-prepare", enabled, serviceRef, valuesPrinted: false })}\n`); +} + +if (!process.execArgv.includes("--check")) main(); diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.mjs b/scripts/native/cicd/publish-unidesk-host-gitops.mjs new file mode 100755 index 00000000..17e75f64 --- /dev/null +++ b/scripts/native/cicd/publish-unidesk-host-gitops.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env bun +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +function option(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + const value = process.argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function record(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value; +} + +function required(value, path) { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`); + return value; +} + +function run(command, args, cwd, allowFailure = false) { + const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0 && !allowFailure) { + const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000); + throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`); + } + return result; +} + +function safeManifestPath(value) { + const path = required(value, "delivery.gitops.manifestPath"); + if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.manifestPath must be a safe relative path"); + return path; +} + +function main() { +const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml"); +const sourceRoot = resolve(option("--source-root") ?? process.cwd()); +const metadataPath = resolve(required(option("--metadata"), "--metadata")); +const sourceCommit = required(option("--source-commit"), "--source-commit"); +const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops"); +if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA"); + +const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath); +const delivery = record(config.delivery, "delivery"); +if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean"); +const enabled = delivery.enabled === true; +const serviceRef = required(delivery.serviceRef, "delivery.serviceRef"); +const imageConfig = record(delivery.image, "delivery.image"); +const gitops = record(delivery.gitops, "delivery.gitops"); +const author = record(gitops.author, "delivery.gitops.author"); +const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl"); +const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl"); +const branch = required(gitops.branch, "delivery.gitops.branch"); +const manifestPath = safeManifestPath(gitops.manifestPath); +let digest = null; +let digestRef = null; +let manifest = null; + +if (enabled) { + const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath); + digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`); + if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("containerimage.digest must be sha256:<64 hex>"); + digestRef = `${required(imageConfig.repository, "delivery.image.repository")}@${digest}`; + const render = run("bun", [ + "scripts/native/deploy/render-unidesk-host-service.mjs", + "--config", + configPath, + "--service-ref", + serviceRef, + "--image", + digestRef, + "--source-commit", + sourceCommit, + ], sourceRoot); + manifest = render.stdout; + if (!manifest.includes("kind: Deployment") || !manifest.includes("kind: Service")) throw new Error("service renderer did not emit Service and Deployment objects"); +} + +rmSync(worktree, { recursive: true, force: true }); +run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot); +const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0; +if (fetched) { + run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree); +} else { + run("git", ["checkout", "--orphan", branch], worktree); + run("git", ["rm", "-rf", "."], worktree, true); +} + +const targetPath = resolve(worktree, manifestPath); +if (!targetPath.startsWith(`${worktree}/`)) throw new Error("resolved manifest path escaped the GitOps worktree"); +if (enabled && manifest !== null) { + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, manifest, "utf8"); +} else { + try { + unlinkSync(targetPath); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} + +run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree); +run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree); +run("git", ["add", "-A"], worktree); +const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0; +if (changed) { + const action = enabled ? "deploy" : "remove"; + run("git", ["commit", "-m", `unidesk-host: ${action} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree); +} + +run("git", ["remote", "set-url", "origin", writeUrl], worktree); +let pushAttempts = 0; +if (changed) { + let pushed = false; + for (let attempt = 1; attempt <= 3; attempt += 1) { + pushAttempts = attempt; + const push = run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true); + if (push.status === 0) { + pushed = true; + break; + } + run("git", ["fetch", "origin", branch], worktree); + const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true); + if (rebase.status !== 0) { + run("git", ["rebase", "--abort"], worktree, true); + throw new Error(`GitOps rebase failed after push attempt ${attempt}`); + } + } + if (!pushed) throw new Error("GitOps push failed after 3 attempts"); +} + +const head = run("git", ["rev-parse", "HEAD"], worktree, !enabled && !changed); +const gitopsCommit = head.status === 0 ? head.stdout.trim() : null; +process.stdout.write(`${JSON.stringify({ + ok: true, + phase: "gitops-publish", + status: enabled ? (changed ? "built" : "reused") : "disabled", + imageStatus: enabled ? (changed ? "built" : "reused") : "disabled", + sourceCommit, + digest, + digestRef, + gitopsCommit, + changed, + pushAttempts, + valuesPrinted: false, +})}\n`); +} + +if (!process.execArgv.includes("--check")) main(); diff --git a/scripts/native/deploy/render-unidesk-host-k8s.mjs b/scripts/native/deploy/render-unidesk-host-k8s.mjs index f614cd2a..3ff7b8a7 100755 --- a/scripts/native/deploy/render-unidesk-host-k8s.mjs +++ b/scripts/native/deploy/render-unidesk-host-k8s.mjs @@ -122,12 +122,14 @@ const configPath = process.argv[2] ?? "config/unidesk-host-k8s.yaml"; const config = Bun.YAML.parse(readFileSync(configPath, "utf8")); const target = record(config.target, "target"); const runtime = record(config.runtime, "runtime"); +const delivery = config.delivery === undefined ? null : record(config.delivery, "delivery"); const images = record(config.images, "images"); const database = record(config.database, "database"); const services = record(config.services, "services"); const backend = record(services.backendCore, "services.backendCore"); const frontend = record(services.frontend, "services.frontend"); const decisionCenter = optionalRecord(services.decisionCenter, "services.decisionCenter"); +const todoNote = optionalRecord(services.todoNote, "services.todoNote"); const runtimeConfig = record(config.runtimeConfig, "runtimeConfig"); const runtimeAuth = record(config.runtimeAuth, "runtimeAuth"); const runtimeAuthSourceRef = record(runtimeAuth.sourceRef, "runtimeAuth.sourceRef"); @@ -152,6 +154,10 @@ for (const key of requiredSecretKeys) { } const secretFingerprint = sha(requiredSecretKeys.map((key) => `${key}=${secrets[key]}`).join("\n")); const runtimeConfigName = "unidesk-runtime-config"; +const deliveryServiceRef = delivery === null ? null : required(delivery.serviceRef, "delivery.serviceRef"); +if (delivery !== null && delivery.enabled !== true && delivery.enabled !== false) { + throw new Error("delivery.enabled must be boolean"); +} const databaseMode = database.mode === undefined ? "k8s-postgres" : required(database.mode, "database.mode"); if (databaseMode !== "k8s-postgres" && databaseMode !== "host-native") throw new Error("database.mode must be k8s-postgres or host-native"); const dbService = required(database.serviceName, "database.serviceName"); @@ -162,21 +168,21 @@ if (secrets[secretKeys.databaseUrl] !== `postgres://${secrets[secretKeys.postgre throw new Error(`DATABASE_URL in ${sourcePath} must target ${dbHost}:${dbPort}/${dbName}`); } -function readDecisionCenterDatabase(decision) { - if (decision === null) return null; - const database = record(decision.database, "services.decisionCenter.database"); - const sourceRef = record(database.sourceRef, "services.decisionCenter.database.sourceRef"); - const sourcePath = resolve(required(sourceRef.path, "services.decisionCenter.database.sourceRef.path")); - const sourceKey = required(sourceRef.key, "services.decisionCenter.database.sourceRef.key"); +function readServiceDatabase(service, servicePath) { + if (service === null) return null; + const database = record(service.database, `${servicePath}.database`); + const sourceRef = record(database.sourceRef, `${servicePath}.database.sourceRef`); + const sourcePath = resolve(required(sourceRef.path, `${servicePath}.database.sourceRef.path`)); + const sourceKey = required(sourceRef.key, `${servicePath}.database.sourceRef.key`); const sourceValues = readEnvFile(sourcePath); const value = sourceValues[sourceKey]; if (typeof value !== "string" || value.length === 0) throw new Error(`missing ${sourceKey} in ${sourcePath}`); return { sourcePath, - sourceRefPath: required(sourceRef.path, "services.decisionCenter.database.sourceRef.path"), + sourceRefPath: required(sourceRef.path, `${servicePath}.database.sourceRef.path`), sourceKey, - secretName: required(database.secretName, "services.decisionCenter.database.secretName"), - secretKey: required(database.secretKey, "services.decisionCenter.database.secretKey"), + secretName: required(database.secretName, `${servicePath}.database.secretName`), + secretKey: required(database.secretKey, `${servicePath}.database.secretKey`), value: removeUrlSearchParam(value, "uselibpqcompat"), fingerprint: sha(`${sourceKey}=${removeUrlSearchParam(value, "uselibpqcompat")}`), }; @@ -194,37 +200,37 @@ function readFileSource(sourceRef, path) { }; } -function readDecisionCenterStorage(decision) { - if (decision === null) return null; - const storage = optionalRecord(decision.storage, "services.decisionCenter.storage"); +function readServiceStorage(service, servicePath) { + if (service === null) return null; + const storage = optionalRecord(service.storage, `${servicePath}.storage`); if (storage === null) return null; - const primary = required(storage.primary, "services.decisionCenter.storage.primary"); - if (primary !== "postgres" && primary !== "github-repo") throw new Error("services.decisionCenter.storage.primary must be postgres or github-repo"); - const github = primary === "github-repo" ? record(storage.github, "services.decisionCenter.storage.github") : null; + const primary = required(storage.primary, `${servicePath}.storage.primary`); + if (primary !== "postgres" && primary !== "github-repo") throw new Error(`${servicePath}.storage.primary must be postgres or github-repo`); + const github = primary === "github-repo" ? record(storage.github, `${servicePath}.storage.github`) : null; if (github === null) return { primary }; - const ssh = record(github.ssh, "services.decisionCenter.storage.github.ssh"); - const privateKey = record(ssh.privateKey, "services.decisionCenter.storage.github.ssh.privateKey"); - const knownHosts = record(ssh.knownHosts, "services.decisionCenter.storage.github.ssh.knownHosts"); - const privateKeySource = readFileSource(record(privateKey.sourceRef, "services.decisionCenter.storage.github.ssh.privateKey.sourceRef"), "services.decisionCenter.storage.github.ssh.privateKey.sourceRef"); - const knownHostsSource = readFileSource(record(knownHosts.sourceRef, "services.decisionCenter.storage.github.ssh.knownHosts.sourceRef"), "services.decisionCenter.storage.github.ssh.knownHosts.sourceRef"); + const ssh = record(github.ssh, `${servicePath}.storage.github.ssh`); + const privateKey = record(ssh.privateKey, `${servicePath}.storage.github.ssh.privateKey`); + const knownHosts = record(ssh.knownHosts, `${servicePath}.storage.github.ssh.knownHosts`); + const privateKeySource = readFileSource(record(privateKey.sourceRef, `${servicePath}.storage.github.ssh.privateKey.sourceRef`), `${servicePath}.storage.github.ssh.privateKey.sourceRef`); + const knownHostsSource = readFileSource(record(knownHosts.sourceRef, `${servicePath}.storage.github.ssh.knownHosts.sourceRef`), `${servicePath}.storage.github.ssh.knownHosts.sourceRef`); return { primary, github: { - repo: required(github.repo, "services.decisionCenter.storage.github.repo"), - sshUrl: required(github.sshUrl, "services.decisionCenter.storage.github.sshUrl"), - branch: required(github.branch, "services.decisionCenter.storage.github.branch"), - basePath: required(github.basePath, "services.decisionCenter.storage.github.basePath"), - worktreePath: required(github.worktreePath, "services.decisionCenter.storage.github.worktreePath"), - commitMessagePrefix: required(github.commitMessagePrefix, "services.decisionCenter.storage.github.commitMessagePrefix"), + repo: required(github.repo, `${servicePath}.storage.github.repo`), + sshUrl: required(github.sshUrl, `${servicePath}.storage.github.sshUrl`), + branch: required(github.branch, `${servicePath}.storage.github.branch`), + basePath: required(github.basePath, `${servicePath}.storage.github.basePath`), + worktreePath: required(github.worktreePath, `${servicePath}.storage.github.worktreePath`), + commitMessagePrefix: required(github.commitMessagePrefix, `${servicePath}.storage.github.commitMessagePrefix`), author: { - name: required(record(github.author, "services.decisionCenter.storage.github.author").name, "services.decisionCenter.storage.github.author.name"), - email: required(record(github.author, "services.decisionCenter.storage.github.author").email, "services.decisionCenter.storage.github.author.email"), + name: required(record(github.author, `${servicePath}.storage.github.author`).name, `${servicePath}.storage.github.author.name`), + email: required(record(github.author, `${servicePath}.storage.github.author`).email, `${servicePath}.storage.github.author.email`), }, ssh: { - secretName: required(ssh.secretName, "services.decisionCenter.storage.github.ssh.secretName"), - mountPath: required(ssh.mountPath, "services.decisionCenter.storage.github.ssh.mountPath"), - privateKeyKey: required(privateKey.secretKey, "services.decisionCenter.storage.github.ssh.privateKey.secretKey"), - knownHostsKey: required(knownHosts.secretKey, "services.decisionCenter.storage.github.ssh.knownHosts.secretKey"), + secretName: required(ssh.secretName, `${servicePath}.storage.github.ssh.secretName`), + mountPath: required(ssh.mountPath, `${servicePath}.storage.github.ssh.mountPath`), + privateKeyKey: required(privateKey.secretKey, `${servicePath}.storage.github.ssh.privateKey.secretKey`), + knownHostsKey: required(knownHosts.secretKey, `${servicePath}.storage.github.ssh.knownHosts.secretKey`), privateKeySource, knownHostsSource, fingerprint: sha(`${privateKeySource.fingerprint}\n${knownHostsSource.fingerprint}`), @@ -233,53 +239,65 @@ function readDecisionCenterStorage(decision) { }; } -const decisionCenterDatabase = readDecisionCenterDatabase(decisionCenter); -const decisionCenterStorage = readDecisionCenterStorage(decisionCenter); -const effectiveMicroservicesJson = decisionCenter === null - ? required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson") - : JSON.stringify([ - { - id: "decision-center", - name: "Decision Center", - providerId: required(target.id, "target.id"), - description: "Decision Center is managed by the NC01 YAML-first k8s runtime for UniDesk decisions, requirements, meetings, and work diaries.", - repository: { - url: required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"), - commitId: required(runtime.commit, "runtime.commit"), - dockerfile: required(record(decisionCenter.repository, "services.decisionCenter.repository").dockerfile, "services.decisionCenter.repository.dockerfile"), - composeFile: required(runtime.deployRef, "runtime.deployRef"), - composeService: required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName"), - containerName: required(decisionCenter.containerName, "services.decisionCenter.containerName"), - }, - backend: { - nodeBaseUrl: `http://${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local:${required(String(decisionCenter.containerPort), "services.decisionCenter.containerPort")}`, - nodeBindHost: `${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local`, - nodePort: positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort"), - proxyMode: "cluster-service-http", - frontendOnly: true, - public: false, - allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"], - allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"], - healthPath: required(decisionCenter.healthPath, "services.decisionCenter.healthPath"), - timeoutMs: 30000, - }, - development: { - providerId: required(target.id, "target.id"), - sshPassthrough: true, - worktreePath: required(record(decisionCenter.repository, "services.decisionCenter.repository").worktreePath, "services.decisionCenter.repository.worktreePath"), - }, - frontend: { - route: required(record(decisionCenter.frontend, "services.decisionCenter.frontend").route, "services.decisionCenter.frontend.route"), - integrated: record(decisionCenter.frontend, "services.decisionCenter.frontend").integrated !== false, - }, - deployment: { - mode: "internal-sidecar", - namespace, - expectedNodeIds: [required(target.id, "target.id")], - activeNodeId: required(target.id, "target.id"), - }, - }, - ]); +function managedMicroservice(service, servicePath, id, name, description) { + return { + id, + name, + providerId: required(target.id, "target.id"), + description, + repository: { + url: required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`), + commitId: required(runtime.commit, "runtime.commit"), + dockerfile: required(record(service.repository, `${servicePath}.repository`).dockerfile, `${servicePath}.repository.dockerfile`), + composeFile: required(runtime.deployRef, "runtime.deployRef"), + composeService: required(service.deploymentName, `${servicePath}.deploymentName`), + containerName: required(service.containerName, `${servicePath}.containerName`), + }, + backend: { + nodeBaseUrl: `http://${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local:${required(String(service.containerPort), `${servicePath}.containerPort`)}`, + nodeBindHost: `${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local`, + nodePort: positiveInteger(service.containerPort, `${servicePath}.containerPort`), + proxyMode: "cluster-service-http", + frontendOnly: true, + public: false, + allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"], + allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"], + healthPath: required(service.healthPath, `${servicePath}.healthPath`), + timeoutMs: 30000, + }, + development: { + providerId: required(target.id, "target.id"), + sshPassthrough: true, + worktreePath: required(record(service.repository, `${servicePath}.repository`).worktreePath, `${servicePath}.repository.worktreePath`), + }, + frontend: { + route: required(record(service.frontend, `${servicePath}.frontend`).route, `${servicePath}.frontend.route`), + integrated: record(service.frontend, `${servicePath}.frontend`).integrated !== false, + }, + deployment: { + mode: "internal-sidecar", + namespace, + expectedNodeIds: [required(target.id, "target.id")], + activeNodeId: required(target.id, "target.id"), + }, + }; +} + +const decisionCenterDatabase = readServiceDatabase(decisionCenter, "services.decisionCenter"); +const decisionCenterStorage = readServiceStorage(decisionCenter, "services.decisionCenter"); +const todoNoteDatabase = readServiceDatabase(todoNote, "services.todoNote"); +const todoNoteStorage = readServiceStorage(todoNote, "services.todoNote"); +const configuredMicroservices = JSON.parse(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson")); +if (!Array.isArray(configuredMicroservices)) throw new Error("runtimeConfig.microservicesJson must encode an array"); +const managedMicroservices = [ + ...(decisionCenter === null ? [] : [managedMicroservice(decisionCenter, "services.decisionCenter", "decision-center", "Decision Center", "NC01 YAML-first Decision Center for decisions, requirements, meetings, and work diaries.")]), + ...(todoNote === null ? [] : [managedMicroservice(todoNote, "services.todoNote", "todo-note", "Todo Note", "NC01 YAML-first Todo Note backed by the shared decision-center-data GitHub repository.")]), +]; +const managedIds = new Set(managedMicroservices.map((service) => service.id)); +const effectiveMicroservicesJson = JSON.stringify([ + ...configuredMicroservices.filter((service) => typeof service !== "object" || service === null || !managedIds.has(service.id)), + ...managedMicroservices, +]); const runtimeConfigFingerprint = sha(JSON.stringify({ ...runtimeConfig, microservicesJson: effectiveMicroservicesJson })); const docs = []; @@ -331,39 +349,60 @@ data: MICROSERVICES_JSON: ${yamlScalar(effectiveMicroservicesJson)} NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))} no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`); -if (decisionCenter !== null && decisionCenterDatabase !== null) { -docs.push(`apiVersion: v1 -kind: Secret -metadata: - name: ${decisionCenterDatabase.secretName} - namespace: ${namespace} - labels: -${indent(labels(config), 4)} - app.kubernetes.io/name: decision-center - annotations: - unidesk.ai/source-ref: ${yamlScalar(decisionCenterDatabase.sourceRefPath)} - unidesk.ai/fingerprint: ${yamlScalar(decisionCenterDatabase.fingerprint)} -type: Opaque -stringData: - ${decisionCenterDatabase.secretKey}: ${yamlScalar(decisionCenterDatabase.value)}`); +const serviceDatabaseSecrets = new Map(); +for (const databaseSecret of [decisionCenterDatabase, todoNoteDatabase].filter(Boolean)) { + const existing = serviceDatabaseSecrets.get(databaseSecret.secretName); + if (existing !== undefined && (existing.secretKey !== databaseSecret.secretKey || existing.value !== databaseSecret.value)) { + throw new Error(`conflicting managed service database Secret: ${databaseSecret.secretName}`); + } + serviceDatabaseSecrets.set(databaseSecret.secretName, databaseSecret); } -if (decisionCenterStorage?.github !== undefined) { -docs.push(`apiVersion: v1 +for (const databaseSecret of serviceDatabaseSecrets.values()) { + docs.push(`apiVersion: v1 kind: Secret metadata: - name: ${decisionCenterStorage.github.ssh.secretName} + name: ${databaseSecret.secretName} namespace: ${namespace} labels: ${indent(labels(config), 4)} - app.kubernetes.io/name: decision-center annotations: - unidesk.ai/private-key-source-ref: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.sourceRefPath)} - unidesk.ai/known-hosts-source-ref: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.sourceRefPath)} - unidesk.ai/fingerprint: ${yamlScalar(decisionCenterStorage.github.ssh.fingerprint)} + unidesk.ai/source-ref: ${yamlScalar(databaseSecret.sourceRefPath)} + unidesk.ai/fingerprint: ${yamlScalar(databaseSecret.fingerprint)} type: Opaque stringData: - ${decisionCenterStorage.github.ssh.privateKeyKey}: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.value)} - ${decisionCenterStorage.github.ssh.knownHostsKey}: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.value)}`); + ${databaseSecret.secretKey}: ${yamlScalar(databaseSecret.value)}`); +} +const serviceGitSecrets = new Map(); +for (const storage of [decisionCenterStorage, todoNoteStorage]) { + if (storage?.github === undefined) continue; + const gitSecret = storage.github.ssh; + const existing = serviceGitSecrets.get(gitSecret.secretName); + if (existing !== undefined && ( + existing.privateKeyKey !== gitSecret.privateKeyKey + || existing.knownHostsKey !== gitSecret.knownHostsKey + || existing.privateKeySource.value !== gitSecret.privateKeySource.value + || existing.knownHostsSource.value !== gitSecret.knownHostsSource.value + )) { + throw new Error(`conflicting managed service Git SSH Secret: ${gitSecret.secretName}`); + } + serviceGitSecrets.set(gitSecret.secretName, gitSecret); +} +for (const gitSecret of serviceGitSecrets.values()) { + docs.push(`apiVersion: v1 +kind: Secret +metadata: + name: ${gitSecret.secretName} + namespace: ${namespace} + labels: +${indent(labels(config), 4)} + annotations: + unidesk.ai/private-key-source-ref: ${yamlScalar(gitSecret.privateKeySource.sourceRefPath)} + unidesk.ai/known-hosts-source-ref: ${yamlScalar(gitSecret.knownHostsSource.sourceRefPath)} + unidesk.ai/fingerprint: ${yamlScalar(gitSecret.fingerprint)} +type: Opaque +stringData: + ${gitSecret.privateKeyKey}: ${yamlScalar(gitSecret.privateKeySource.value)} + ${gitSecret.knownHostsKey}: ${yamlScalar(gitSecret.knownHostsSource.value)}`); } if (databaseMode === "k8s-postgres") { docs.push(`apiVersion: v1 @@ -585,44 +624,77 @@ ${indent(resourceBlock(backend.resources), 10)} volumes: - name: logs emptyDir: {}`); -if (decisionCenter !== null) { +function managedStorageEnv(envPrefix, storage) { + const lines = [ + `- name: ${envPrefix}_STORAGE_PRIMARY`, + ` value: ${yamlScalar(storage?.primary ?? "postgres")}`, + ]; + if (storage?.github === undefined) return lines.join("\n"); + lines.push( + `- name: ${envPrefix}_GIT_REPO`, + ` value: ${yamlScalar(storage.github.repo)}`, + `- name: ${envPrefix}_GIT_REPO_SSH_URL`, + ` value: ${yamlScalar(storage.github.sshUrl)}`, + `- name: ${envPrefix}_GIT_BRANCH`, + ` value: ${yamlScalar(storage.github.branch)}`, + `- name: ${envPrefix}_GIT_BASE_PATH`, + ` value: ${yamlScalar(storage.github.basePath)}`, + `- name: ${envPrefix}_GIT_WORKTREE`, + ` value: ${yamlScalar(storage.github.worktreePath)}`, + `- name: ${envPrefix}_GIT_AUTHOR_NAME`, + ` value: ${yamlScalar(storage.github.author.name)}`, + `- name: ${envPrefix}_GIT_AUTHOR_EMAIL`, + ` value: ${yamlScalar(storage.github.author.email)}`, + `- name: ${envPrefix}_GIT_COMMIT_PREFIX`, + ` value: ${yamlScalar(storage.github.commitMessagePrefix)}`, + "- name: GIT_SSH_COMMAND", + ` value: ${yamlScalar(`ssh -i ${storage.github.ssh.mountPath}/${storage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${storage.github.ssh.mountPath}/${storage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`, + ); + return lines.join("\n"); +} + +function appendManagedServiceDeployment({ service, servicePath, serviceId, imageKey, envPrefix, databaseConfig, storageConfig }) { +if (service === null) return; +if (deliveryServiceRef === servicePath) return; +if (databaseConfig === null) throw new Error(`${servicePath}.database is required`); +const github = storageConfig?.github; docs.push(`apiVersion: v1 kind: Service metadata: - name: ${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")} + name: ${required(service.serviceName, `${servicePath}.serviceName`)} namespace: ${namespace} labels: ${indent(labels(config), 4)} - app.kubernetes.io/name: decision-center + app.kubernetes.io/name: ${serviceId} spec: type: ClusterIP selector: - app.kubernetes.io/name: decision-center + app.kubernetes.io/name: ${serviceId} ports: - name: http - port: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")} + port: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)} targetPort: http`); docs.push(`apiVersion: apps/v1 kind: Deployment metadata: - name: ${required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName")} + name: ${required(service.deploymentName, `${servicePath}.deploymentName`)} namespace: ${namespace} labels: ${indent(labels(config), 4)} - app.kubernetes.io/name: decision-center + app.kubernetes.io/name: ${serviceId} spec: - replicas: 1 + replicas: ${positiveInteger(service.replicas, `${servicePath}.replicas`)} selector: matchLabels: - app.kubernetes.io/name: decision-center + app.kubernetes.io/name: ${serviceId} template: metadata: labels: - app.kubernetes.io/name: decision-center + app.kubernetes.io/name: ${serviceId} app.kubernetes.io/part-of: unidesk annotations: - unidesk.ai/secret-fingerprint: ${yamlScalar(decisionCenterDatabase?.fingerprint ?? "")} - unidesk.ai/storage-secret-fingerprint: ${yamlScalar(decisionCenterStorage?.github?.ssh.fingerprint ?? "")} + unidesk.ai/secret-fingerprint: ${yamlScalar(databaseConfig.fingerprint)} + unidesk.ai/storage-secret-fingerprint: ${yamlScalar(github?.ssh.fingerprint ?? "")} unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)} spec: initContainers: @@ -633,65 +705,48 @@ spec: - name: DATABASE_URL valueFrom: secretKeyRef: - name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")} - key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")} + name: ${databaseConfig.secretName} + key: ${databaseConfig.secretKey} command: ["sh", "-ec", "db_url=\\"$(printf '%s' \\"$DATABASE_URL\\" | sed 's/[?&]uselibpqcompat=true//g')\\"; until psql \\"$db_url\\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"] containers: - - name: ${required(decisionCenter.containerName, "services.decisionCenter.containerName")} - image: ${required(images.decisionCenter, "images.decisionCenter")} + - name: ${required(service.containerName, `${servicePath}.containerName`)} + image: ${required(images[imageKey], `images.${imageKey}`)} imagePullPolicy: IfNotPresent ports: - name: http - containerPort: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")} + containerPort: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)} env: - name: HOST value: "0.0.0.0" - name: PORT - value: ${yamlScalar(decisionCenter.containerPort)} + value: ${yamlScalar(service.containerPort)} - name: DATABASE_URL valueFrom: secretKeyRef: - name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")} - key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")} + name: ${databaseConfig.secretName} + key: ${databaseConfig.secretKey} - name: DATABASE_POOL_MAX - value: "2" + value: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))} - name: UNIDESK_DEPLOY_SERVICE_ID - value: "decision-center" + value: ${yamlScalar(serviceId)} - name: UNIDESK_DEPLOY_REPO - value: ${yamlScalar(required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"))} + value: ${yamlScalar(required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`))} - name: UNIDESK_DEPLOY_COMMIT value: ${yamlScalar(required(runtime.commit, "runtime.commit"))} - name: UNIDESK_DEPLOY_REQUESTED_COMMIT value: ${yamlScalar(required(runtime.commit, "runtime.commit"))} - name: LOG_FILE - value: ${yamlScalar(required(decisionCenter.logFile, "services.decisionCenter.logFile"))} - - name: DECISION_CENTER_STORAGE_PRIMARY - value: ${yamlScalar(decisionCenterStorage?.primary ?? "postgres")} -${decisionCenterStorage?.github !== undefined ? indent(`- name: DECISION_CENTER_GIT_REPO_SSH_URL - value: ${yamlScalar(decisionCenterStorage.github.sshUrl)} -- name: DECISION_CENTER_GIT_BRANCH - value: ${yamlScalar(decisionCenterStorage.github.branch)} -- name: DECISION_CENTER_GIT_BASE_PATH - value: ${yamlScalar(decisionCenterStorage.github.basePath)} -- name: DECISION_CENTER_GIT_WORKTREE - value: ${yamlScalar(decisionCenterStorage.github.worktreePath)} -- name: DECISION_CENTER_GIT_AUTHOR_NAME - value: ${yamlScalar(decisionCenterStorage.github.author.name)} -- name: DECISION_CENTER_GIT_AUTHOR_EMAIL - value: ${yamlScalar(decisionCenterStorage.github.author.email)} -- name: DECISION_CENTER_GIT_COMMIT_PREFIX - value: ${yamlScalar(decisionCenterStorage.github.commitMessagePrefix)} -- name: GIT_SSH_COMMAND - value: ${yamlScalar(`ssh -i ${decisionCenterStorage.github.ssh.mountPath}/${decisionCenterStorage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${decisionCenterStorage.github.ssh.mountPath}/${decisionCenterStorage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`, 12) : ""} + value: ${yamlScalar(required(service.logFile, `${servicePath}.logFile`))} +${indent(managedStorageEnv(envPrefix, storageConfig), 12)} volumeMounts: - name: logs mountPath: /var/log/unidesk -${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh - mountPath: ${yamlScalar(decisionCenterStorage.github.ssh.mountPath)} - readOnly: true`, 12) : ""} +${github === undefined ? "" : indent(`- name: github-ssh + mountPath: ${yamlScalar(github.ssh.mountPath)} + readOnly: true`, 12)} readinessProbe: httpGet: - path: ${required(decisionCenter.healthPath, "services.decisionCenter.healthPath")} + path: ${required(service.healthPath, `${servicePath}.healthPath`)} port: http periodSeconds: 5 timeoutSeconds: 3 @@ -710,18 +765,34 @@ ${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 30 -${indent(resourceBlock(decisionCenter.resources), 10)} +${indent(resourceBlock(service.resources), 10)} volumes: - name: logs - emptyDir: {}`); -if (decisionCenterStorage?.github !== undefined) { -docs[docs.length - 1] += ` - - name: github-ssh - secret: - secretName: ${decisionCenterStorage.github.ssh.secretName} - defaultMode: 0400`; -} + emptyDir: {} +${github === undefined ? "" : indent(`- name: github-ssh + secret: + secretName: ${github.ssh.secretName} + defaultMode: 0400`, 8)}`); } + +appendManagedServiceDeployment({ + service: decisionCenter, + servicePath: "services.decisionCenter", + serviceId: "decision-center", + imageKey: "decisionCenter", + envPrefix: "DECISION_CENTER", + databaseConfig: decisionCenterDatabase, + storageConfig: decisionCenterStorage, +}); +appendManagedServiceDeployment({ + service: todoNote, + servicePath: "services.todoNote", + serviceId: "todo-note", + imageKey: "todoNote", + envPrefix: "TODO_NOTE", + databaseConfig: todoNoteDatabase, + storageConfig: todoNoteStorage, +}); docs.push(`apiVersion: v1 kind: Service metadata: diff --git a/scripts/native/deploy/render-unidesk-host-service.mjs b/scripts/native/deploy/render-unidesk-host-service.mjs new file mode 100755 index 00000000..653671c7 --- /dev/null +++ b/scripts/native/deploy/render-unidesk-host-service.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +function option(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + const value = process.argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function record(value, path) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value; +} + +function required(value, path) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`); + return value; +} + +function positiveInteger(value, path) { + if (!Number.isInteger(value) || value < 1) throw new Error(`${path} must be a positive integer`); + return value; +} + +function booleanValue(value, path) { + if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); + return value; +} + +function stringValue(value, path) { + if (typeof value !== "string") throw new Error(`${path} must be a string`); + return value; +} + +function valueAt(root, reference) { + return reference.split(".").reduce((value, key) => record(value, reference)[key], root); +} + +function sha(value) { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +function secretEnv(name, secretName, secretKey) { + return { name, valueFrom: { secretKeyRef: { name: secretName, key: secretKey } } }; +} + +function main() { +const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml"; +const serviceRef = required(option("--service-ref"), "--service-ref"); +const image = required(option("--image"), "--image"); +const sourceCommit = required(option("--source-commit"), "--source-commit"); +if (!/^services\.[A-Za-z][A-Za-z0-9]*$/u.test(serviceRef)) throw new Error("--service-ref must identify one services. entry"); +if (!/@sha256:[0-9a-f]{64}$/u.test(image)) throw new Error("--image must be digest pinned"); +if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA"); + +const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath); +const target = record(config.target, "target"); +const runtime = record(config.runtime, "runtime"); +const images = record(config.images, "images"); +const service = record(valueAt(config, serviceRef), serviceRef); +const database = record(service.database, `${serviceRef}.database`); +const storage = record(service.storage, `${serviceRef}.storage`); +const repository = record(service.repository, `${serviceRef}.repository`); +const reminder = record(service.reminder, `${serviceRef}.reminder`); +const reminderTransport = record(reminder.transport, `${serviceRef}.reminder.transport`); +const resources = record(service.resources, `${serviceRef}.resources`); +const github = storage.primary === "github-repo" ? record(storage.github, `${serviceRef}.storage.github`) : null; +const githubSsh = github === null ? null : record(github.ssh, `${serviceRef}.storage.github.ssh`); +const privateKey = githubSsh === null ? null : record(githubSsh.privateKey, `${serviceRef}.storage.github.ssh.privateKey`); +const knownHosts = githubSsh === null ? null : record(githubSsh.knownHosts, `${serviceRef}.storage.github.ssh.knownHosts`); +const namespace = required(target.namespace, "target.namespace"); +const serviceId = required(service.serviceId, `${serviceRef}.serviceId`); +const serviceName = required(service.serviceName, `${serviceRef}.serviceName`); +const deploymentName = required(service.deploymentName, `${serviceRef}.deploymentName`); +const containerName = required(service.containerName, `${serviceRef}.containerName`); +const containerPort = positiveInteger(service.containerPort, `${serviceRef}.containerPort`); +const databaseSecretName = required(database.secretName, `${serviceRef}.database.secretName`); +const databaseSecretKey = required(database.secretKey, `${serviceRef}.database.secretKey`); +const storageEnvPrefix = required(service.storageEnvPrefix, `${serviceRef}.storageEnvPrefix`); +const reminderTransportType = required(reminderTransport.type, `${serviceRef}.reminder.transport.type`); +if (reminderTransportType !== "claudeqq") throw new Error(`${serviceRef}.reminder.transport.type must be claudeqq`); +const reminderTargetType = required(reminderTransport.targetType, `${serviceRef}.reminder.transport.targetType`); +if (reminderTargetType !== "private" && reminderTargetType !== "group") throw new Error(`${serviceRef}.reminder.transport.targetType must be private or group`); +const reminderUserId = stringValue(reminderTransport.userId, `${serviceRef}.reminder.transport.userId`); +const reminderGroupId = stringValue(reminderTransport.groupId, `${serviceRef}.reminder.transport.groupId`); +if (booleanValue(reminder.enabled, `${serviceRef}.reminder.enabled`) && reminderTargetType === "private" && reminderUserId.length === 0) throw new Error(`${serviceRef}.reminder.transport.userId is required for private reminders`); +if (reminder.enabled && reminderTargetType === "group" && reminderGroupId.length === 0) throw new Error(`${serviceRef}.reminder.transport.groupId is required for group reminders`); +const configuredImage = required(valueAt(config, required(service.imageRef, `${serviceRef}.imageRef`)), service.imageRef); +const configFingerprint = sha(JSON.stringify({ target, runtime, images, serviceRef, service, configuredImage })); +const commonLabels = { + "app.kubernetes.io/name": serviceId, + "app.kubernetes.io/part-of": "unidesk", + "app.kubernetes.io/managed-by": "argocd", + "unidesk.ai/deployment": required(record(config.metadata, "metadata").name, "metadata.name"), + "unidesk.ai/target": required(target.id, "target.id"), +}; + +const env = [ + { name: "HOST", value: "0.0.0.0" }, + { name: "PORT", value: String(containerPort) }, + secretEnv("DATABASE_URL", databaseSecretName, databaseSecretKey), + { name: "DATABASE_POOL_MAX", value: required(record(config.runtimeConfig, "runtimeConfig").databasePoolMax, "runtimeConfig.databasePoolMax") }, + { name: "UNIDESK_DEPLOY_SERVICE_ID", value: serviceId }, + { name: "UNIDESK_DEPLOY_REPO", value: required(repository.url, `${serviceRef}.repository.url`) }, + { name: "UNIDESK_DEPLOY_COMMIT", value: sourceCommit }, + { name: "UNIDESK_DEPLOY_REQUESTED_COMMIT", value: sourceCommit }, + { name: "LOG_FILE", value: required(service.logFile, `${serviceRef}.logFile`) }, + { name: `${storageEnvPrefix}_STORAGE_PRIMARY`, value: required(storage.primary, `${serviceRef}.storage.primary`) }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED", value: String(reminder.enabled) }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL", value: required(reminderTransport.baseUrl, `${serviceRef}.reminder.transport.baseUrl`) }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE", value: reminderTargetType }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID", value: reminderUserId }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID", value: reminderGroupId }, + { name: "TODO_NOTE_REMINDER_LEAD_MINUTES", value: String(positiveInteger(reminder.leadMinutes, `${serviceRef}.reminder.leadMinutes`)) }, + { name: "TODO_NOTE_REMINDER_SCAN_INTERVAL_MS", value: String(positiveInteger(reminder.scanIntervalMs, `${serviceRef}.reminder.scanIntervalMs`)) }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS", value: String(positiveInteger(reminderTransport.timeoutMs, `${serviceRef}.reminder.transport.timeoutMs`)) }, + { name: "TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS", value: String(positiveInteger(reminderTransport.sendAttempts, `${serviceRef}.reminder.transport.sendAttempts`)) }, +]; + +const volumeMounts = [{ name: "logs", mountPath: "/var/log/unidesk" }]; +const volumes = [{ name: "logs", emptyDir: {} }]; +if (github !== null && githubSsh !== null && privateKey !== null && knownHosts !== null) { + const mountPath = required(githubSsh.mountPath, `${serviceRef}.storage.github.ssh.mountPath`); + const privateKeyName = required(privateKey.secretKey, `${serviceRef}.storage.github.ssh.privateKey.secretKey`); + const knownHostsName = required(knownHosts.secretKey, `${serviceRef}.storage.github.ssh.knownHosts.secretKey`); + env.push( + { name: `${storageEnvPrefix}_GIT_REPO`, value: required(github.repo, `${serviceRef}.storage.github.repo`) }, + { name: `${storageEnvPrefix}_GIT_REPO_SSH_URL`, value: required(github.sshUrl, `${serviceRef}.storage.github.sshUrl`) }, + { name: `${storageEnvPrefix}_GIT_BRANCH`, value: required(github.branch, `${serviceRef}.storage.github.branch`) }, + { name: `${storageEnvPrefix}_GIT_BASE_PATH`, value: required(github.basePath, `${serviceRef}.storage.github.basePath`) }, + { name: `${storageEnvPrefix}_GIT_WORKTREE`, value: required(github.worktreePath, `${serviceRef}.storage.github.worktreePath`) }, + { name: `${storageEnvPrefix}_GIT_AUTHOR_NAME`, value: required(record(github.author, `${serviceRef}.storage.github.author`).name, `${serviceRef}.storage.github.author.name`) }, + { name: `${storageEnvPrefix}_GIT_AUTHOR_EMAIL`, value: required(record(github.author, `${serviceRef}.storage.github.author`).email, `${serviceRef}.storage.github.author.email`) }, + { name: `${storageEnvPrefix}_GIT_COMMIT_PREFIX`, value: required(github.commitMessagePrefix, `${serviceRef}.storage.github.commitMessagePrefix`) }, + { name: "GIT_SSH_COMMAND", value: `ssh -i ${mountPath}/${privateKeyName} -o UserKnownHostsFile=${mountPath}/${knownHostsName} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes` }, + ); + volumeMounts.push({ name: "github-ssh", mountPath, readOnly: true }); + volumes.push({ name: "github-ssh", secret: { secretName: required(githubSsh.secretName, `${serviceRef}.storage.github.ssh.secretName`), defaultMode: 256 } }); +} + +const objects = [ + { + apiVersion: "v1", + kind: "Service", + metadata: { name: serviceName, namespace, labels: commonLabels }, + spec: { + type: "ClusterIP", + selector: { "app.kubernetes.io/name": serviceId }, + ports: [{ name: "http", port: containerPort, targetPort: "http" }], + }, + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: deploymentName, namespace, labels: commonLabels }, + spec: { + replicas: positiveInteger(service.replicas, `${serviceRef}.replicas`), + selector: { matchLabels: { "app.kubernetes.io/name": serviceId } }, + template: { + metadata: { + labels: commonLabels, + annotations: { + "unidesk.ai/source-commit": sourceCommit, + "unidesk.ai/config-fingerprint": `sha256:${configFingerprint}`, + }, + }, + spec: { + initContainers: [{ + name: "wait-database", + image: required(images.postgres, "images.postgres"), + imagePullPolicy: "IfNotPresent", + env: [secretEnv("DATABASE_URL", databaseSecretName, databaseSecretKey)], + command: ["sh", "-ec", "db_url=\"$(printf '%s' \"$DATABASE_URL\" | sed 's/[?&]uselibpqcompat=true//g')\"; until psql \"$db_url\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"], + }], + containers: [{ + name: containerName, + image, + imagePullPolicy: "IfNotPresent", + ports: [{ name: "http", containerPort }], + env, + volumeMounts, + readinessProbe: { httpGet: { path: required(service.healthPath, `${serviceRef}.healthPath`), port: "http" }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 18 }, + livenessProbe: { httpGet: { path: required(service.livePath, `${serviceRef}.livePath`), port: "http" }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 6 }, + startupProbe: { httpGet: { path: required(service.livePath, `${serviceRef}.livePath`), port: "http" }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 30 }, + resources, + }], + volumes, + }, + }, + }, + }, +]; + +process.stdout.write(`${objects.map((object) => Bun.YAML.stringify(object).trim()).join("\n---\n")}\n`); +} + +if (!process.execArgv.includes("--check")) main(); diff --git a/scripts/src/ci-catalog.ts b/scripts/src/ci-catalog.ts index e9d74fc4..3a4d9233 100644 --- a/scripts/src/ci-catalog.ts +++ b/scripts/src/ci-catalog.ts @@ -4,6 +4,8 @@ import { rootPath } from "./config"; export type CiArtifactStatus = "supported" | "blocked"; +export type CiSourceBuildProducer = "ci publish-backend-core" | "ci publish-user-service" | "platform-infra pipelines-as-code"; + export type CiCatalogArtifact = CiSourceBuildCatalogArtifact | CiUpstreamImageCatalogArtifact; export interface CiCatalog { @@ -27,7 +29,7 @@ export interface CiSourceBuildCatalogArtifact { serviceId: string; kind: "source-build"; status: CiArtifactStatus; - producer: "ci publish-backend-core" | "ci publish-user-service"; + producer: CiSourceBuildProducer; source: { repo: string; dockerfile: string; @@ -132,8 +134,8 @@ function validateSourceBuildArtifact(item: Record, index: numbe const status = stringField(item, "status", path); if (status !== "supported" && status !== "blocked") throw new Error(`${path}.status must be supported or blocked`); const producer = stringField(item, "producer", path); - if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service") { - throw new Error(`${path}.producer must be ci publish-backend-core or ci publish-user-service`); + if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service" && producer !== "platform-infra pipelines-as-code") { + throw new Error(`${path}.producer must be ci publish-backend-core, ci publish-user-service, or platform-infra pipelines-as-code`); } const artifact: CiSourceBuildCatalogArtifact = { serviceId: stringField(item, "serviceId", path), @@ -238,9 +240,9 @@ export function findCiCatalogArtifact(serviceId: string): CiCatalogArtifact | nu return loadCiCatalog().artifacts.find((artifact) => artifact.serviceId === serviceId) ?? null; } -export function supportedSourceBuildArtifactIds(): string[] { +export function supportedSourceBuildArtifactIds(producer?: CiSourceBuildProducer): string[] { return loadCiCatalog().artifacts - .filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported") + .filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported" && (producer === undefined || artifact.producer === producer)) .map((artifact) => artifact.serviceId); } diff --git a/scripts/src/ci/entry.ts b/scripts/src/ci/entry.ts index 46973840..e3c2fdb9 100644 --- a/scripts/src/ci/entry.ts +++ b/scripts/src/ci/entry.ts @@ -27,7 +27,7 @@ import { makeRunId, resolveDeployDevManifest, runDevE2E } from "./dev-e2e"; import { ciHelp, requireRunId } from "./help"; import { install, installAsync, installStatus } from "./install"; import { logs } from "./logs"; -import { blockedArtifactResult, blockedReason, boolFlag, ciCleanupFailedPodsOptions, ciCleanupRunsOptions, ciLogsOptions, isHelpArg, numberOption, publishTransportOption, requireDesiredRef, requireFullCommit, requireRepoRelativePath, requireRevision, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options"; +import { assertUserServicePublishProducer, blockedArtifactResult, blockedReason, boolFlag, ciCleanupFailedPodsOptions, ciCleanupRunsOptions, ciLogsOptions, isHelpArg, numberOption, publishTransportOption, requireDesiredRef, requireFullCommit, requireRepoRelativePath, requireRevision, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options"; import { backendCoreArtifactSourceHostPath, userServiceArtifactSourceHostPath } from "./pipelinerun"; import { publishBackendCoreArtifact, publishUserServiceArtifact, run } from "./publish"; import { ciTarget, ciTargetSourceSummary, providerIdOption } from "./types"; @@ -135,6 +135,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi if (artifact.status === "blocked") { return blockedArtifactResult(artifact, commit, blockedReason(artifact)); } + assertUserServicePublishProducer(artifact); const repoUrl = artifact.source.repo; const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`); const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact); diff --git a/scripts/src/ci/help.ts b/scripts/src/ci/help.ts index 0eb7efa7..5c4f8953 100644 --- a/scripts/src/ci/help.ts +++ b/scripts/src/ci/help.ts @@ -117,7 +117,7 @@ export function ciHelp(): Record { tekton: "D601 Tekton PipelineRun through backend-core/provider control plane", directDocker: "repo-owned Docker artifact publish without backend-core/database dispatch; no deploy apply, rollout, restart, or active-task mutation", }, - supportedServices: supportedSourceBuildArtifactIds().filter((serviceId) => serviceId !== "backend-core"), + supportedServices: supportedSourceBuildArtifactIds("ci publish-user-service"), blockedServices: blockedCatalogArtifactIds(), registry: "127.0.0.1:5000/unidesk/:", outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"], diff --git a/scripts/src/ci/options.ts b/scripts/src/ci/options.ts index e09ff001..8fb68885 100644 --- a/scripts/src/ci/options.ts +++ b/scripts/src/ci/options.ts @@ -280,6 +280,14 @@ export function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact { return artifact; } +export function assertUserServicePublishProducer(artifact: CiSourceBuildCatalogArtifact): void { + if (artifact.producer === "ci publish-user-service") return; + if (artifact.producer === "platform-infra pipelines-as-code") { + throw new Error(`${artifact.serviceId} is produced by platform-infra pipelines-as-code; use bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01 --consumer unidesk-host`); + } + throw new Error(`${artifact.serviceId} is produced by ${artifact.producer}; use the owning producer command`); +} + export function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record { const base = { ok: false, diff --git a/scripts/src/ci/publish.ts b/scripts/src/ci/publish.ts index 3c3b5318..0d254576 100644 --- a/scripts/src/ci/publish.ts +++ b/scripts/src/ci/publish.ts @@ -25,7 +25,7 @@ import { runSshCommandCapture } from "../ssh"; import type { ArtifactSummaryContext, CiOptions, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflightTransport } from "./types"; import { artifactSummaryDefaults } from "./artifact-summary"; import { publishUserServiceArtifactDirectDocker } from "./direct-docker"; -import { blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options"; +import { assertUserServicePublishProducer, blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options"; import { backendCoreArtifactPipelineRunManifest, backendCoreArtifactSourceHostPath, pipelineRunManifest, userServiceArtifactPipelineRunManifest, userServiceArtifactSourceHostPath } from "./pipelinerun"; import { pipelineRunWaitSucceeded, readPipelineRunCondition, remoteCreatePipelineRun, waitForPipelineRun } from "./pipelinerun-runtime"; import { publishPreflightControlChannelOrder, publishPreflightFailedScopes } from "./preflight"; @@ -238,6 +238,7 @@ export async function runCiPublishUserServiceDryRunPreflight( if (artifact.status === "blocked") { return blockedArtifactResult(artifact, commit, blockedReason(artifact)); } + assertUserServicePublishProducer(artifact); const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`); const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact); if (boundaryBlock !== null) return boundaryBlock; diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts index 7bde30f2..575d7376 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts @@ -2,7 +2,7 @@ // Responsibility: shared types, config helpers and text render utilities for web-probe sentinel CI/CD. import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery"; import type { CommandResult } from "./command"; import { repoRoot, rootPath } from "./config"; @@ -320,7 +320,7 @@ export function monitorWebBuildkitStatePlan(cicd: Record): Reco } export function secretSourcePaths(sourceRef: string): string[] { - if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef); + if (isAbsolute(sourceRef)) return [sourceRef]; const paths = [join(repoRoot, ".state", "secrets", sourceRef)]; const marker = "/.worktree/"; const index = repoRoot.indexOf(marker); @@ -328,14 +328,6 @@ export function secretSourcePaths(sourceRef: string): string[] { return [...new Set(paths)]; } -export function ownerFileSourcePaths(sourceRef: string): string[] { - if (sourceRef.includes("..") || sourceRef.includes("\0")) return []; - const marker = "/.worktree/"; - const index = repoRoot.indexOf(marker); - const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot]; - return [...new Set(roots.map((root) => join(root, sourceRef)))]; -} - export function parseEnvFile(textValue: string): Record { const values: Record = {}; for (const rawLine of textValue.split(/\r?\n/u)) { diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 6590c716..18bd833e 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -7,7 +7,7 @@ // Responsibility: YAML-first node/lane operations, including Workbench observability control commands. import { createHash, randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { repoRoot, rootPath, type Config } from "../config"; import { runCommand, type CommandResult } from "../command"; import { startJob } from "../jobs"; @@ -739,7 +739,7 @@ export function readLocalPostgresPasswordMaterial(input: { sourceRef: string; so } export function localSecretSourcePaths(sourceRef: string): string[] { - if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef); + if (isAbsolute(sourceRef)) return [sourceRef]; const marker = "/.worktree/"; const index = repoRoot.indexOf(marker); const paths = index >= 0 @@ -1286,7 +1286,7 @@ export function externalPostgresSecretSourceRoot(spec: HwlabRuntimeLaneSpec): st } export function readSecretSourceValue(secretRoot: string, sourceRef: string, key: string): { ok: true; value: string; sourcePath: string } | { ok: false; sourcePath: string } { - const sourcePath = join(secretRoot, sourceRef); + const sourcePath = isAbsolute(sourceRef) ? sourceRef : join(secretRoot, sourceRef); if (!existsSync(sourcePath)) return { ok: false, sourcePath }; const values = parseEnvFile(readFileSync(sourcePath, "utf8")); const value = values[key]; @@ -1295,7 +1295,7 @@ export function readSecretSourceValue(secretRoot: string, sourceRef: string, key } export function secretSourcePaths(sourceRef: string): string[] { - if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef); + if (isAbsolute(sourceRef)) return [sourceRef]; const paths = [join(repoRoot, ".state", "secrets", sourceRef)]; const marker = "/.worktree/"; const index = repoRoot.indexOf(marker); @@ -1303,14 +1303,6 @@ export function secretSourcePaths(sourceRef: string): string[] { return [...new Set(paths)]; } -function ownerFileSourcePaths(sourceRef: string): string[] { - if (sourceRef.includes("..") || sourceRef.includes("\0")) return []; - const marker = "/.worktree/"; - const index = repoRoot.indexOf(marker); - const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot]; - return [...new Set(roots.map((root) => join(root, sourceRef)))]; -} - export function displayRepoPath(path: string): string { const normalizedRoot = repoRoot.replace(/\/+$/u, ""); if (path === normalizedRoot) return "."; diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index 68a61dda..94a211f9 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -4,20 +4,17 @@ import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot } from "./config"; import { jsonByteLength, previewJson } from "./preview"; -// Todo Note misleading-404 rewrite (issue #198) — CLI-side interim workaround -// for the upstream todo_note catch-all handler. The upstream repo -// (https://gitee.com/Lyon1998/todo_note) wraps every unregistered path in +// Todo Note misleading-404 rewrite (issue #198) for legacy deployments. +// The historical service wrapped every unregistered path in // `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`, // which makes agents and humans misdiagnose a path typo as a write lock. -// When the upstream fix lands, this rewrite becomes a no-op: the upstream -// body will not match `isTodoNoteMisleadingRouteNotFoundBody` anymore and +// The vendored service returns normal route errors, so this becomes a no-op: +// its body does not match `isTodoNoteMisleadingRouteNotFoundBody` and // the CLI will pass the upstream response through unchanged. // // Source of truth for the writable endpoint list below is -// docs/reference/microservices.md "Todo Note On Main Server" and -// apps/server/src/server.ts in the upstream todo_note repo. If the -// upstream registered routes change, update TODO_NOTE_WRITABLE_ENDPOINTS -// and TODO_NOTE_ACTION_TYPES in lock-step. +// src/components/microservices/todo-note/src/index.ts. Keep the catalog and +// action types aligned with that service. interface TodoNoteEndpoint { method: string; @@ -38,6 +35,8 @@ export const TODO_NOTE_ACTION_TYPES = [ ] as const; export const TODO_NOTE_WRITABLE_ENDPOINTS: readonly TodoNoteEndpoint[] = [ + { method: "POST", path: "/api/storage/import", hint: "Import a confirmed snapshot or gzip-packed migration request." }, + { method: "POST", path: "/api/storage/migrate", hint: "Persist the current runtime index to the configured GitHub store." }, { method: "POST", path: "/api/instances", hint: "Create a new todo list; body {name}." }, { method: "DELETE", path: "/api/instances/:instanceId", hint: "Delete a todo list." }, { @@ -673,6 +672,15 @@ function methodOption(args: string[], hasBody = false): string { return method; } +export function parseMicroserviceProxyRequest(args: string[]): { body: unknown | undefined; method: string; checkPath: boolean } { + const body = requestBodyOption(args); + return { + body, + method: methodOption(args, body !== undefined), + checkPath: hasFlag(args, "--check-path"), + }; +} + export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown { const full = args.includes("--full"); const raw = args.includes("--raw"); @@ -969,16 +977,14 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin if (action === "proxy") { const id = requireId(idArg, "microservice proxy"); const path = requireProxyPath(pathArg); - const body = requestBodyOption(args); + const request = parseMicroserviceProxyRequest(args); const full = hasFlag(args, "--full"); const raw = hasFlag(args, "--raw"); - const checkPath = hasFlag(args, "--check-path"); - const method = methodOption(args, body !== undefined); - if (checkPath) { + if (request.checkPath) { if (id !== "todo-note") { return { ok: false, - command: `microservice proxy ${id} ${path} --method ${method} --check-path`, + command: `microservice proxy ${id} ${path} --method ${request.method} --check-path`, data: { ok: false, error: `--check-path currently only supports service=todo-note; got ${id}`, @@ -987,12 +993,12 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin }, }; } - return runTodoNoteCheckPath(method, path); + return runTodoNoteCheckPath(request.method, path); } const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000); const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000); - const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method, body, maxResponseBytes }); - const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, method, path, fetched); + const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: request.method, body: request.body, maxResponseBytes }); + const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, request.method, path, fetched); return summarizeMicroserviceProxyResponse(rewritten, args); } throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy"); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index cf53edfc..931fc8b3 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -232,10 +232,19 @@ apply_action() { --from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null + argo_bootstrap=false + if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then + argo_tmp=$(mktemp) + printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp" + kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null + rm -f "$argo_tmp" + kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" annotate application "$UNIDESK_PAC_ARGO_APPLICATION" argocd.argoproj.io/refresh=hard --overwrite >/dev/null + argo_bootstrap=true + fi hook_id=$(ensure_webhook) ready=false if wait_ready; then ready=true; fi - printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" + printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" } condition_status() { @@ -701,15 +710,24 @@ NODE runtime_summary() { app_file=$(mktemp) + params_file=$(mktemp) kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json >"$app_file" 2>/dev/null || printf '{}' >"$app_file" - target=$(node - "$app_file" <<'NODE' + printf '%s' "$UNIDESK_PAC_PARAMS_JSON" >"$params_file" + target=$(node - "$app_file" "$params_file" <<'NODE' const fs = require('node:fs'); const app = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{}'); +const params = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '{}'); +const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : ''; +const param = (name) => params[`${prefix}${name}`] ?? params[name]; +if (param('runtime_namespace') && param('runtime_deployment')) { + process.stdout.write(`${param('runtime_namespace')}\t${param('runtime_deployment')}`); + process.exit(0); +} const resource = (app.status?.resources || []).find((item) => item.kind === 'Deployment' && item.namespace && item.name); if (resource) process.stdout.write(`${resource.namespace}\t${resource.name}`); NODE ) - rm -f "$app_file" + rm -f "$app_file" "$params_file" if [ -z "$target" ]; then printf '{}' return @@ -759,8 +777,10 @@ const pipelines = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '[]'); const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {}; const sourceCommit = latest.sourceCommit || null; const tag = sourceCommit ? String(sourceCommit).slice(0, 12) : ''; -const imageRepository = typeof params.image_repository === 'string' ? params.image_repository : ''; -const probeBase = typeof params.registry_probe_base === 'string' ? params.registry_probe_base.replace(/\/+$/u, '') : ''; +const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : ''; +const param = (name) => params[`${prefix}${name}`] ?? params[name]; +const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : ''; +const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : ''; let registryUrl = ''; if (imageRepository && tag) { const firstSlash = imageRepository.indexOf('/'); @@ -777,9 +797,14 @@ line('registryProbeBase', probeBase); line('sourceCommit', sourceCommit || ''); line('tag', tag); line('registryUrl', registryUrl); -line('sentinelId', params.sentinel_id || ''); -line('gitopsBranch', params.gitops_branch || ''); -line('gitopsManifestPath', params.gitops_manifest_path || ''); +line('sentinelId', param('sentinel_id') || ''); +line('gitopsBranch', param('gitops_branch') || ''); +line('gitopsManifestPath', param('gitops_manifest_path') || ''); +line('runtimeNamespace', param('runtime_namespace') || ''); +line('runtimeService', param('runtime_service') || ''); +line('runtimeServicePort', param('runtime_service_port') || ''); +line('healthPath', param('health_path') || ''); +line('healthUrl', param('health_url') || ''); NODE ) image_repository=$(printf '%s\n' "$probe_env" | sed -n 's/^imageRepository=//p' | base64 -d 2>/dev/null || true) @@ -790,6 +815,11 @@ NODE sentinel_id=$(printf '%s\n' "$probe_env" | sed -n 's/^sentinelId=//p' | base64 -d 2>/dev/null || true) gitops_branch=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsBranch=//p' | base64 -d 2>/dev/null || true) gitops_manifest_path=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsManifestPath=//p' | base64 -d 2>/dev/null || true) + runtime_namespace=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeNamespace=//p' | base64 -d 2>/dev/null || true) + runtime_service=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeService=//p' | base64 -d 2>/dev/null || true) + runtime_service_port=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeServicePort=//p' | base64 -d 2>/dev/null || true) + health_path=$(printf '%s\n' "$probe_env" | sed -n 's/^healthPath=//p' | base64 -d 2>/dev/null || true) + health_url=$(printf '%s\n' "$probe_env" | sed -n 's/^healthUrl=//p' | base64 -d 2>/dev/null || true) registry_present=false registry_digest="" if [ -n "$registry_url" ]; then @@ -804,6 +834,26 @@ NODE fi registry_present_json=false if [ "$registry_present" = true ]; then registry_present_json=true; fi + health_status="" + if [ -n "$health_url" ]; then + if [ -n "$runtime_namespace" ] && [ -n "$runtime_service" ] && [ -n "$runtime_service_port" ] && [ -n "$health_path" ]; then + health_file=$(mktemp) + if kubectl get --raw "/api/v1/namespaces/$runtime_namespace/services/$runtime_service:$runtime_service_port/proxy$health_path" >"$health_file" 2>/dev/null \ + && node - "$health_file" <<'NODE' +const fs = require('node:fs'); +const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +process.exit(body?.ok === true ? 0 : 1); +NODE + then + health_status=200 + else + health_status=503 + fi + rm -f "$health_file" + else + health_status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$health_url" 2>/dev/null || true) + fi + fi export UNIDESK_PAC_DIAG_IMAGE_REPOSITORY="$image_repository" export UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE="$registry_probe_base" export UNIDESK_PAC_DIAG_SOURCE_COMMIT="$source_commit" @@ -814,6 +864,8 @@ NODE export UNIDESK_PAC_DIAG_SENTINEL_ID="$sentinel_id" export UNIDESK_PAC_DIAG_GITOPS_BRANCH="$gitops_branch" export UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH="$gitops_manifest_path" + export UNIDESK_PAC_DIAG_HEALTH_URL="$health_url" + export UNIDESK_PAC_DIAG_HEALTH_STATUS="$health_status" node - "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" <<'NODE' const fs = require('node:fs'); function parseLoose(path, fallback) { @@ -836,12 +888,15 @@ const registryDigest = process.env.UNIDESK_PAC_DIAG_REGISTRY_DIGEST || null; const expectedDigest = registryDigest || artifact.digest || null; const runtimeImage = runtime.image || null; const runtimeMatches = !expectedDigest || (typeof runtimeImage === 'string' && runtimeImage.includes(expectedDigest)); +const healthUrl = process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null; +const healthStatus = process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null; +const healthReady = !healthUrl || healthStatus === '200'; const gitopsReady = Boolean(artifact.gitopsCommit || argo.revision); let code = 'pac-diagnostics-not-applicable'; let phase = 'not-applicable'; let ok = true; let hint = 'consumer has no sentinel image_repository diagnostic config'; -if (params.image_repository) { +if (process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY) { if (!sourceCommit) { ok = false; code = 'sentinel-pac-source-unknown'; phase = 'source-unknown'; hint = 'latest PaC PipelineRun did not expose a source commit'; } else if (!registryPresent) { @@ -852,6 +907,8 @@ if (params.image_repository) { ok = false; code = 'sentinel-pac-argo-not-ready'; phase = 'gitops-ready-argo-pending'; hint = 'GitOps exists but Argo is not Synced/Healthy'; } else if (!runtimeMatches) { ok = false; code = 'sentinel-pac-runtime-not-aligned'; phase = 'argo-ready-runtime-mismatch'; hint = 'runtime image does not match the registry digest observed for the source commit'; + } else if (!healthReady) { + ok = false; code = 'pac-health-not-ready'; phase = 'runtime-ready-health-pending'; hint = 'runtime image is aligned but the configured health endpoint is not ready'; } else { code = 'sentinel-pac-ready'; phase = 'ready'; hint = 'source, registry tag, GitOps, Argo and runtime are aligned'; } @@ -877,6 +934,7 @@ process.stdout.write(JSON.stringify({ }, argo: { sync: argo.sync || null, health: argo.health || null, revision: argo.revision || null }, runtime: { image: runtimeImage, digest: runtime.digest || null, readyReplicas: runtime.readyReplicas ?? null, replicas: runtime.replicas ?? null }, + health: { url: healthUrl, status: healthStatus, ready: healthReady }, pipelineRun: latest.name || null, valuesPrinted: false, })); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index f40412e9..276df7f2 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -120,6 +120,14 @@ interface PacConsumer { pipelineRunPrefix: string; argoNamespace: string; argoApplication: string; + argoBootstrap: { + project: string; + repoUrl: string; + targetRevision: string; + path: string; + destinationNamespace: string; + automated: boolean; + } | null; repositoryRef: string; closeoutGitOpsMirrorFlush: boolean; } @@ -315,6 +323,7 @@ function parseRepository(repository: Record, path: string): Pac function parseConsumer(consumer: Record, path: string, defaultRepositoryRef: string): PacConsumer { const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`; + const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path); return { id, node: y.stringField(consumer, "node", path), @@ -324,6 +333,14 @@ function parseConsumer(consumer: Record, path: string, defaultR pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", path), argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", path), argoApplication: y.kubernetesNameField(consumer, "argoApplication", path), + argoBootstrap: argoBootstrap === null ? null : { + project: y.kubernetesNameField(argoBootstrap, "project", `${path}.argoBootstrap`), + repoUrl: urlField(argoBootstrap, "repoUrl", `${path}.argoBootstrap`), + targetRevision: y.stringField(argoBootstrap, "targetRevision", `${path}.argoBootstrap`), + path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`), + destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`), + automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`), + }, repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, closeoutGitOpsMirrorFlush: consumer.closeoutGitOpsMirrorFlush === undefined ? false : y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path), }; @@ -673,6 +690,7 @@ function remoteScript(action: "apply" | "status" | "history" | "webhook-test", p UNIDESK_PAC_DISPLAY_TIME_ZONE: pac.display.timeZone, UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace, UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication, + UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"), UNIDESK_PAC_PART_OF: pacPartOf(consumer.id), UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec), }; @@ -698,11 +716,46 @@ function credentialPath(root: string, sourceRef: string): string { } function pacPartOf(consumerId: string): string { + if (consumerId === "unidesk-host") return "unidesk-host"; if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel"; if (consumerId.startsWith("hwlab-")) return "hwlab"; return "agentrun"; } +function argoBootstrapManifest(consumer: PacConsumer): string { + const bootstrap = consumer.argoBootstrap; + if (bootstrap === null) return ""; + const application = { + apiVersion: "argoproj.io/v1alpha1", + kind: "Application", + metadata: { + name: consumer.argoApplication, + namespace: consumer.argoNamespace, + labels: { + "app.kubernetes.io/managed-by": "unidesk", + "app.kubernetes.io/part-of": pacPartOf(consumer.id), + }, + }, + spec: { + project: bootstrap.project, + source: { + repoURL: bootstrap.repoUrl, + targetRevision: bootstrap.targetRevision, + path: bootstrap.path, + }, + destination: { + server: "https://kubernetes.default.svc", + namespace: bootstrap.destinationNamespace, + }, + syncPolicy: bootstrap.automated ? { + automated: { prune: true, selfHeal: true }, + syncOptions: ["CreateNamespace=true"], + } : undefined, + }, + }; + return `${Bun.YAML.stringify(application).trim()}\n`; +} + function parseLinePair(path: string, usernameLine: number, passwordLine: number): { username: string; password: string } { const lines = readFileSync(path, "utf8").split(/\r?\n/u); const username = lines[usernameLine - 1]?.trim() ?? ""; diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index e6330b9f..ff28471f 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -4,7 +4,14 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { type UniDeskConfig } from "./config"; import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug"; -import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices"; +import { + parseMicroserviceProxyRequest, + rewriteTodoNoteMisleadingRouteNotFound, + runTodoNoteCheckPath, + summarizeMicroserviceHealthResponse, + summarizeMicroserviceObservation, + summarizeMicroserviceProxyResponse, +} from "./microservices"; import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf"; import { buildWindowsPowerShellInvocation, @@ -742,19 +749,35 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro }; } if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) { + const request = parseMicroserviceProxyRequest(args); + if (request.checkPath) { + if (id !== "todo-note") throw new Error(`--check-path currently only supports service=todo-note; got ${id}`); + return { transport: "frontend", response: runTodoNoteCheckPath(request.method, path) }; + } const full = args.includes("--full"); const raw = args.includes("--raw"); const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000); const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000); - const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000, maxResponseBytes); + const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, { + method: request.method, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + }, 30_000, maxResponseBytes); + const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, request.method, path, response); return { transport: "frontend", - response: summarizeMicroserviceProxyResponse(response, args), + response: summarizeMicroserviceProxyResponse(rewritten, args), }; } throw new Error("remote microservice command must be: microservice list | status | health | diagnostics | tunnel-self-test | proxy "); } +function remoteMicroserviceOk(result: unknown): boolean { + if (typeof result !== "object" || result === null || Array.isArray(result)) return true; + const response = (result as { response?: unknown }).response; + if (typeof response !== "object" || response === null || Array.isArray(response)) return true; + return (response as { ok?: unknown }).ok !== false; +} + function commandResultFromFrontendTask(command: string[], task: { status?: string; result?: Record } | undefined) { const result = task?.result ?? {}; const stdout = typeof result.stdout === "string" ? result.stdout : ""; @@ -1615,8 +1638,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe return 0; } if (top === "microservice") { - emitRemoteJson(name, await remoteMicroservice(session, args)); - return 0; + const result = await remoteMicroservice(session, args); + const ok = remoteMicroserviceOk(result); + emitRemoteJson(name, result, ok); + return ok ? 0 : 1; } if (top === "artifact-registry") { emitRemoteJson(name, await remoteArtifactRegistry(session, args)); diff --git a/scripts/src/trans-config.ts b/scripts/src/trans-config.ts index 5780e015..95ae470c 100644 --- a/scripts/src/trans-config.ts +++ b/scripts/src/trans-config.ts @@ -10,7 +10,7 @@ export interface TransSshBackendConfig { sourceRef: string; } -const defaultTransConfigPath = ".env/trans.ymal"; +const defaultTransConfigPath = "/root/.unidesk/.env/trans.ymal"; export function readTransSshBackendConfig(env: NodeJS.ProcessEnv = process.env): TransSshBackendConfig | null { const configPath = resolveTransConfigPath(env); diff --git a/src/components/backend-core/src/microservice_proxy.rs b/src/components/backend-core/src/microservice_proxy.rs index 139e45ba..e1284f1d 100644 --- a/src/components/backend-core/src/microservice_proxy.rs +++ b/src/components/backend-core/src/microservice_proxy.rs @@ -606,6 +606,16 @@ const TODO_NOTE_ACTION_TYPES: &[&str] = &[ ]; const TODO_NOTE_WRITABLE_ENDPOINTS: &[(&str, &str, &str)] = &[ + ( + "POST", + "/api/storage/import", + "Import a confirmed snapshot or gzip-packed migration request.", + ), + ( + "POST", + "/api/storage/migrate", + "Persist the current runtime index to the configured GitHub store.", + ), ( "POST", "/api/instances", diff --git a/src/components/microservices/decision-center/src/index.ts b/src/components/microservices/decision-center/src/index.ts index b173add5..4f7ed614 100644 --- a/src/components/microservices/decision-center/src/index.ts +++ b/src/components/microservices/decision-center/src/index.ts @@ -175,6 +175,7 @@ const requirementRecordTypes = new Set(["decision", "goal const recordLevels = new Set(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]); const recordStatuses = new Set(["active", "blocked", "parked", "done"]); const serviceStartedAt = new Date().toISOString(); +const isCheckMode = process.execArgv.includes("--check"); const recentLogs: JsonRecord[] = []; let schemaReady = false; let schemaLastError: JsonRecord | null = null; @@ -196,7 +197,7 @@ function envNumber(name: string, fallback: number): number { } function configFromEnv(): RuntimeConfig { - const databaseUrl = process.env.DATABASE_URL || ""; + const databaseUrl = process.env.DATABASE_URL || (isCheckMode ? "postgres://syntax-check@127.0.0.1:1/syntax-check" : ""); if (!databaseUrl) throw new Error("DATABASE_URL is required"); const storagePrimary = envString("DECISION_CENTER_STORAGE_PRIMARY", "postgres"); if (storagePrimary !== "postgres" && storagePrimary !== "github-repo") throw new Error("DECISION_CENTER_STORAGE_PRIMARY must be postgres or github-repo"); @@ -240,7 +241,7 @@ const logWriter = config.logFile maxBytes: logRetentionBytesForService("decision-center"), }) : null; -logWriter?.prune(); +if (!isCheckMode) logWriter?.prune(); function log(level: "info" | "warn" | "error", event: string, detail: JsonRecord = {}): void { const record: JsonRecord = { at: new Date().toISOString(), service: "decision-center", level, event, ...detail }; @@ -1111,9 +1112,28 @@ async function writeSnapshotToGit(reason: string, dryRun: boolean): Promise { throw new HttpError(404, "route not found", { path: url.pathname }); } -Bun.serve({ - hostname: config.host, - port: config.port, - async fetch(req) { - try { - return await route(req); - } catch (error) { - return errorResponse(error); - } - }, -}); - -void waitForSchema() - .then(async () => { - await refreshDatabaseHealth(); - await updateStorageHealth(); - }) - .catch((error) => { - log("error", "schema_init_failed", { error: errorToJson(error) }); +if (import.meta.main && !isCheckMode) { + Bun.serve({ + hostname: config.host, + port: config.port, + async fetch(req) { + try { + return await route(req); + } catch (error) { + return errorResponse(error); + } + }, }); -setInterval(() => { - if (schemaReady) { - void refreshDatabaseHealth(); - void updateStorageHealth(); - } -}, 15_000).unref(); -log("info", "service_started", { host: config.host, port: config.port, storage: storageConfigSummary() }); + + void waitForSchema() + .then(async () => { + await refreshDatabaseHealth(); + await updateStorageHealth(); + }) + .catch((error) => { + log("error", "schema_init_failed", { error: errorToJson(error) }); + }); + setInterval(() => { + if (schemaReady) { + void refreshDatabaseHealth(); + void updateStorageHealth(); + } + }, 15_000).unref(); + log("info", "service_started", { host: config.host, port: config.port, storage: storageConfigSummary() }); +} diff --git a/src/components/microservices/todo-note/Dockerfile b/src/components/microservices/todo-note/Dockerfile new file mode 100644 index 00000000..61967788 --- /dev/null +++ b/src/components/microservices/todo-note/Dockerfile @@ -0,0 +1,16 @@ +ARG TODO_NOTE_BASE_IMAGE=oven/bun:1-debian +FROM ${TODO_NOTE_BASE_IMAGE} + +WORKDIR /app/src/components/microservices/todo-note +RUN apt-get update \ + && apt-get install -y --no-install-recommends git openssh-client ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +COPY src/components/microservices/todo-note/package.json src/components/microservices/todo-note/bun.lock ./ +RUN bun install --production --frozen-lockfile +COPY src/components/microservices/todo-note/tsconfig.json ./tsconfig.json +COPY src/components/shared /app/src/components/shared +COPY src/components/microservices/todo-note/src ./src + +EXPOSE 4288 +CMD ["bun", "run", "src/index.ts"] diff --git a/src/components/microservices/todo-note/bun.lock b/src/components/microservices/todo-note/bun.lock new file mode 100644 index 00000000..14b0dea6 --- /dev/null +++ b/src/components/microservices/todo-note/bun.lock @@ -0,0 +1,224 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@unidesk/todo-note", + "dependencies": { + "express": "latest", + "postgres": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "latest", + "@types/node": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.2", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + } +} diff --git a/src/components/microservices/todo-note/package.json b/src/components/microservices/todo-note/package.json new file mode 100644 index 00000000..5884032b --- /dev/null +++ b/src/components/microservices/todo-note/package.json @@ -0,0 +1,20 @@ +{ + "name": "@unidesk/todo-note", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "check": "bun --check src/index.ts && bun --check src/repository.ts && bun --check src/git-storage.ts && bun --check src/reminder-notifier.ts && bun --check scripts/export-postgres.ts && bun --check scripts/pack-import.ts" + }, + "dependencies": { + "express": "latest", + "postgres": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/express": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/src/components/microservices/todo-note/scripts/export-postgres.ts b/src/components/microservices/todo-note/scripts/export-postgres.ts new file mode 100644 index 00000000..7f755205 --- /dev/null +++ b/src/components/microservices/todo-note/scripts/export-postgres.ts @@ -0,0 +1,134 @@ +import { createHash } from "node:crypto"; +import { chmod } from "node:fs/promises"; + +import postgres from "postgres"; + +interface InstanceRow { + id: string; + name: string; + file_path: string; + history_path: string; + created_at: string; + updated_at: string; + history_pointer: number; + todos: unknown; + source: unknown; +} + +interface HistoryRow { + instance_id: string; + sequence: number; + timestamp: string; + kind: string; + action: string; + snapshot: unknown; + detail: unknown; +} + +interface ReminderNotificationRow { + instance_id: string; + todo_id: string; + reminder_at: string; + target: string; + title: string; + message: string; + response_preview: string; + sent_at: string; +} + +function outputPath(): string { + const index = Bun.argv.indexOf("--output"); + const value = index >= 0 ? Bun.argv[index + 1] : process.env.TODO_NOTE_EXPORT_PATH; + if (!value) throw new Error("--output PATH is required"); + return value; +} + +async function main(): Promise { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + const sql = postgres(databaseUrl, { + max: 1, + connect_timeout: 10, + connection: { application_name: "todo-note-cc01-export" }, + }); + try { + const instances = await sql` + SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source + FROM todo_note_instances + ORDER BY created_at ASC, id ASC + `; + const histories = await sql` + SELECT instance_id, sequence, timestamp, kind, action, snapshot, detail + FROM todo_note_history + ORDER BY instance_id ASC, id ASC + `; + const reminderNotifications = await sql` + SELECT instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at::text AS sent_at + FROM todo_note_reminder_notifications + ORDER BY id ASC + `; + const historyByInstance = new Map(); + for (const row of histories) { + const entries = historyByInstance.get(row.instance_id) ?? []; + entries.push(row); + historyByInstance.set(row.instance_id, entries); + } + const snapshot = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + instances: instances.map((row) => ({ + instance: { + id: row.id, + name: row.name, + filePath: row.file_path, + historyPath: row.history_path, + createdAt: row.created_at, + updatedAt: row.updated_at, + historyPointer: Number(row.history_pointer), + todos: row.todos, + }, + history: (historyByInstance.get(row.id) ?? []).map((entry) => ({ + sequence: Number(entry.sequence), + timestamp: entry.timestamp, + kind: entry.kind, + action: entry.action, + ...(entry.snapshot === null ? {} : { snapshot: entry.snapshot }), + ...(entry.detail === null ? {} : { detail: entry.detail }), + })), + source: typeof row.source === "object" && row.source !== null ? row.source : {}, + })), + reminderNotifications: reminderNotifications.map((row) => ({ + instanceId: row.instance_id, + todoId: row.todo_id, + reminderAt: row.reminder_at, + target: row.target, + title: row.title, + message: row.message, + responsePreview: row.response_preview, + sentAt: row.sent_at, + })), + }; + const serialized = `${JSON.stringify(snapshot, null, 2)}\n`; + const target = outputPath(); + await Bun.write(target, serialized); + await chmod(target, 0o600); + process.stdout.write(`${JSON.stringify({ + ok: true, + output: target, + instances: instances.length, + historyEntries: histories.length, + reminderNotifications: reminderNotifications.length, + bytes: Buffer.byteLength(serialized), + sha256: createHash("sha256").update(serialized).digest("hex"), + })}\n`); + } finally { + await sql.end({ timeout: 5 }); + } +} + +if (import.meta.main && !process.execArgv.includes("--check")) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/components/microservices/todo-note/scripts/pack-import.ts b/src/components/microservices/todo-note/scripts/pack-import.ts new file mode 100644 index 00000000..629f7983 --- /dev/null +++ b/src/components/microservices/todo-note/scripts/pack-import.ts @@ -0,0 +1,36 @@ +import { createHash } from "node:crypto"; +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { gzipSync } from "node:zlib"; + +function option(name: string): string { + const index = Bun.argv.indexOf(name); + const value = index >= 0 ? Bun.argv[index + 1] : undefined; + if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +async function main(): Promise { + const inputPath = option("--input"); + const outputPath = option("--output"); + const source = await readFile(inputPath); + JSON.parse(source.toString("utf8")); + const compressed = gzipSync(source, { level: 9 }); + const payload = `${JSON.stringify({ confirm: true, snapshotGzipBase64: compressed.toString("base64") })}\n`; + await writeFile(outputPath, payload, { encoding: "utf8", mode: 0o600 }); + await chmod(outputPath, 0o600); + process.stdout.write(`${JSON.stringify({ + ok: true, + output: outputPath, + sourceBytes: source.length, + compressedBytes: compressed.length, + requestBytes: Buffer.byteLength(payload), + sourceSha256: createHash("sha256").update(source).digest("hex"), + })}\n`); +} + +if (import.meta.main && !process.execArgv.includes("--check")) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/components/microservices/todo-note/src/domain.ts b/src/components/microservices/todo-note/src/domain.ts new file mode 100644 index 00000000..128e9284 --- /dev/null +++ b/src/components/microservices/todo-note/src/domain.ts @@ -0,0 +1,398 @@ +import type { InstanceSummary, TodoAction, TodoInstance, TodoItem } from "./types"; + +function cloneTodo(todo: TodoItem): TodoItem { + return { + ...todo, + children: todo.children.map(cloneTodo) + }; +} + +function makeId(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +export function createTodo(title: string, now: string): TodoItem { + return { + id: makeId("todo"), + title, + completed: false, + expanded: true, + reminderAt: null, + createdAt: now, + updatedAt: now, + children: [] + }; +} + +export function createInstance(name: string, filePath: string, historyPath: string, now: string): TodoInstance { + return { + id: makeId("instance"), + name, + filePath, + historyPath, + createdAt: now, + updatedAt: now, + historyPointer: 0, + todos: [] + }; +} + +function updateNodeList( + nodes: TodoItem[], + todoId: string, + updater: (todo: TodoItem) => TodoItem +): { nodes: TodoItem[]; found: boolean } { + let found = false; + const nextNodes = nodes.map((node) => { + if (node.id === todoId) { + found = true; + return updater(node); + } + + const nested = updateNodeList(node.children, todoId, updater); + if (nested.found) { + found = true; + return { + ...node, + children: nested.nodes + }; + } + + return node; + }); + + return { nodes: nextNodes, found }; +} + +function appendNode(nodes: TodoItem[], parentId: string, todo: TodoItem): { nodes: TodoItem[]; found: boolean } { + let found = false; + const nextNodes = nodes.map((node) => { + if (node.id === parentId) { + found = true; + return { + ...node, + expanded: true, + children: [...node.children, todo] + }; + } + + const nested = appendNode(node.children, parentId, todo); + if (nested.found) { + found = true; + return { + ...node, + children: nested.nodes + }; + } + + return node; + }); + + return { nodes: nextNodes, found }; +} + +function removeNode(nodes: TodoItem[], todoId: string): { nodes: TodoItem[]; found: boolean } { + let found = false; + const nextNodes: TodoItem[] = []; + + for (const node of nodes) { + if (node.id === todoId) { + found = true; + continue; + } + + const nested = removeNode(node.children, todoId); + if (nested.found) { + found = true; + nextNodes.push({ + ...node, + children: nested.nodes + }); + continue; + } + + nextNodes.push(node); + } + + return { nodes: nextNodes, found }; +} + +function containsTodo(nodes: TodoItem[], todoId: string): boolean { + return nodes.some((node) => node.id === todoId || containsTodo(node.children, todoId)); +} + +function clampIndex(index: number, max: number): number { + return Math.max(0, Math.min(index, max)); +} + +function removeNodeWithLocation( + nodes: TodoItem[], + todoId: string, + parentId: string | null = null +): { nodes: TodoItem[]; removed: TodoItem | null; parentId: string | null; index: number } { + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]; + if (node.id === todoId) { + return { + nodes: [...nodes.slice(0, index), ...nodes.slice(index + 1)], + removed: node, + parentId, + index + }; + } + + const nested = removeNodeWithLocation(node.children, todoId, node.id); + if (nested.removed) { + return { + nodes: [...nodes.slice(0, index), { ...node, children: nested.nodes }, ...nodes.slice(index + 1)], + removed: nested.removed, + parentId: nested.parentId, + index: nested.index + }; + } + } + + return { + nodes, + removed: null, + parentId: null, + index: -1 + }; +} + +function insertNode( + nodes: TodoItem[], + parentId: string | null, + index: number, + todo: TodoItem, + now: string +): { nodes: TodoItem[]; inserted: boolean } { + const movedTodo = { + ...todo, + updatedAt: now + }; + + if (!parentId) { + const nextNodes = [...nodes]; + nextNodes.splice(clampIndex(index, nextNodes.length), 0, movedTodo); + return { nodes: nextNodes, inserted: true }; + } + + let inserted = false; + const nextNodes = nodes.map((node) => { + if (node.id === parentId) { + inserted = true; + const nextChildren = [...node.children]; + nextChildren.splice(clampIndex(index, nextChildren.length), 0, movedTodo); + return { + ...node, + expanded: true, + updatedAt: now, + children: nextChildren + }; + } + + const nested = insertNode(node.children, parentId, index, todo, now); + if (nested.inserted) { + inserted = true; + return { + ...node, + children: nested.nodes + }; + } + + return node; + }); + + return { nodes: nextNodes, inserted }; +} + +function setExpandedState(nodes: TodoItem[], expanded: boolean, now: string): TodoItem[] { + return nodes.map((node) => { + if (node.children.length === 0) { + return node; + } + + const nextChildren = setExpandedState(node.children, expanded, now); + return { + ...node, + expanded, + updatedAt: now, + children: nextChildren + }; + }); +} + +export function countTodos(nodes: TodoItem[]): { total: number; completed: number } { + return nodes.reduce( + (accumulator, node) => { + const nested = countTodos(node.children); + return { + total: accumulator.total + 1 + nested.total, + completed: accumulator.completed + (node.completed ? 1 : 0) + nested.completed + }; + }, + { total: 0, completed: 0 } + ); +} + +export function summarizeInstance(instance: TodoInstance): InstanceSummary { + const counts = countTodos(instance.todos); + return { + id: instance.id, + name: instance.name, + filePath: instance.filePath, + historyPath: instance.historyPath, + createdAt: instance.createdAt, + updatedAt: instance.updatedAt, + todoCount: counts.total, + completedCount: counts.completed + }; +} + +export function cloneInstance(instance: TodoInstance): TodoInstance { + return { + ...instance, + todos: instance.todos.map(cloneTodo) + }; +} + +export function applyInstanceAction(instance: TodoInstance, action: TodoAction, now: string): TodoInstance { + switch (action.type) { + case "addTodo": { + const todo = createTodo(action.title, now); + if (!action.parentId) { + return { + ...instance, + updatedAt: now, + todos: [...instance.todos, todo] + }; + } + + const appended = appendNode(instance.todos, action.parentId, todo); + if (!appended.found) { + throw new Error(`Todo not found: ${action.parentId}`); + } + + return { + ...instance, + updatedAt: now, + todos: appended.nodes + }; + } + case "updateTodoTitle": { + const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({ + ...todo, + title: action.title, + updatedAt: now + })); + if (!updated.found) { + throw new Error(`Todo not found: ${action.todoId}`); + } + + return { + ...instance, + updatedAt: now, + todos: updated.nodes + }; + } + case "toggleTodoCompleted": { + const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({ + ...todo, + completed: !todo.completed, + updatedAt: now + })); + if (!updated.found) { + throw new Error(`Todo not found: ${action.todoId}`); + } + + return { + ...instance, + updatedAt: now, + todos: updated.nodes + }; + } + case "toggleTodoExpanded": { + const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({ + ...todo, + expanded: !todo.expanded, + updatedAt: now + })); + if (!updated.found) { + throw new Error(`Todo not found: ${action.todoId}`); + } + + return { + ...instance, + updatedAt: now, + todos: updated.nodes + }; + } + case "setAllTodosExpanded": + return { + ...instance, + updatedAt: now, + todos: setExpandedState(instance.todos, action.expanded, now) + }; + case "setTodoReminder": { + const updated = updateNodeList(instance.todos, action.todoId, (todo) => ({ + ...todo, + reminderAt: action.reminderAt, + updatedAt: now + })); + if (!updated.found) { + throw new Error(`Todo not found: ${action.todoId}`); + } + + return { + ...instance, + updatedAt: now, + todos: updated.nodes + }; + } + case "moveTodo": { + const removed = removeNodeWithLocation(instance.todos, action.todoId); + if (!removed.removed) { + throw new Error(`Todo not found: ${action.todoId}`); + } + + const targetParentId = action.targetParentId ?? null; + if (targetParentId && (targetParentId === action.todoId || containsTodo(removed.removed.children, targetParentId))) { + throw new Error(`Cannot move todo ${action.todoId} into its own descendant`); + } + + const nextTargetIndex = + removed.parentId === targetParentId && removed.index >= 0 && removed.index < action.targetIndex ? action.targetIndex - 1 : action.targetIndex; + const inserted = insertNode(removed.nodes, targetParentId, nextTargetIndex, removed.removed, now); + if (!inserted.inserted) { + throw new Error(`Target parent not found: ${targetParentId}`); + } + + return { + ...instance, + updatedAt: now, + todos: inserted.nodes + }; + } + case "deleteTodo": { + const updated = removeNode(instance.todos, action.todoId); + if (!updated.found) { + throw new Error(`Todo not found: ${action.todoId}`); + } + + return { + ...instance, + updatedAt: now, + todos: updated.nodes + }; + } + case "renameInstance": + return { + ...instance, + name: action.name, + updatedAt: now + }; + default: + return instance; + } +} diff --git a/src/components/microservices/todo-note/src/git-storage.ts b/src/components/microservices/todo-note/src/git-storage.ts new file mode 100644 index 00000000..cf380087 --- /dev/null +++ b/src/components/microservices/todo-note/src/git-storage.ts @@ -0,0 +1,316 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { normalizeTodoSnapshot, type TodoSnapshot } from "./repository"; + +interface GitResult { + ok: boolean; + stdout: string; + stderr: string; + exitCode: number; +} + +export interface TodoGitStorageConfig { + primary: "postgres" | "github-repo"; + repo: string; + repoSshUrl: string; + branch: string; + basePath: string; + worktreePath: string; + sshCommand: string; + authorName: string; + authorEmail: string; + commitMessagePrefix: string; +} + +export class GitStorageError extends Error { + readonly detail: Record; + + constructor(message: string, detail: Record = {}) { + super(message); + this.name = "GitStorageError"; + this.detail = detail; + } +} + +function envString(name: string, fallback: string): string { + const value = process.env[name]; + return value === undefined || value.length === 0 ? fallback : value; +} + +export function gitStorageConfigFromEnv(): TodoGitStorageConfig { + const primary = envString("TODO_NOTE_STORAGE_PRIMARY", "postgres"); + if (primary !== "postgres" && primary !== "github-repo") throw new Error("TODO_NOTE_STORAGE_PRIMARY must be postgres or github-repo"); + const basePath = envString("TODO_NOTE_GIT_BASE_PATH", "todo-note").replace(/^\/+|\/+$/gu, ""); + if (!basePath || basePath === "." || basePath.split("/").includes("..")) { + throw new Error("TODO_NOTE_GIT_BASE_PATH must be a non-root repository path"); + } + return { + primary, + repo: envString("TODO_NOTE_GIT_REPO", "pikasTech/decision-center-data"), + repoSshUrl: envString("TODO_NOTE_GIT_REPO_SSH_URL", ""), + branch: envString("TODO_NOTE_GIT_BRANCH", "main"), + basePath, + worktreePath: envString("TODO_NOTE_GIT_WORKTREE", "/var/lib/unidesk/todo-note/repo"), + sshCommand: envString("GIT_SSH_COMMAND", ""), + authorName: envString("TODO_NOTE_GIT_AUTHOR_NAME", "UniDesk Todo Note"), + authorEmail: envString("TODO_NOTE_GIT_AUTHOR_EMAIL", "todo-note@unidesk.local"), + commitMessagePrefix: envString("TODO_NOTE_GIT_COMMIT_PREFIX", "todo-note:"), + }; +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value as Record).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableValue(item)])); + } + return value; +} + +export function snapshotHash(snapshot: TodoSnapshot): string { + const comparable = { schemaVersion: snapshot.schemaVersion, instances: snapshot.instances, reminderNotifications: snapshot.reminderNotifications }; + return createHash("sha256").update(JSON.stringify(stableValue(comparable))).digest("hex"); +} + +function safeId(value: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new GitStorageError("Todo instance id is not safe for Git storage", { instanceId: value }); + return value; +} + +export class TodoGitStorage { + private queue: Promise = Promise.resolve(); + + constructor(readonly config: TodoGitStorageConfig) { + if (config.primary === "github-repo" && !config.repoSshUrl) throw new Error("TODO_NOTE_GIT_REPO_SSH_URL is required for github-repo storage"); + } + + enabled(): boolean { + return this.config.primary === "github-repo"; + } + + summary(): Record { + return { + primary: this.config.primary, + repo: this.config.repo, + branch: this.config.branch, + basePath: this.config.basePath, + worktreePath: this.config.worktreePath, + sshConfigured: this.config.sshCommand.length > 0, + head: this.headSummary(), + }; + } + + async readSnapshot(): Promise { + if (!this.enabled()) return null; + return await this.enqueue(async () => { + this.syncWorktree(); + const indexPath = this.path("indexes", "instances.json"); + if (!existsSync(indexPath)) return null; + const index = JSON.parse(readFileSync(indexPath, "utf8")) as { schemaVersion?: unknown; instances?: unknown; reminderNotificationsPath?: unknown }; + if (index.schemaVersion !== 1 || !Array.isArray(index.instances)) throw new GitStorageError("Todo Git index has an unsupported schema"); + const instances = index.instances.map((raw) => { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new GitStorageError("Todo Git index entry is invalid"); + const entry = raw as { id?: unknown; instancePath?: unknown; historyPath?: unknown; source?: unknown }; + if (typeof entry.id !== "string" || typeof entry.instancePath !== "string" || typeof entry.historyPath !== "string") { + throw new GitStorageError("Todo Git index entry is missing paths"); + } + const id = safeId(entry.id); + const instancePath = join(this.config.basePath, "instances", `${id}.json`); + const historyPath = join(this.config.basePath, "history", `${id}.json`); + if (entry.instancePath !== instancePath || entry.historyPath !== historyPath) { + throw new GitStorageError("Todo Git index entry points outside its managed instance paths", { + instanceId: id, + instancePath: entry.instancePath, + historyPath: entry.historyPath, + }); + } + const instance = JSON.parse(readFileSync(join(this.config.worktreePath, instancePath), "utf8")); + const historyFile = JSON.parse(readFileSync(join(this.config.worktreePath, historyPath), "utf8")) as { history?: unknown }; + return { instance, history: historyFile.history, source: entry.source }; + }); + const reminderNotificationsPath = join(this.config.basePath, "reminders", "notifications.json"); + let reminderNotifications: unknown[] = []; + if (index.reminderNotificationsPath !== undefined) { + if (index.reminderNotificationsPath !== reminderNotificationsPath) { + throw new GitStorageError("Todo Git index points outside its managed reminder notification path", { reminderNotificationsPath: index.reminderNotificationsPath }); + } + const reminderFile = JSON.parse(readFileSync(join(this.config.worktreePath, reminderNotificationsPath), "utf8")) as { notifications?: unknown }; + if (!Array.isArray(reminderFile.notifications)) throw new GitStorageError("Todo Git reminder notification file is invalid"); + reminderNotifications = reminderFile.notifications; + } + return normalizeTodoSnapshot({ + schemaVersion: 1, + generatedAt: typeof (index as { generatedAt?: unknown }).generatedAt === "string" ? (index as { generatedAt: string }).generatedAt : new Date().toISOString(), + instances, + reminderNotifications, + }); + }); + } + + async writeSnapshot(snapshot: TodoSnapshot, reason: string): Promise> { + if (!this.enabled()) return { ok: true, changed: false, primary: "postgres" }; + const normalized = normalizeTodoSnapshot(snapshot); + return await this.enqueue(async () => { + this.syncWorktree(); + const root = this.path(); + rmSync(root, { recursive: true, force: true }); + mkdirSync(root, { recursive: true }); + const indexEntries = normalized.instances.map((entry) => { + const id = safeId(entry.instance.id); + const instancePath = join(this.config.basePath, "instances", `${id}.json`); + const historyPath = join(this.config.basePath, "history", `${id}.json`); + this.writeJson(instancePath, entry.instance); + this.writeJson(historyPath, { schemaVersion: 1, instanceId: id, history: entry.history }); + return { + id, + name: entry.instance.name, + updatedAt: entry.instance.updatedAt, + historyPointer: entry.instance.historyPointer, + instancePath, + historyPath, + source: entry.source, + }; + }); + this.writeJson(join(this.config.basePath, ".todo-note", "schema.json"), { + schemaVersion: 1, + generatedBy: "unidesk-todo-note", + }); + const reminderNotificationsPath = join(this.config.basePath, "reminders", "notifications.json"); + this.writeJson(reminderNotificationsPath, { + schemaVersion: 1, + notifications: normalized.reminderNotifications, + }); + this.writeJson(join(this.config.basePath, "indexes", "instances.json"), { + schemaVersion: 1, + generatedAt: normalized.generatedAt, + hash: snapshotHash(normalized), + reminderNotificationsPath, + instances: indexEntries, + }); + this.runGit(["add", "--all", "--", this.config.basePath]); + const diff = this.runGit(["diff", "--cached", "--quiet"], { allowFailure: true }); + if (diff.exitCode === 0) return { ok: true, changed: false, hash: snapshotHash(normalized), head: this.headSummary() }; + const commit = this.runGit(["commit", "-m", `${this.config.commitMessagePrefix} ${reason}`], { + env: { + GIT_AUTHOR_NAME: this.config.authorName, + GIT_AUTHOR_EMAIL: this.config.authorEmail, + GIT_COMMITTER_NAME: this.config.authorName, + GIT_COMMITTER_EMAIL: this.config.authorEmail, + }, + }); + if (!commit.ok) throw new GitStorageError("Todo Git commit failed", { stderr: commit.stderr.slice(-2000) }); + for (let attempt = 1; attempt <= 3; attempt += 1) { + const pushed = this.runGit(["push", "origin", this.config.branch], { allowFailure: true }); + if (pushed.ok) return { ok: true, changed: true, hash: snapshotHash(normalized), attempts: attempt, head: this.headSummary() }; + const fetched = this.runGit(["fetch", "origin", this.config.branch], { allowFailure: true }); + if (!fetched.ok) continue; + const rebased = this.runGit(["rebase", `origin/${this.config.branch}`], { allowFailure: true }); + if (!rebased.ok) { + this.runGit(["rebase", "--abort"], { allowFailure: true }); + throw new GitStorageError("Todo Git rebase failed", { attempt, stderr: rebased.stderr.slice(-2000) }); + } + } + throw new GitStorageError("Todo Git push failed after retries", { branch: this.config.branch }); + }); + } + + async verify(databaseSnapshot: TodoSnapshot): Promise> { + if (!this.enabled()) return { ok: true, primary: "postgres", databaseHash: snapshotHash(databaseSnapshot), gitHash: null }; + const gitSnapshot = await this.readSnapshot(); + const databaseHash = snapshotHash(normalizeTodoSnapshot(databaseSnapshot)); + const gitHash = gitSnapshot === null ? null : snapshotHash(gitSnapshot); + return { + ok: gitHash === databaseHash, + primary: "github-repo", + instances: databaseSnapshot.instances.length, + reminderNotifications: databaseSnapshot.reminderNotifications.length, + databaseHash, + gitHash, + head: this.headSummary(), + }; + } + + private async enqueue(operation: () => Promise): Promise { + const next = this.queue.then(operation, operation); + this.queue = next.catch(() => undefined); + return await next; + } + + private syncWorktree(): void { + mkdirSync(dirname(this.config.worktreePath), { recursive: true }); + if (!existsSync(join(this.config.worktreePath, ".git"))) { + if (existsSync(this.config.worktreePath)) rmSync(this.config.worktreePath, { recursive: true, force: true }); + this.runGit(["clone", this.config.repoSshUrl, this.config.worktreePath], { cwd: dirname(this.config.worktreePath) }); + } + this.runGit(["remote", "set-url", "origin", this.config.repoSshUrl]); + const status = this.runGit(["status", "--porcelain"]); + if (status.stdout.trim()) throw new GitStorageError("Todo Git worktree contains uncommitted changes", { paths: status.stdout.trim().split("\n").slice(0, 20) }); + const fetched = this.runGit(["fetch", "origin", this.config.branch], { allowFailure: true }); + const localBranch = this.runGit(["show-ref", "--verify", `refs/heads/${this.config.branch}`], { allowFailure: true }); + if (fetched.ok) { + if (localBranch.ok) { + this.runGit(["checkout", this.config.branch]); + this.runGit(["rebase", `origin/${this.config.branch}`]); + } else { + this.runGit(["checkout", "-b", this.config.branch, `origin/${this.config.branch}`]); + } + return; + } + const head = this.runGit(["rev-parse", "--verify", "HEAD"], { allowFailure: true }); + if (localBranch.ok) this.runGit(["checkout", this.config.branch]); + else if (head.ok) this.runGit(["checkout", "-b", this.config.branch]); + else this.runGit(["symbolic-ref", "HEAD", `refs/heads/${this.config.branch}`]); + } + + private runGit(args: string[], options: { cwd?: string; allowFailure?: boolean; env?: Record } = {}): GitResult { + const result = spawnSync("git", args, { + cwd: options.cwd ?? this.config.worktreePath, + env: { + ...process.env, + ...(this.config.sshCommand ? { GIT_SSH_COMMAND: this.config.sshCommand } : {}), + ...options.env, + }, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + const exitCode = result.status ?? 1; + const output = { + ok: exitCode === 0, + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + exitCode, + }; + if (!output.ok && options.allowFailure !== true) { + throw new GitStorageError("Todo Git command failed", { + command: ["git", ...args].join(" "), + exitCode, + stderr: output.stderr.slice(-2000), + }); + } + return output; + } + + private path(...parts: string[]): string { + return join(this.config.worktreePath, this.config.basePath, ...parts); + } + + private writeJson(relativePath: string, value: unknown): void { + const target = join(this.config.worktreePath, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + } + + private headSummary(): Record { + if (!existsSync(join(this.config.worktreePath, ".git"))) return { present: false }; + const commit = this.runGit(["rev-parse", "HEAD"], { allowFailure: true }); + const branch = this.runGit(["rev-parse", "--abbrev-ref", "HEAD"], { allowFailure: true }); + return { + present: commit.ok, + branch: branch.stdout.trim(), + commit: commit.stdout.trim().slice(0, 40), + shortCommit: commit.stdout.trim().slice(0, 12), + }; + } +} diff --git a/src/components/microservices/todo-note/src/index.ts b/src/components/microservices/todo-note/src/index.ts new file mode 100644 index 00000000..a0e6c3de --- /dev/null +++ b/src/components/microservices/todo-note/src/index.ts @@ -0,0 +1,403 @@ +import express from "express"; +import { gunzipSync } from "node:zlib"; + +import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl"; +import { GitStorageError, TodoGitStorage, gitStorageConfigFromEnv, snapshotHash } from "./git-storage"; +import { loadReminderNotifierConfig, TodoReminderNotifier } from "./reminder-notifier"; +import { PostgresTodoRepository, normalizeTodoSnapshot, type TodoReminderNotification, type TodoSnapshot } from "./repository"; +import type { TodoAction } from "./types"; + +type LogLevel = "info" | "warn" | "error"; + +class HttpError extends Error { + constructor(readonly status: number, message: string, readonly detail: Record = {}) { + super(message); + this.name = "HttpError"; + } +} + +function envString(name: string, fallback: string): string { + const value = process.env[name]; + return value === undefined || value.length === 0 ? fallback : value; +} + +function envPort(name: string, fallback: number): number { + const value = Number(process.env[name] || fallback); + if (!Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${name} must be a valid TCP port`); + return value; +} + +function errorDetail(error: unknown): Record { + if (error instanceof GitStorageError) return { name: error.name, message: error.message, ...error.detail }; + if (error instanceof HttpError) return { name: error.name, message: error.message, ...error.detail }; + if (error instanceof Error) return { name: error.name, message: error.message }; + return { name: "Error", message: String(error) }; +} + +const host = envString("HOST", "0.0.0.0"); +const port = envPort("PORT", 4288); +const isCheckMode = process.execArgv.includes("--check"); +const databaseUrl = envString("DATABASE_URL", isCheckMode ? "postgres://syntax-check@127.0.0.1:1/syntax-check" : ""); +if (!databaseUrl) throw new Error("DATABASE_URL is required"); +const logFile = envString("LOG_FILE", "/var/log/unidesk/todo-note.jsonl"); +const repository = new PostgresTodoRepository(databaseUrl); +const gitStorage = new TodoGitStorage(gitStorageConfigFromEnv()); +const startedAt = new Date().toISOString(); +const recentLogs: Record[] = []; +const logWriter = createHourlyJsonlWriter({ + baseLogFile: logFile, + service: "todo-note", + maxBytes: logRetentionBytesForService("todo-note"), +}); +let storageState: Record = { ok: false, initialized: false }; +let mutationQueue: Promise = Promise.resolve(); + +function log(level: LogLevel, event: string, detail: Record = {}): void { + const record = { at: new Date().toISOString(), service: "todo-note", level, event, ...detail }; + recentLogs.push(record); + while (recentLogs.length > 300) recentLogs.shift(); + process.stdout.write(`${JSON.stringify(record)}\n`); + logWriter.appendJson(record); +} + +function deployInfo(): Record { + return { + serviceId: envString("UNIDESK_DEPLOY_SERVICE_ID", "todo-note"), + repo: envString("UNIDESK_DEPLOY_REPO", ""), + commit: envString("UNIDESK_DEPLOY_COMMIT", ""), + requestedCommit: envString("UNIDESK_DEPLOY_REQUESTED_COMMIT", ""), + }; +} + +async function refreshStorageState(): Promise> { + const snapshot = await repository.exportSnapshot(); + const verification = await gitStorage.verify(snapshot); + storageState = { + ok: verification.ok === true, + initialized: true, + checkedAt: new Date().toISOString(), + config: gitStorage.summary(), + verification, + }; + return storageState; +} + +async function initializeStorage(): Promise { + await repository.ensureReady(); + if (!gitStorage.enabled()) { + storageState = { + ok: true, + initialized: true, + checkedAt: new Date().toISOString(), + config: gitStorage.summary(), + }; + return; + } + const databaseSnapshot = await repository.exportSnapshot(); + const gitSnapshot = await gitStorage.readSnapshot(); + if (gitSnapshot === null) { + const result = await gitStorage.writeSnapshot(databaseSnapshot, "initialize storage"); + log("info", "github_storage_initialized", { instances: databaseSnapshot.instances.length, result }); + } else { + const mergedSnapshot = { + ...gitSnapshot, + generatedAt: new Date().toISOString(), + reminderNotifications: mergeReminderNotifications(gitSnapshot.reminderNotifications, databaseSnapshot.reminderNotifications), + }; + await repository.replaceSnapshot(mergedSnapshot); + if (snapshotHash(mergedSnapshot) !== snapshotHash(gitSnapshot)) await gitStorage.writeSnapshot(mergedSnapshot, "merge reminder notifications"); + log("info", "github_storage_hydrated", { + instances: mergedSnapshot.instances.length, + reminderNotifications: mergedSnapshot.reminderNotifications.length, + hash: snapshotHash(mergedSnapshot), + }); + } + await refreshStorageState(); +} + +function mergeReminderNotifications(gitValues: TodoReminderNotification[], databaseValues: TodoReminderNotification[]): TodoReminderNotification[] { + const merged = new Map(); + for (const value of [...gitValues, ...databaseValues]) merged.set(`${value.instanceId}\0${value.todoId}\0${value.reminderAt}`, value); + return [...merged.values()].sort((left, right) => left.sentAt.localeCompare(right.sentAt)); +} + +async function enqueueMutation(operation: () => Promise): Promise { + const next = mutationQueue.then(operation, operation); + mutationQueue = next.catch(() => undefined); + return await next; +} + +function recordSuccessfulWrite(snapshot: TodoSnapshot, persisted: Record): void { + const hash = snapshotHash(snapshot); + storageState = { + ok: true, + initialized: true, + checkedAt: new Date().toISOString(), + config: gitStorage.summary(), + verification: { + ok: true, + databaseHash: hash, + gitHash: gitStorage.enabled() ? hash : null, + }, + lastWrite: persisted, + }; +} + +async function mutate(reason: string, operation: () => Promise): Promise { + return await enqueueMutation(async () => { + if (!gitStorage.enabled()) return await operation(); + const before = await repository.exportSnapshot(); + const result = await operation(); + try { + const after = await repository.exportSnapshot(); + const persisted = await gitStorage.writeSnapshot(after, reason); + recordSuccessfulWrite(after, persisted); + return result; + } catch (error) { + try { + await repository.replaceSnapshot(before, {}, { preserveReminderNotifications: true }); + } catch (rollbackError) { + log("error", "database_rollback_failed", { reason, error: errorDetail(rollbackError) }); + } + storageState = { + ok: false, + initialized: true, + checkedAt: new Date().toISOString(), + config: gitStorage.summary(), + error: errorDetail(error), + }; + throw error; + } + }); +} + +async function replaceFromImport(snapshotValue: unknown): Promise> { + const snapshot = normalizeTodoSnapshot(snapshotValue); + return await enqueueMutation(async () => { + const before = await repository.exportSnapshot(); + let imported: TodoSnapshot; + let persisted: Record; + try { + imported = await repository.replaceSnapshot(snapshot); + persisted = await gitStorage.writeSnapshot(imported, "migrate from CC01"); + } catch (error) { + try { + await repository.replaceSnapshot(before); + } catch (rollbackError) { + log("error", "database_rollback_failed", { reason: "migrate from CC01", error: errorDetail(rollbackError) }); + } + throw error; + } + recordSuccessfulWrite(imported, persisted); + return { + ok: true, + instances: imported.instances.length, + historyEntries: imported.instances.reduce((total, entry) => total + entry.history.length, 0), + reminderNotifications: imported.reminderNotifications.length, + hash: snapshotHash(imported), + persisted, + }; + }); +} + +function importSnapshotValue(body: unknown): unknown { + if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError(400, "storage import body must be an object"); + const record = body as Record; + const hasSnapshot = record.snapshot !== undefined; + const hasCompressed = record.snapshotGzipBase64 !== undefined; + if (hasSnapshot === hasCompressed) throw new HttpError(400, "storage import requires exactly one of snapshot or snapshotGzipBase64"); + if (hasSnapshot) return record.snapshot; + if (typeof record.snapshotGzipBase64 !== "string" || record.snapshotGzipBase64.length === 0) { + throw new HttpError(400, "snapshotGzipBase64 must be a non-empty string"); + } + if (record.snapshotGzipBase64.length > 2 * 1024 * 1024) throw new HttpError(413, "compressed storage import is too large"); + try { + const compressed = Buffer.from(record.snapshotGzipBase64, "base64"); + if (compressed.length > 1536 * 1024) throw new HttpError(413, "compressed storage import is too large"); + const decoded = gunzipSync(compressed, { maxOutputLength: 20 * 1024 * 1024 }); + return JSON.parse(decoded.toString("utf8")) as unknown; + } catch (error) { + if (error instanceof HttpError) throw error; + throw new HttpError(400, "snapshotGzipBase64 must contain valid gzip-compressed JSON"); + } +} + +function healthPayload(): Record { + const ok = storageState.ok === true; + return { + ok, + service: "todo-note", + status: ok ? "ready" : "not-ready", + startedAt, + storage: storageState, + deploy: deployInfo(), + }; +} + +async function main(): Promise { + await initializeStorage(); + const reminderNotifier = new TodoReminderNotifier(repository, { log }, loadReminderNotifierConfig(), databaseUrl, async (writeNotification) => { + await enqueueMutation(async () => { + await writeNotification(); + const snapshot = await repository.exportSnapshot(); + try { + const persisted = await gitStorage.writeSnapshot(snapshot, "record reminder notification"); + recordSuccessfulWrite(snapshot, persisted); + } catch (error) { + storageState = { ok: false, initialized: true, checkedAt: new Date().toISOString(), config: gitStorage.summary(), error: errorDetail(error) }; + throw error; + } + }); + }); + reminderNotifier.start(); + logWriter.prune(); + const app = express(); + app.use(express.json({ limit: "20mb" })); + app.use((request, _response, next) => { + log("info", "http_request", { method: request.method, path: request.path }); + next(); + }); + + app.get("/live", (_request, response) => response.json({ ok: true, service: "todo-note", startedAt })); + app.get(["/health", "/api/health"], async (_request, response, next) => { + try { + await repository.ping(); + response.status(storageState.ok === true ? 200 : 503).json({ ...healthPayload(), reminders: reminderNotifier.status() }); + } catch (error) { + next(error); + } + }); + app.get(["/logs", "/api/logs"], (_request, response) => { + response.type("application/x-ndjson").send(`${recentLogs.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + }); + app.get("/api/reminders/status", (_request, response) => response.json({ ok: true, reminders: reminderNotifier.status() })); + app.post("/api/reminders/scan", async (_request, response, next) => { + try { + response.json({ ok: true, reminders: await reminderNotifier.scanNow() }); + } catch (error) { + next(error); + } + }); + app.get("/api/storage/status", (_request, response) => response.json(storageState)); + app.get("/api/storage/verify", async (_request, response, next) => { + try { + const state = await enqueueMutation(refreshStorageState); + response.status(state.ok === true ? 200 : 409).json(state); + } catch (error) { + next(error); + } + }); + app.post("/api/storage/migrate", async (request, response, next) => { + try { + const result = await enqueueMutation(async () => { + const snapshot = await repository.exportSnapshot(); + if (request.body?.dryRun === true) return { + ok: true, + dryRun: true, + instances: snapshot.instances.length, + historyEntries: snapshot.instances.reduce((total, entry) => total + entry.history.length, 0), + reminderNotifications: snapshot.reminderNotifications.length, + hash: snapshotHash(snapshot), + }; + if (request.body?.confirm !== true) throw new HttpError(400, "storage migration requires confirm=true"); + const persisted = await gitStorage.writeSnapshot(snapshot, "migrate runtime index"); + recordSuccessfulWrite(snapshot, persisted); + return { ok: true, dryRun: false, result: persisted }; + }); + response.json(result); + } catch (error) { + next(error); + } + }); + app.post("/api/storage/import", async (request, response, next) => { + try { + if (request.body?.confirm !== true) throw new HttpError(400, "storage import requires confirm=true"); + response.json(await replaceFromImport(importSnapshotValue(request.body))); + } catch (error) { + next(error); + } + }); + + app.get("/api/instances", async (_request, response, next) => { + try { + response.json(await repository.listInstances()); + } catch (error) { + next(error); + } + }); + app.post("/api/instances", async (request, response, next) => { + try { + const name = String(request.body?.name ?? "").trim() || "New List"; + response.status(201).json(await mutate(`create ${name}`, () => repository.createInstance(name))); + } catch (error) { + next(error); + } + }); + app.get("/api/instances/:instanceId", async (request, response, next) => { + try { + response.json(await repository.getInstance(request.params.instanceId)); + } catch (error) { + next(error); + } + }); + app.delete("/api/instances/:instanceId", async (request, response, next) => { + try { + await mutate(`delete ${request.params.instanceId}`, () => repository.deleteInstance(request.params.instanceId)); + response.json({ ok: true }); + } catch (error) { + next(error); + } + }); + app.post("/api/instances/:instanceId/actions", async (request, response, next) => { + try { + const action = request.body?.action as TodoAction; + if (!action || typeof action.type !== "string") throw new HttpError(400, "action.type is required"); + response.json(await mutate(`${action.type} ${request.params.instanceId}`, () => repository.applyAction(request.params.instanceId, action))); + } catch (error) { + next(error); + } + }); + app.post("/api/instances/:instanceId/undo", async (request, response, next) => { + try { + response.json(await mutate(`undo ${request.params.instanceId}`, () => repository.undo(request.params.instanceId))); + } catch (error) { + next(error); + } + }); + app.post("/api/instances/:instanceId/redo", async (request, response, next) => { + try { + response.json(await mutate(`redo ${request.params.instanceId}`, () => repository.redo(request.params.instanceId))); + } catch (error) { + next(error); + } + }); + + app.use((_request, response) => response.status(404).json({ ok: false, error: "route not found" })); + app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => { + const detail = errorDetail(error); + const message = String(detail.message ?? "request failed"); + const status = error instanceof HttpError ? error.status : error instanceof GitStorageError ? 503 : /not found/iu.test(message) ? 404 : 500; + log("error", "request_failed", { status, error: detail }); + response.status(status).json({ ok: false, error: message, detail }); + }); + + const server = app.listen(port, host, () => { + log("info", "server_started", { host, port, storage: gitStorage.summary() }); + }); + const shutdown = async (signal: string): Promise => { + log("info", "server_stopping", { signal }); + server.close(); + await reminderNotifier.stop(); + await repository.close(); + process.exit(0); + }; + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); +} + +if (import.meta.main && !isCheckMode) { + main().catch((error) => { + log("error", "server_start_failed", { error: errorDetail(error) }); + process.exitCode = 1; + }); +} diff --git a/src/components/microservices/todo-note/src/reminder-notifier.ts b/src/components/microservices/todo-note/src/reminder-notifier.ts new file mode 100644 index 00000000..37c8b703 --- /dev/null +++ b/src/components/microservices/todo-note/src/reminder-notifier.ts @@ -0,0 +1,375 @@ +import postgres from "postgres"; + +import type { Registry, TodoInstance, TodoItem } from "./types"; + +interface ReminderRepository { + listInstances(): Promise; + getInstance(instanceId: string): Promise; +} + +interface ReminderLogger { + log(level: "info" | "warn" | "error", event: string, detail?: Record): void; +} + +type ReminderTargetType = "private" | "group"; + +export interface ReminderNotifierConfig { + enabled: boolean; + baseUrl: string; + targetType: ReminderTargetType; + userId: string; + groupId: string; + leadMs: number; + scanIntervalMs: number; + timeoutMs: number; + sendAttempts: number; +} + +interface ReminderCandidate { + instanceId: string; + instanceName: string; + todoId: string; + title: string; + titlePath: string[]; + reminderAt: string; + reminderAtMs: number; +} + +interface ReminderStatus { + enabled: boolean; + target: string; + baseUrl: string; + leadMinutes: number; + scanIntervalMs: number; + timeoutMs: number; + sendAttempts: number; + running: boolean; + lastScanAt: string | null; + nextScanAt: string | null; + lastError: string | null; + sentCount: number; + inFlightCount: number; +} + +function envValue(name: string): string { + const value = process.env[name]; + if (value === undefined) throw new Error(`${name} is required`); + return value.trim(); +} + +function envBoolean(name: string): boolean { + const value = envValue(name).toLowerCase(); + if (value === "true") return true; + if (value === "false") return false; + throw new Error(`${name} must be true or false`); +} + +function envInteger(name: string, min: number, max: number): number { + const value = Number(envValue(name)); + if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer from ${min} to ${max}`); + return value; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function safePreview(value: string, maxChars: number): string { + return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`; +} + +function errorDetail(error: unknown): Record { + if (error instanceof Error) return { name: error.name, message: error.message }; + return { name: "Error", message: String(error) }; +} + +function targetLabel(config: ReminderNotifierConfig): string { + return config.targetType === "group" ? `group:${config.groupId || "-"}` : `private:${config.userId || "-"}`; +} + +function targetConfigured(config: ReminderNotifierConfig): boolean { + if (!config.enabled || !config.baseUrl) return false; + return config.targetType === "group" ? config.groupId.length > 0 : config.userId.length > 0; +} + +function collectReminderCandidates(instance: TodoInstance, nowMs: number, leadMs: number): ReminderCandidate[] { + const candidates: ReminderCandidate[] = []; + const visit = (todo: TodoItem, titlePath: string[]): void => { + const nextPath = [...titlePath, todo.title || "Untitled"]; + if (!todo.completed && todo.reminderAt) { + const reminderAtMs = Date.parse(todo.reminderAt); + if (Number.isFinite(reminderAtMs) && nowMs >= reminderAtMs - leadMs && nowMs <= reminderAtMs) { + candidates.push({ + instanceId: instance.id, + instanceName: instance.name, + todoId: todo.id, + title: todo.title || "Untitled", + titlePath: nextPath, + reminderAt: todo.reminderAt, + reminderAtMs, + }); + } + } + for (const child of todo.children) visit(child, nextPath); + }; + for (const todo of instance.todos) visit(todo, []); + return candidates; +} + +function messageForCandidate(candidate: ReminderCandidate, nowMs: number, leadMs: number): string { + const remainingMinutes = Math.max(0, Math.ceil((candidate.reminderAtMs - nowMs) / 60_000)); + return [ + "Todo Note 时间提醒", + `清单: ${candidate.instanceName}`, + `任务: ${candidate.titlePath.join(" / ")}`, + `提醒时间: ${new Date(candidate.reminderAtMs).toISOString().replace("T", " ").replace(/\.\d{3}Z$/u, " UTC")}`, + `剩余: ${remainingMinutes} 分钟`, + `默认提前: ${Math.ceil(leadMs / 60_000)} 分钟`, + ].join("\n"); +} + +function claudeQqPayload(config: ReminderNotifierConfig, message: string): Record { + return config.targetType === "group" + ? { targetType: "group", groupId: config.groupId, message } + : { targetType: "private", userId: config.userId, message }; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +class ReminderNotificationStore { + private readonly memorySent = new Set(); + private readonly sql: postgres.Sql; + private ready = false; + + constructor(databaseUrl: string) { + this.sql = postgres(databaseUrl, { max: 2, idle_timeout: 20, connect_timeout: 10, connection: { application_name: "unidesk-todo-note-reminders" } }); + } + + async ensureReady(): Promise { + if (this.ready) return; + await this.sql` + CREATE TABLE IF NOT EXISTS todo_note_reminder_notifications ( + id BIGSERIAL PRIMARY KEY, + instance_id TEXT NOT NULL, + todo_id TEXT NOT NULL, + reminder_at TEXT NOT NULL, + target TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + response_preview TEXT NOT NULL DEFAULT '', + sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (instance_id, todo_id, reminder_at) + ) + `; + await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_reminder_notifications_sent_at ON todo_note_reminder_notifications(sent_at DESC)`; + this.ready = true; + } + + async hasSent(candidate: ReminderCandidate): Promise { + const key = `${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`; + if (this.memorySent.has(key)) return true; + const rows = await this.sql>` + SELECT EXISTS ( + SELECT 1 FROM todo_note_reminder_notifications + WHERE instance_id = ${candidate.instanceId} AND todo_id = ${candidate.todoId} AND reminder_at = ${candidate.reminderAt} + ) AS exists + `; + if (Boolean(rows[0]?.exists)) this.memorySent.add(key); + return Boolean(rows[0]?.exists); + } + + async markSent(candidate: ReminderCandidate, target: string, message: string, responsePreview: string): Promise { + this.memorySent.add(`${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`); + await this.sql` + INSERT INTO todo_note_reminder_notifications (instance_id, todo_id, reminder_at, target, title, message, response_preview) + VALUES (${candidate.instanceId}, ${candidate.todoId}, ${candidate.reminderAt}, ${target}, ${candidate.title}, ${message}, ${responsePreview}) + ON CONFLICT (instance_id, todo_id, reminder_at) DO NOTHING + `; + } + + async close(): Promise { + await this.sql.end({ timeout: 5 }); + } +} + +export function loadReminderNotifierConfig(): ReminderNotifierConfig { + const targetType = envValue("TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE").toLowerCase(); + if (targetType !== "private" && targetType !== "group") throw new Error("TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE must be private or group"); + const config: ReminderNotifierConfig = { + enabled: envBoolean("TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED"), + baseUrl: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL").replace(/\/+$/u, ""), + targetType, + userId: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID"), + groupId: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID"), + leadMs: envInteger("TODO_NOTE_REMINDER_LEAD_MINUTES", 1, 24 * 60) * 60_000, + scanIntervalMs: envInteger("TODO_NOTE_REMINDER_SCAN_INTERVAL_MS", 5_000, 10 * 60_000), + timeoutMs: envInteger("TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS", 1_000, 60_000), + sendAttempts: envInteger("TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS", 1, 10), + }; + if (config.enabled && !targetConfigured(config)) throw new Error("Todo Note reminder target is not configured"); + return config; +} + +export class TodoReminderNotifier { + private readonly store: ReminderNotificationStore; + private readonly inFlight = new Set(); + private readonly failureRetryAfter = new Map(); + private timer: ReturnType | null = null; + private running = false; + private scanning = false; + private lastScanAt: string | null = null; + private nextScanAt: string | null = null; + private lastError: string | null = null; + private sentCount = 0; + + constructor( + private readonly repository: ReminderRepository, + private readonly logger: ReminderLogger, + private readonly config: ReminderNotifierConfig, + databaseUrl: string, + private readonly persistNotification: (writeNotification: () => Promise) => Promise, + ) { + this.store = new ReminderNotificationStore(databaseUrl); + } + + start(): void { + if (!targetConfigured(this.config)) { + this.logger.log("info", "todo_reminder_notifier_disabled", { target: targetLabel(this.config), baseUrl: this.config.baseUrl }); + return; + } + this.running = true; + this.logger.log("info", "todo_reminder_notifier_started", this.status()); + this.schedule(1_000); + } + + async stop(): Promise { + this.running = false; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + this.nextScanAt = null; + await this.store.close(); + } + + status(): ReminderStatus { + return { + enabled: targetConfigured(this.config), + target: targetLabel(this.config), + baseUrl: this.config.baseUrl, + leadMinutes: this.config.leadMs / 60_000, + scanIntervalMs: this.config.scanIntervalMs, + timeoutMs: this.config.timeoutMs, + sendAttempts: this.config.sendAttempts, + running: this.running, + lastScanAt: this.lastScanAt, + nextScanAt: this.nextScanAt, + lastError: this.lastError, + sentCount: this.sentCount, + inFlightCount: this.inFlight.size, + }; + } + + async scanNow(): Promise { + if (targetConfigured(this.config)) await this.scanOnce(); + return this.status(); + } + + private schedule(delayMs: number): void { + if (!this.running) return; + this.nextScanAt = new Date(Date.now() + delayMs).toISOString(); + this.timer = setTimeout(() => void this.tick(), delayMs); + this.timer.unref?.(); + } + + private async tick(): Promise { + try { + await this.scanOnce(); + } finally { + this.schedule(this.config.scanIntervalMs); + } + } + + private async scanOnce(): Promise { + if (this.scanning) return; + this.scanning = true; + const nowMs = Date.now(); + this.lastScanAt = nowIso(); + this.nextScanAt = null; + try { + await this.store.ensureReady(); + const registry = await this.repository.listInstances(); + let candidates = 0; + for (const summary of registry.instances) { + const instance = await this.repository.getInstance(summary.id); + for (const candidate of collectReminderCandidates(instance, nowMs, this.config.leadMs)) { + candidates += 1; + await this.maybeSend(candidate, nowMs); + } + } + this.lastError = null; + this.logger.log("info", "todo_reminder_scan_complete", { checked: registry.instances.length, candidates, sentCount: this.sentCount }); + } catch (error) { + this.lastError = error instanceof Error ? error.message : String(error); + this.logger.log("warn", "todo_reminder_scan_failed", errorDetail(error)); + } finally { + this.scanning = false; + } + } + + private async maybeSend(candidate: ReminderCandidate, nowMs: number): Promise { + const key = `${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`; + if (this.inFlight.has(key) || (this.failureRetryAfter.get(key) ?? 0) > nowMs || await this.store.hasSent(candidate)) return; + this.inFlight.add(key); + try { + const message = messageForCandidate(candidate, nowMs, this.config.leadMs); + const responsePreview = await this.postClaudeQqText(message); + try { + await this.persistNotification(() => this.store.markSent(candidate, targetLabel(this.config), message, responsePreview)); + } catch (error) { + this.logger.log("warn", "todo_reminder_persist_failed", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, error: errorDetail(error) }); + return; + } + this.sentCount += 1; + this.failureRetryAfter.delete(key); + this.logger.log("info", "todo_reminder_sent", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, target: targetLabel(this.config) }); + } catch (error) { + this.failureRetryAfter.set(key, Date.now() + Math.min(60_000, this.config.scanIntervalMs * 2)); + this.logger.log("warn", "todo_reminder_send_failed", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, error: errorDetail(error) }); + } finally { + this.inFlight.delete(key); + } + } + + private async postClaudeQqText(message: string): Promise { + let lastError: unknown = null; + for (let attempt = 1; attempt <= this.config.sendAttempts; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.config.timeoutMs); + try { + const response = await fetch(`${this.config.baseUrl}/api/push/text`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(claudeQqPayload(this.config, message)), + signal: controller.signal, + }); + const responseText = await response.text(); + const responsePreview = safePreview(responseText, 500); + if (!response.ok) throw new Error(`ClaudeQQ proxy returned HTTP ${response.status}: ${responsePreview}`); + try { + const parsed = JSON.parse(responseText) as Record; + if (parsed.ok === false || parsed.success === false || parsed.status === "napcat_offline") throw new Error(`ClaudeQQ push failed: ${responsePreview}`); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + return responsePreview; + } catch (error) { + lastError = error; + if (attempt < this.config.sendAttempts) await delay(Math.min(30_000, 1_000 * (2 ** (attempt - 1)))); + } finally { + clearTimeout(timer); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "ClaudeQQ push failed")); + } +} diff --git a/src/components/microservices/todo-note/src/repository.ts b/src/components/microservices/todo-note/src/repository.ts new file mode 100644 index 00000000..dcee47ff --- /dev/null +++ b/src/components/microservices/todo-note/src/repository.ts @@ -0,0 +1,451 @@ +import { readFile } from "node:fs/promises"; +import postgres from "postgres"; + +import { applyInstanceAction, cloneInstance, createInstance, summarizeInstance } from "./domain"; +import type { HistoryEntry, Registry, TodoAction, TodoInstance } from "./types"; + +export interface TodoSnapshotEntry { + instance: TodoInstance; + history: HistoryEntry[]; + source: Record; +} + +export interface TodoReminderNotification { + instanceId: string; + todoId: string; + reminderAt: string; + target: string; + title: string; + message: string; + responsePreview: string; + sentAt: string; +} + +export interface TodoSnapshot { + schemaVersion: 1; + generatedAt: string; + instances: TodoSnapshotEntry[]; + reminderNotifications: TodoReminderNotification[]; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function storageBasePath(): string { + return (process.env.TODO_NOTE_GIT_BASE_PATH || "todo-note").replace(/^\/+|\/+$/gu, ""); +} + +function storageRepo(): string { + return process.env.TODO_NOTE_GIT_REPO || "pikasTech/decision-center-data"; +} + +function instancePath(instanceId: string): string { + if (process.env.TODO_NOTE_STORAGE_PRIMARY === "github-repo") { + return `github://${storageRepo()}/${storageBasePath()}/instances/${instanceId}.json`; + } + return `pg://todo-note/instances/${instanceId}`; +} + +function historyPath(instanceId: string): string { + if (process.env.TODO_NOTE_STORAGE_PRIMARY === "github-repo") { + return `github://${storageRepo()}/${storageBasePath()}/history/${instanceId}.json`; + } + return `pg://todo-note/history/${instanceId}`; +} + +function normalizeInstance(value: unknown): TodoInstance { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Todo instance is not an object"); + } + const record = value as Partial; + if (typeof record.id !== "string" || !record.id || typeof record.name !== "string" || !Array.isArray(record.todos)) { + throw new Error("Todo instance is missing required fields"); + } + return { + id: record.id, + name: record.name, + filePath: instancePath(record.id), + historyPath: historyPath(record.id), + createdAt: typeof record.createdAt === "string" ? record.createdAt : nowIso(), + updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : nowIso(), + historyPointer: typeof record.historyPointer === "number" && Number.isInteger(record.historyPointer) ? record.historyPointer : 0, + todos: record.todos, + }; +} + +function normalizeHistoryEntry(value: unknown): HistoryEntry { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo history entry is not an object"); + const record = value as Partial; + if (!Number.isInteger(record.sequence) || typeof record.timestamp !== "string" || (record.kind !== "snapshot" && record.kind !== "command") || typeof record.action !== "string") { + throw new Error("Todo history entry is missing required fields"); + } + return { + sequence: Number(record.sequence), + timestamp: record.timestamp, + kind: record.kind, + action: record.action, + ...(record.snapshot === undefined ? {} : { snapshot: normalizeInstance(record.snapshot) }), + ...(record.detail === undefined ? {} : { detail: asRecord(record.detail) }), + }; +} + +function normalizeReminderNotification(value: unknown): TodoReminderNotification { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo reminder notification is not an object"); + const record = value as Partial; + for (const key of ["instanceId", "todoId", "reminderAt", "target", "title", "message", "responsePreview", "sentAt"] as const) { + if (typeof record[key] !== "string") throw new Error(`Todo reminder notification ${key} must be a string`); + } + if (!record.instanceId || !record.todoId || !record.reminderAt || !record.target || !record.sentAt) { + throw new Error("Todo reminder notification is missing required fields"); + } + return record as TodoReminderNotification; +} + +export function normalizeTodoSnapshot(value: unknown): TodoSnapshot { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Todo snapshot is not an object"); + const record = value as Partial; + if (record.schemaVersion !== 1 || !Array.isArray(record.instances)) throw new Error("Todo snapshot schemaVersion must be 1"); + const seen = new Set(); + const instances = record.instances.map((rawEntry) => { + if (typeof rawEntry !== "object" || rawEntry === null || Array.isArray(rawEntry)) throw new Error("Todo snapshot entry is not an object"); + const entry = rawEntry as Partial; + const instance = normalizeInstance(entry.instance); + if (seen.has(instance.id)) throw new Error(`Duplicate Todo instance: ${instance.id}`); + seen.add(instance.id); + return { + instance, + history: Array.isArray(entry.history) ? entry.history.map(normalizeHistoryEntry) : [], + source: asRecord(entry.source), + }; + }); + return { + schemaVersion: 1, + generatedAt: typeof record.generatedAt === "string" ? record.generatedAt : nowIso(), + instances, + reminderNotifications: Array.isArray(record.reminderNotifications) ? record.reminderNotifications.map(normalizeReminderNotification) : [], + }; +} + +export class PostgresTodoRepository { + private readonly sql: postgres.Sql; + + constructor(databaseUrl: string) { + this.sql = postgres(databaseUrl, { + max: 4, + idle_timeout: 20, + connect_timeout: 10, + connection: { application_name: "unidesk-todo-note" }, + }); + } + + async close(): Promise { + await this.sql.end({ timeout: 5 }); + } + + async ensureReady(): Promise { + await this.sql` + CREATE TABLE IF NOT EXISTS todo_note_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + file_path TEXT NOT NULL, + history_path TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + history_pointer INTEGER NOT NULL DEFAULT 0, + todos JSONB NOT NULL DEFAULT '[]'::jsonb, + source JSONB NOT NULL DEFAULT '{}'::jsonb + ) + `; + await this.sql` + CREATE TABLE IF NOT EXISTS todo_note_history ( + id BIGSERIAL PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES todo_note_instances(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + timestamp TEXT NOT NULL, + kind TEXT NOT NULL, + action TEXT NOT NULL, + snapshot JSONB, + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_history_instance_sequence ON todo_note_history(instance_id, sequence)`; + await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_instances_updated_at ON todo_note_instances(updated_at DESC)`; + await this.sql` + CREATE TABLE IF NOT EXISTS todo_note_reminder_notifications ( + id BIGSERIAL PRIMARY KEY, + instance_id TEXT NOT NULL, + todo_id TEXT NOT NULL, + reminder_at TEXT NOT NULL, + target TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + response_preview TEXT NOT NULL DEFAULT '', + sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (instance_id, todo_id, reminder_at) + ) + `; + await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_reminder_notifications_sent_at ON todo_note_reminder_notifications(sent_at DESC)`; + } + + async ping(): Promise { + await this.sql`SELECT 1`; + } + + async listInstances(): Promise { + const rows = await this.sql>>` + SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos + FROM todo_note_instances + ORDER BY created_at ASC, id ASC + `; + return { instances: rows.map((row) => summarizeInstance(this.instanceFromRow(row))) }; + } + + async createInstance(name: string): Promise { + const createdAt = nowIso(); + const instance = createInstance(name, "", "", createdAt); + instance.filePath = instancePath(instance.id); + instance.historyPath = historyPath(instance.id); + await this.sql.begin(async (tx) => { + await this.upsertInstance(tx, instance, { createdBy: "unidesk-todo-note" }); + await this.insertHistory(tx, instance.id, { + sequence: 0, + timestamp: createdAt, + kind: "snapshot", + action: "create-instance", + snapshot: cloneInstance(instance), + }); + }); + return instance; + } + + async getInstance(instanceId: string): Promise { + const rows = await this.sql>>` + SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos + FROM todo_note_instances + WHERE id = ${instanceId} + LIMIT 1 + `; + if (!rows[0]) throw new Error(`Instance not found: ${instanceId}`); + return this.instanceFromRow(rows[0]); + } + + async deleteInstance(instanceId: string): Promise { + const result = await this.sql`DELETE FROM todo_note_instances WHERE id = ${instanceId}`; + if (result.count === 0) throw new Error(`Instance not found: ${instanceId}`); + } + + async applyAction(instanceId: string, action: TodoAction): Promise { + const instance = await this.getInstance(instanceId); + const updatedAt = nowIso(); + const updated = applyInstanceAction(instance, action, updatedAt); + updated.historyPointer = instance.historyPointer + 1; + updated.filePath = instancePath(updated.id); + updated.historyPath = historyPath(updated.id); + await this.sql.begin(async (tx) => { + await this.upsertInstance(tx, updated, { updatedBy: "action", action: action.type }); + await this.insertHistory(tx, updated.id, { + sequence: updated.historyPointer, + timestamp: updatedAt, + kind: "snapshot", + action: action.type, + snapshot: cloneInstance(updated), + }); + }); + return updated; + } + + async undo(instanceId: string): Promise { + const instance = await this.getInstance(instanceId); + if (instance.historyPointer === 0) return instance; + const snapshot = await this.snapshotAt(instanceId, instance.historyPointer - 1); + if (!snapshot) throw new Error(`Undo snapshot missing for ${instanceId}`); + snapshot.historyPointer = instance.historyPointer - 1; + snapshot.filePath = instancePath(snapshot.id); + snapshot.historyPath = historyPath(snapshot.id); + await this.sql.begin(async (tx) => { + await this.upsertInstance(tx, snapshot, { updatedBy: "undo" }); + await this.insertHistory(tx, snapshot.id, { + sequence: instance.historyPointer, + timestamp: nowIso(), + kind: "command", + action: "undo", + detail: { from: instance.historyPointer, to: snapshot.historyPointer }, + }); + }); + return snapshot; + } + + async redo(instanceId: string): Promise { + const instance = await this.getInstance(instanceId); + const snapshot = await this.snapshotAt(instanceId, instance.historyPointer + 1); + if (!snapshot) return instance; + snapshot.historyPointer = instance.historyPointer + 1; + snapshot.filePath = instancePath(snapshot.id); + snapshot.historyPath = historyPath(snapshot.id); + await this.sql.begin(async (tx) => { + await this.upsertInstance(tx, snapshot, { updatedBy: "redo" }); + await this.insertHistory(tx, snapshot.id, { + sequence: snapshot.historyPointer, + timestamp: nowIso(), + kind: "command", + action: "redo", + detail: { from: instance.historyPointer, to: snapshot.historyPointer }, + }); + }); + return snapshot; + } + + async exportSnapshot(): Promise { + const instanceRows = await this.sql>>` + SELECT id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source + FROM todo_note_instances + ORDER BY created_at ASC, id ASC + `; + const historyRows = await this.sql>>` + SELECT instance_id, sequence, timestamp, kind, action, snapshot, detail + FROM todo_note_history + ORDER BY instance_id ASC, id ASC + `; + const reminderRows = await this.sql>>` + SELECT instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at::text AS sent_at + FROM todo_note_reminder_notifications + ORDER BY id ASC + `; + const historyByInstance = new Map(); + for (const row of historyRows) { + const instanceId = String(row.instance_id); + const entries = historyByInstance.get(instanceId) ?? []; + entries.push(normalizeHistoryEntry({ + sequence: Number(row.sequence), + timestamp: String(row.timestamp), + kind: row.kind, + action: row.action, + ...(row.snapshot === null ? {} : { snapshot: row.snapshot }), + ...(row.detail === null ? {} : { detail: row.detail }), + })); + historyByInstance.set(instanceId, entries); + } + return { + schemaVersion: 1, + generatedAt: nowIso(), + instances: instanceRows.map((row) => ({ + instance: this.instanceFromRow(row), + history: historyByInstance.get(String(row.id)) ?? [], + source: asRecord(row.source), + })), + reminderNotifications: reminderRows.map((row) => normalizeReminderNotification({ + instanceId: row.instance_id, + todoId: row.todo_id, + reminderAt: row.reminder_at, + target: row.target, + title: row.title, + message: row.message, + responsePreview: row.response_preview, + sentAt: row.sent_at, + })), + }; + } + + async replaceSnapshot(snapshot: unknown, source: Record = {}, options: { preserveReminderNotifications?: boolean } = {}): Promise { + const normalized = normalizeTodoSnapshot(snapshot); + await this.sql.begin(async (tx) => { + await tx`DELETE FROM todo_note_history`; + await tx`DELETE FROM todo_note_instances`; + for (const entry of normalized.instances) { + await this.upsertInstance(tx, entry.instance, { ...entry.source, ...source }); + for (const historyEntry of entry.history) await this.insertHistory(tx, entry.instance.id, historyEntry); + if (!entry.history.some((item) => item.kind === "snapshot" && item.sequence === entry.instance.historyPointer)) { + await this.insertHistory(tx, entry.instance.id, { + sequence: entry.instance.historyPointer, + timestamp: entry.instance.updatedAt, + kind: "snapshot", + action: "migration-latest", + snapshot: cloneInstance(entry.instance), + }); + } + } + if (options.preserveReminderNotifications !== true) { + await tx`DELETE FROM todo_note_reminder_notifications`; + for (const notification of normalized.reminderNotifications) await this.insertReminderNotification(tx, notification); + } + }); + return await this.exportSnapshot(); + } + + async readLogs(): Promise { + const logPath = process.env.TODO_NOTE_LOG_PATH || process.env.LOG_FILE; + if (!logPath) return ""; + try { + return await readFile(logPath, "utf8"); + } catch { + return ""; + } + } + + private instanceFromRow(row: Record): TodoInstance { + return normalizeInstance({ + id: row.id, + name: row.name, + createdAt: row.created_at, + updatedAt: row.updated_at, + historyPointer: Number(row.history_pointer), + todos: row.todos, + }); + } + + private async snapshotAt(instanceId: string, sequence: number): Promise { + const rows = await this.sql>` + SELECT snapshot + FROM todo_note_history + WHERE instance_id = ${instanceId} AND sequence = ${sequence} AND kind = 'snapshot' AND snapshot IS NOT NULL + ORDER BY id DESC + LIMIT 1 + `; + return rows[0]?.snapshot ? normalizeInstance(rows[0].snapshot) : null; + } + + private async upsertInstance(tx: postgres.TransactionSql, instance: TodoInstance, source: Record): Promise { + const normalized = normalizeInstance(instance); + await tx` + INSERT INTO todo_note_instances (id, name, file_path, history_path, created_at, updated_at, history_pointer, todos, source) + VALUES (${normalized.id}, ${normalized.name}, ${normalized.filePath}, ${normalized.historyPath}, ${normalized.createdAt}, ${normalized.updatedAt}, ${normalized.historyPointer}, ${tx.json(normalized.todos as never)}, ${tx.json(source as never)}) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + file_path = EXCLUDED.file_path, + history_path = EXCLUDED.history_path, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at, + history_pointer = EXCLUDED.history_pointer, + todos = EXCLUDED.todos, + source = EXCLUDED.source + `; + } + + private async insertHistory(tx: postgres.TransactionSql, instanceId: string, entry: HistoryEntry): Promise { + const normalized = normalizeHistoryEntry(entry); + await tx` + INSERT INTO todo_note_history (instance_id, sequence, timestamp, kind, action, snapshot, detail) + VALUES (${instanceId}, ${normalized.sequence}, ${normalized.timestamp}, ${normalized.kind}, ${normalized.action}, ${normalized.snapshot === undefined ? null : tx.json(normalized.snapshot as never)}, ${normalized.detail === undefined ? null : tx.json(normalized.detail as never)}) + `; + } + + private async insertReminderNotification(tx: postgres.TransactionSql, value: TodoReminderNotification): Promise { + const notification = normalizeReminderNotification(value); + await tx` + INSERT INTO todo_note_reminder_notifications (instance_id, todo_id, reminder_at, target, title, message, response_preview, sent_at) + VALUES (${notification.instanceId}, ${notification.todoId}, ${notification.reminderAt}, ${notification.target}, ${notification.title}, ${notification.message}, ${notification.responsePreview}, ${notification.sentAt}) + ON CONFLICT (instance_id, todo_id, reminder_at) DO UPDATE SET + target = EXCLUDED.target, + title = EXCLUDED.title, + message = EXCLUDED.message, + response_preview = EXCLUDED.response_preview, + sent_at = EXCLUDED.sent_at + `; + } +} diff --git a/src/components/microservices/todo-note/src/types.ts b/src/components/microservices/todo-note/src/types.ts new file mode 100644 index 00000000..6386b6a7 --- /dev/null +++ b/src/components/microservices/todo-note/src/types.ts @@ -0,0 +1,58 @@ +export type TodoFilter = "all" | "active" | "completed"; + +export interface TodoItem { + id: string; + title: string; + completed: boolean; + expanded: boolean; + reminderAt: string | null; + createdAt: string; + updatedAt: string; + children: TodoItem[]; +} + +export interface TodoInstance { + id: string; + name: string; + filePath: string; + historyPath: string; + createdAt: string; + updatedAt: string; + historyPointer: number; + todos: TodoItem[]; +} + +export interface InstanceSummary { + id: string; + name: string; + filePath: string; + historyPath: string; + createdAt: string; + updatedAt: string; + todoCount: number; + completedCount: number; +} + +export interface Registry { + instances: InstanceSummary[]; +} + +export type TodoAction = + | { type: "addTodo"; title: string; parentId?: string } + | { type: "updateTodoTitle"; todoId: string; title: string } + | { type: "toggleTodoCompleted"; todoId: string } + | { type: "toggleTodoExpanded"; todoId: string } + | { type: "setAllTodosExpanded"; expanded: boolean } + | { type: "setTodoReminder"; todoId: string; reminderAt: string | null } + | { type: "moveTodo"; todoId: string; targetParentId?: string; targetIndex: number } + | { type: "deleteTodo"; todoId: string } + | { type: "renameInstance"; name: string }; + +export interface HistoryEntry { + sequence: number; + timestamp: string; + kind: "snapshot" | "command"; + action: string; + snapshot?: TodoInstance; + detail?: Record; +} diff --git a/src/components/microservices/todo-note/tsconfig.json b/src/components/microservices/todo-note/tsconfig.json new file mode 100644 index 00000000..f2159353 --- /dev/null +++ b/src/components/microservices/todo-note/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun", "node"] + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +}