@starci/hfs 4.0.10 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +221 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -1,488 +0,0 @@
|
|
|
1
|
-
// scripts/connectors/lib.mjs — what the ask gateway, the tunnel manager and the
|
|
2
|
-
// Telegram notifier share: the host locks every singleton manager claims, the
|
|
3
|
-
// connectors rows (machine.sqlite `connectors`: gateway, tunnel,
|
|
4
|
-
// telegram-bridge, telegram-route, supervisor-channel:<id>), the repositories
|
|
5
|
-
// whose asks go public, the read-only projection of open ask forms, and the
|
|
6
|
-
// small process helpers. docs/connectors.md is the design note.
|
|
7
|
-
//
|
|
8
|
-
// Every ledger read here goes through inspectLedger (read-only): the
|
|
9
|
-
// connectors observe asks, they never write a ledger. Every piece of host state
|
|
10
|
-
// lives in machine.sqlite (engine/db/machine.mjs): a manager lock is a
|
|
11
|
-
// host_locks row, a launch still starting is that row in state 'starting'.
|
|
12
|
-
import fs from 'node:fs';
|
|
13
|
-
import os from 'node:os';
|
|
14
|
-
import path from 'node:path';
|
|
15
|
-
import { spawnNode } from '../api/node/spawn-node.mjs';
|
|
16
|
-
import { inspectLedger, ledgerFileFor, isRuntimeRoot, hasLedger } from '../../engine/db/ledger.mjs';
|
|
17
|
-
import { machineLog, pidAlive, readMachine, withMachine } from '../../engine/db/machine.mjs';
|
|
18
|
-
import { skillRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
|
|
19
|
-
import { loadConfig } from '../../engine/config.mjs';
|
|
20
|
-
import { parseJson, readJsonFile } from '../lib/json.mjs';
|
|
21
|
-
import { jobDisplayNameOf, workflowNameOf } from '../lib/display-names.mjs';
|
|
22
|
-
import { sleep } from '../lib/sleep.mjs';
|
|
23
|
-
import { OWNED_PROCESS_SCHEMA } from '../lib/process-identity.mjs';
|
|
24
|
-
import { stopOwnedProcess } from '../api/process/stop-owned-process.mjs';
|
|
25
|
-
import { canonicalJSON } from '../../engine/canonical-json.mjs';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/** When this host last booted (ms). */
|
|
29
|
-
const hostBootAt = () => Date.now() - os.uptime() * 1000;
|
|
30
|
-
/**
|
|
31
|
-
* Whether the process a state record names ({pid, startedAt}) is still that process: its pid is
|
|
32
|
-
* live AND it started in this boot. After a reboot the recorded pid may name an unrelated process,
|
|
33
|
-
* and a connector that trusted it would never start again. `startedAt` is an ISO time or epoch ms.
|
|
34
|
-
*/
|
|
35
|
-
export const recordAlive = (record) => {
|
|
36
|
-
if (!record?.pid || !pidAlive(record.pid)) return false;
|
|
37
|
-
const started = typeof record.startedAt === 'number' ? record.startedAt : Date.parse(record.startedAt ?? '');
|
|
38
|
-
return !Number.isFinite(started) || started >= hostBootAt() - 60_000;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/* ------------------------------------------------------------ host locks (machine.sqlite host_locks) */
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* How long a held manager lock stays unexpired without a renewal. The holder renews it every LOCK_RENEW_MS from an
|
|
45
|
-
* unref'd timer, so an expired lock (v_leaks) is one whose holder stopped renewing. Who holds a lock is decided by its
|
|
46
|
-
* holder pid (recordAlive: live and of this boot), never by the expiry: a busy holder that missed a renewal keeps it.
|
|
47
|
-
*/
|
|
48
|
-
const LOCK_TTL_MS = 10 * 60_000;
|
|
49
|
-
const LOCK_RENEW_MS = 3 * 60_000;
|
|
50
|
-
const holderLabel = () => (process.argv[1] ? path.basename(process.argv[1]) : 'node');
|
|
51
|
-
/** A host_locks row as the record callers read: {pid, startedAt (ISO), at, handedOverFrom, state, holder}. */
|
|
52
|
-
const lockRecord = (row) => (row ? { pid: row.holder_pid, startedAt: new Date(row.started_at).toISOString(), at: row.started_at,
|
|
53
|
-
handedOverFrom: row.handed_over_from ?? null, state: row.state, holder: row.holder ?? null } : null);
|
|
54
|
-
const liveRow = (row) => Boolean(row) && row.state !== 'released' && recordAlive({ pid: row.holder_pid, startedAt: row.started_at });
|
|
55
|
-
|
|
56
|
-
// The renewal timers of the locks this process holds, by name.
|
|
57
|
-
const renewals = new Map();
|
|
58
|
-
const stopRenewal = (name) => { clearInterval(renewals.get(name)); renewals.delete(name); };
|
|
59
|
-
function startRenewal(name, env) {
|
|
60
|
-
stopRenewal(name);
|
|
61
|
-
const timer = setInterval(() => {
|
|
62
|
-
let still = true;
|
|
63
|
-
try { still = withMachine((m) => m.renewHostLock({ name, ttlMs: LOCK_TTL_MS }), { env }); } catch { /* a busy store: the next tick tries again */ }
|
|
64
|
-
if (!still) stopRenewal(name);
|
|
65
|
-
}, LOCK_RENEW_MS);
|
|
66
|
-
timer.unref?.();
|
|
67
|
-
renewals.set(name, timer);
|
|
68
|
-
}
|
|
69
|
-
/** The handle a successful claim returns: release() frees the lock only while it still names this process. */
|
|
70
|
-
function heldBy(name, env, extra = {}) {
|
|
71
|
-
startRenewal(name, env);
|
|
72
|
-
const release = () => {
|
|
73
|
-
stopRenewal(name);
|
|
74
|
-
try { withMachine((m) => m.releaseHostLock({ name }), { env }); } catch { /* the store is gone: nothing left to free */ }
|
|
75
|
-
};
|
|
76
|
-
return { ok: true, release, name, ...extra };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Take `name` for this process inside one transaction on `m`: refused while another live process of this boot holds
|
|
81
|
-
* it (state 'held'); a dead holder, one from an earlier boot, or a launch still 'starting' is replaced. Returns
|
|
82
|
-
* {ok:true} or {ok:false, holder}.
|
|
83
|
-
*/
|
|
84
|
-
function takeLock(m, name) {
|
|
85
|
-
return m.transaction(() => {
|
|
86
|
-
const custody = recordOf(m.connectorOf(name));
|
|
87
|
-
if (connectorManagerBlocked(custody)) return { ok: false, holder: custody, reason: 'connector-child-custody-unreconciled' };
|
|
88
|
-
const cur = m.hostLock(name);
|
|
89
|
-
if (cur && cur.state === 'held' && cur.holder_pid !== process.pid && liveRow(cur)) return { ok: false, holder: lockRecord(cur) };
|
|
90
|
-
if (cur && cur.state !== 'released' && cur.holder_pid !== process.pid) m.releaseHostLock({ name, force: true });
|
|
91
|
-
const got = m.acquireHostLock({ name, holder: holderLabel(), ttlMs: LOCK_TTL_MS, state: 'held' });
|
|
92
|
-
return got.ok ? { ok: true } : { ok: false, holder: lockRecord(got.holder) };
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Claim the single-manager host lock `name` for this process. `current` is the manager's own record (its connectors
|
|
98
|
-
* row: gateway, tunnel): a live one that is not this process owns the state even without the lock. A lock whose
|
|
99
|
-
* holder is dead or from an earlier boot is stale and taken over. Returns {ok:true, release} or {ok:false, holder}.
|
|
100
|
-
*/
|
|
101
|
-
export function claimManager(name, { current = null, env = process.env } = {}) {
|
|
102
|
-
if (connectorManagerBlocked(current)) return { ok: false, holder: current, reason: 'connector-child-custody-unreconciled' };
|
|
103
|
-
if (current && current.pid !== process.pid && recordAlive(current)) return { ok: false, holder: current };
|
|
104
|
-
const got = withMachine((m) => takeLock(m, name), { env });
|
|
105
|
-
return got.ok ? heldBy(name, env) : { ok: false, holder: got.holder ?? null, ...(got.reason ? { reason: got.reason } : {}) };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Claim a manager lock that may be handed over (scripts/machine/self-reload.mjs): a loop that re-execs itself
|
|
110
|
-
* spawns its replacement with `from` = its own pid and waits, still holding the lock, until the lock names
|
|
111
|
-
* the replacement. The replacement takes the row over only while it still names `from`, in one transaction,
|
|
112
|
-
* so there is no moment the lock is free for a third claimant. Without `from`, or when the lock no longer
|
|
113
|
-
* names it, this is claimManager. Returns {ok:true, release, takenOver?} or {ok:false, holder}.
|
|
114
|
-
*/
|
|
115
|
-
export function claimOrTakeOver(name, { from = null, env = process.env } = {}) {
|
|
116
|
-
const fromPid = Number(from);
|
|
117
|
-
if (Number.isInteger(fromPid) && fromPid > 0) {
|
|
118
|
-
const took = withMachine((m) => m.transaction(() => {
|
|
119
|
-
if (connectorManagerBlocked(recordOf(m.connectorOf(name)))) return false;
|
|
120
|
-
const cur = m.hostLock(name);
|
|
121
|
-
if (!cur || cur.state === 'released' || cur.holder_pid !== fromPid) return false;
|
|
122
|
-
const at = m.now();
|
|
123
|
-
return m.update('host_locks', { holder_pid: process.pid, holder: holderLabel(), started_at: at, heartbeat_at: at, expires_at: at + LOCK_TTL_MS,
|
|
124
|
-
handed_over_from: fromPid, state: 'held' }, { name, holder_pid: fromPid }).changes > 0;
|
|
125
|
-
}), { env });
|
|
126
|
-
if (took) return heldBy(name, env, { takenOver: true });
|
|
127
|
-
}
|
|
128
|
-
return claimManager(name, { env });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Make the lock name this process again (a handover that failed after the replacement took it). */
|
|
132
|
-
export const reassertManager = (name, { env = process.env } = {}) => {
|
|
133
|
-
const ok = withMachine((m) => m.transaction(() => {
|
|
134
|
-
const cur = m.hostLock(name);
|
|
135
|
-
if (cur && cur.state !== 'released' && cur.holder_pid !== process.pid) m.releaseHostLock({ name, force: true });
|
|
136
|
-
return m.acquireHostLock({ name, holder: holderLabel(), ttlMs: LOCK_TTL_MS, state: 'held' }).ok;
|
|
137
|
-
}), { env });
|
|
138
|
-
if (ok) startRenewal(name, env);
|
|
139
|
-
return ok;
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
/** The live holder of a manager lock ({pid, startedAt, handedOverFrom, ...}), or null. A launch still starting is not one. */
|
|
143
|
-
export const lockHolder = (name, env = process.env) => {
|
|
144
|
-
const row = readMachine((m) => m.hostLock(name), null, { env });
|
|
145
|
-
return row?.state === 'held' && liveRow(row) ? lockRecord(row) : null;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* A manager a starter just launched but that has not claimed its lock yet (node takes a while to
|
|
150
|
-
* start on a loaded host). `start` marks the lock row 'starting' with the launched pid right after the
|
|
151
|
-
* spawn, and every liveness test counts it for STARTING_MS while that pid lives, so two starters in
|
|
152
|
-
* that window do not both launch a manager. The launched manager's claim turns the row 'held'.
|
|
153
|
-
*/
|
|
154
|
-
const STARTING_MS = 30_000;
|
|
155
|
-
export const markStarting = (name, pid, env = process.env) => {
|
|
156
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
157
|
-
return withMachine((m) => m.transaction(() => {
|
|
158
|
-
const cur = m.hostLock(name);
|
|
159
|
-
// A live holder (the launched manager already claimed, or another one) is never overwritten.
|
|
160
|
-
if (cur && cur.state === 'held' && liveRow(cur)) return false;
|
|
161
|
-
if (cur && cur.state !== 'released' && cur.holder_pid !== pid) m.releaseHostLock({ name, force: true });
|
|
162
|
-
return m.acquireHostLock({ name, holder: 'starting', pid, ttlMs: STARTING_MS, state: 'starting' }).ok;
|
|
163
|
-
}), { env });
|
|
164
|
-
};
|
|
165
|
-
export const startingHolder = (name, env = process.env, { windowMs = STARTING_MS, now = Date.now() } = {}) => {
|
|
166
|
-
const row = readMachine((m) => m.hostLock(name), null, { env });
|
|
167
|
-
return row?.state === 'starting' && now - row.started_at < windowMs && pidAlive(row.holder_pid)
|
|
168
|
-
? { pid: row.holder_pid, at: row.started_at, startedAt: new Date(row.started_at).toISOString() } : null;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Run async `fn` while holding the host lock `name` (a short cross-process mutex: the Telegram notice store, the
|
|
173
|
-
* media dedupe). Waits up to `waitMs`, then resolves {ok:false, skipped} without running `fn`. Calls from one
|
|
174
|
-
* process are chained, since a host lock is per pid.
|
|
175
|
-
*/
|
|
176
|
-
const chains = new Map();
|
|
177
|
-
export function withHostMutex(name, fn, { env = process.env, waitMs = 10_000, stepMs = 100 } = {}) {
|
|
178
|
-
const prior = chains.get(name) ?? Promise.resolve();
|
|
179
|
-
const run = prior.catch(() => {}).then(async () => {
|
|
180
|
-
const end = Date.now() + waitMs;
|
|
181
|
-
for (;;) {
|
|
182
|
-
let got;
|
|
183
|
-
try { got = withMachine((m) => takeLock(m, name), { env }); } catch { got = { ok: false }; }
|
|
184
|
-
if (got.ok) break;
|
|
185
|
-
if (Date.now() > end) return { ok: false, skipped: `${name} is locked` };
|
|
186
|
-
await sleep(stepMs);
|
|
187
|
-
}
|
|
188
|
-
try { return await fn(); } finally { try { withMachine((m) => m.releaseHostLock({ name }), { env }); } catch { /* gone */ } }
|
|
189
|
-
});
|
|
190
|
-
chains.set(name, run);
|
|
191
|
-
run.catch(() => {}).finally(() => { if (chains.get(name) === run) chains.delete(name); });
|
|
192
|
-
return run;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export const CONNECTOR_LAUNCH_ENV = 'STARCI_CONNECTOR_LAUNCH';
|
|
196
|
-
|
|
197
|
-
/* ------------------------------------------------------------ connectors rows (machine.sqlite connectors) */
|
|
198
|
-
|
|
199
|
-
const recordOf = (row) => (row ? { ...(row.config ?? {}), pid: row.pid ?? row.config?.pid ?? null, port: row.port ?? row.config?.port ?? null,
|
|
200
|
-
publicUrl: row.public_url ?? null, state: row.state ?? null, cursor: row.cursor ?? null, updatedAt: row.updated_at } : null);
|
|
201
|
-
/**
|
|
202
|
-
* One connector's record, or null: its config_json spread, with the row's pid, port, publicUrl, state, cursor and
|
|
203
|
-
* updatedAt. `name`: gateway | tunnel | telegram-bridge | telegram-route | supervisor-channel:<id>.
|
|
204
|
-
*/
|
|
205
|
-
export const connectorState = (name, env = process.env) => readMachine((m) => recordOf(m.connectorOf(name)), null, { env });
|
|
206
|
-
/** Every connector row whose name starts with `prefix`, as connectorState records with their `name`. */
|
|
207
|
-
export const connectorStates = (prefix, env = process.env) => readMachine((m) => m.db.prepare('SELECT name FROM connectors WHERE substr(name,1,length(?))=? ORDER BY name')
|
|
208
|
-
.all(prefix, prefix).map((r) => ({ name: r.name, ...recordOf(m.connectorOf(r.name)) })), [], { env });
|
|
209
|
-
/**
|
|
210
|
-
* Write one connector row: only the fields given change (`config` and `cursor` replace their column whole). Returns
|
|
211
|
-
* true. No secret ever goes here (the token lives in .starcistacks / the secrets file).
|
|
212
|
-
*/
|
|
213
|
-
export const writeConnectorState = (name, { kind, state, pid, port, publicUrl, config, cursor } = {}, env = process.env) => withMachine((m) => m.transaction(() => {
|
|
214
|
-
const current = recordOf(m.connectorOf(name));
|
|
215
|
-
if (connectorChildUnresolved(current) && config !== undefined
|
|
216
|
-
&& (config.childLaunchNonce !== current.childLaunchNonce || canonicalJSON(config.childIdentity ?? null) !== canonicalJSON(current.childIdentity ?? null)
|
|
217
|
-
|| canonicalJSON(config.childCapture ?? null) !== canonicalJSON(current.childCapture ?? null)
|
|
218
|
-
|| closedProcess(current.stopReceipt?.manager, current.processIdentity)
|
|
219
|
-
&& canonicalJSON(config.stopReceipt?.manager ?? null) !== canonicalJSON(current.stopReceipt.manager)))
|
|
220
|
-
throw Error('connector-child-custody-unreconciled');
|
|
221
|
-
const row = { name, updated_at: m.now(), kind, state, pid, port, public_url: publicUrl, config_json: config, cursor_json: cursor };
|
|
222
|
-
m.upsert('connectors', Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined)), ['name']);
|
|
223
|
-
return true;
|
|
224
|
-
}), { env });
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const sameIdentity = (a, b) => Boolean(a && b) && canonicalJSON(a) === canonicalJSON(b);
|
|
228
|
-
const sameConnectorOwner = (a, b) => Boolean(a && b) && a.pid === b.pid && a.source === b.source && sameIdentity(a.processIdentity, b.processIdentity);
|
|
229
|
-
const closedProcess = (receipt, identity) => receipt?.schema === OWNED_PROCESS_SCHEMA && receipt.ok === true
|
|
230
|
-
&& ['gone', 'stopped'].includes(receipt.outcome) && receipt.proof === 'process-handle-signaled'
|
|
231
|
-
&& receipt.pid === identity?.pid && sameIdentity(receipt.identity, identity);
|
|
232
|
-
const closedChild = (receipt, record) => Boolean(receipt && record.childLaunchNonce && receipt.launchNonce === record.childLaunchNonce)
|
|
233
|
-
&& (closedProcess(receipt, record.childIdentity) || receipt.ok === true && receipt.proof === 'owned-child-process-exit'
|
|
234
|
-
&& receipt.pid === record.childIdentity?.pid || receipt.ok === true && receipt.proof === 'owned-child-spawn-failed' && receipt.pid == null);
|
|
235
|
-
|
|
236
|
-
/** Original child custody remains owed even when its manager or lock no longer lives. */
|
|
237
|
-
export const connectorChildUnresolved = (record) => Boolean(record && (record.childLaunchNonce || record.childPid || record.childIdentity))
|
|
238
|
-
&& !closedChild(record.childClosure, record) && !closedChild(record.stopReceipt?.child, record);
|
|
239
|
-
export const connectorManagerBlocked = (record) => connectorChildUnresolved(record)
|
|
240
|
-
&& (closedProcess(record.stopReceipt?.manager, record.processIdentity) || !recordAlive(record));
|
|
241
|
-
|
|
242
|
-
const retainConnectorClosure = (name, expected, receipt, env) => withMachine((m) => m.transaction(() => {
|
|
243
|
-
const row = m.connectorOf(name), current = recordOf(row);
|
|
244
|
-
if (!sameConnectorOwner(current, expected) || canonicalJSON(current) !== canonicalJSON(expected)) return false;
|
|
245
|
-
m.upsert('connectors', { name, updated_at: m.now(), config_json: { ...(row.config ?? {}), stopReceipt: receipt } }, ['name']);
|
|
246
|
-
return true;
|
|
247
|
-
}), { env });
|
|
248
|
-
|
|
249
|
-
/** Close only recorded process objects; unknown effects retain the original row and a concurrent new owner is never overwritten. */
|
|
250
|
-
export function stopConnector(name, { source, child = false, env = process.env, read = connectorState, stop = stopOwnedProcess,
|
|
251
|
-
retain = (expected, receipt) => retainConnectorClosure(name, expected, receipt, env),
|
|
252
|
-
commit = (expected, receipt) => withMachine((m) => m.transaction(() => {
|
|
253
|
-
const row = m.connectorOf(name), current = recordOf(row);
|
|
254
|
-
if (!sameConnectorOwner(current, expected) || canonicalJSON(current) !== canonicalJSON(expected)) return false;
|
|
255
|
-
const stoppedAt = new Date(m.now()).toISOString();
|
|
256
|
-
m.upsert('connectors', { name, state: 'stopped', pid: null, public_url: null, updated_at: m.now(),
|
|
257
|
-
config_json: { ...(row.config ?? {}), pid: null, childPid: null, connected: false, stoppedAt, stopReceipt: receipt } }, ['name']);
|
|
258
|
-
return true;
|
|
259
|
-
}), { env }) } = {}) {
|
|
260
|
-
let original;
|
|
261
|
-
try { original = read(name, env); } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-state-unreadable', error: String(error?.message ?? error) }; }
|
|
262
|
-
if (!original) return { ok: false, effectState: 'unknown', reason: 'connector-custody-missing' };
|
|
263
|
-
if (original.source !== source) return { ok: false, effectState: 'none', reason: 'connector-source-conflict', custody: original };
|
|
264
|
-
const prior = original.stopReceipt;
|
|
265
|
-
if (original.state === 'stopped' && closedProcess(prior?.manager, original.processIdentity)
|
|
266
|
-
&& (!child || closedChild(prior?.child, original)))
|
|
267
|
-
return { ok: true, already: true, effectState: 'completed', stopped: original.processIdentity.pid, receipt: prior };
|
|
268
|
-
const identity = original.processIdentity;
|
|
269
|
-
if (!identity || identity.pid !== original.pid) return { ok: false, effectState: 'none', reason: 'connector-process-custody-required', custody: original };
|
|
270
|
-
let manager;
|
|
271
|
-
if (closedProcess(prior?.manager, identity)) manager = prior.manager;
|
|
272
|
-
else { try { manager = stop(identity); } catch (error) { manager = { ok: false, error: String(error?.message ?? error) }; } }
|
|
273
|
-
if (!closedProcess(manager, identity)) return { ok: false, effectState: manager?.outcome === 'refused' ? 'none' : 'unknown',
|
|
274
|
-
reason: 'connector-manager-closure-unverified', custody: original, receipt: { manager } };
|
|
275
|
-
let current;
|
|
276
|
-
try { current = read(name, env); } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-state-unreadable', custody: original, receipt: { manager }, error: String(error?.message ?? error) }; }
|
|
277
|
-
if (!sameConnectorOwner(current, original)) return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt: { manager } };
|
|
278
|
-
if (child) {
|
|
279
|
-
try {
|
|
280
|
-
if (retain(current, { manager, child: current.stopReceipt?.child ?? null }) !== true)
|
|
281
|
-
return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt: { manager } };
|
|
282
|
-
current = read(name, env);
|
|
283
|
-
if (!sameConnectorOwner(current, original)) return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt: { manager } };
|
|
284
|
-
} catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt: { manager }, error: String(error?.message ?? error) }; }
|
|
285
|
-
}
|
|
286
|
-
let childReceipt = null;
|
|
287
|
-
if (child) {
|
|
288
|
-
if (closedChild(current.stopReceipt?.child, current)) childReceipt = current.stopReceipt.child;
|
|
289
|
-
else if (!current.childPid) {
|
|
290
|
-
if (!closedChild(current.childClosure, current)) return { ok: false, effectState: 'unknown', reason: 'connector-child-custody-unverified', custody: current, receipt: { manager } };
|
|
291
|
-
childReceipt = current.childClosure;
|
|
292
|
-
} else {
|
|
293
|
-
if (!current.childIdentity || current.childIdentity.pid !== current.childPid)
|
|
294
|
-
return { ok: false, effectState: 'unknown', reason: 'connector-child-custody-unverified', custody: current, receipt: { manager } };
|
|
295
|
-
try { childReceipt = { ...stop(current.childIdentity), launchNonce: current.childLaunchNonce }; } catch (error) { childReceipt = { ok: false, error: String(error?.message ?? error) }; }
|
|
296
|
-
if (!closedProcess(childReceipt, current.childIdentity)) return { ok: false, effectState: 'unknown',
|
|
297
|
-
reason: 'connector-child-closure-unverified', custody: current, receipt: { manager, child: childReceipt } };
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
const receipt = { manager, child: childReceipt };
|
|
301
|
-
try {
|
|
302
|
-
if (child) {
|
|
303
|
-
if (retain(current, receipt) !== true) return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt };
|
|
304
|
-
current = read(name, env);
|
|
305
|
-
if (!sameConnectorOwner(current, original) || !closedChild(current?.stopReceipt?.child, current))
|
|
306
|
-
return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt };
|
|
307
|
-
}
|
|
308
|
-
if (commit(current, receipt) !== true) return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt };
|
|
309
|
-
} catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt, error: String(error?.message ?? error) }; }
|
|
310
|
-
return { ok: true, effectState: 'completed', stopped: original.pid, receipt };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** One connector log line in machine_logs (actor connector, kind `<source>.<kind>`). Never throws. */
|
|
314
|
-
export const connectorLog = (source, msg, { env = process.env, level = 'info', kind = 'log', data = null } = {}) =>
|
|
315
|
-
machineLog({ actor: 'connector', kind: `${source}.${kind}`, msg: String(msg), level, data }, { env });
|
|
316
|
-
|
|
317
|
-
/** Launch `node <script> ...args` detached from this process, output discarded. */
|
|
318
|
-
export const spawnDetached = (script, args = [], { env = process.env } = {}) => {
|
|
319
|
-
const child = spawnNode([script, ...args], { detached: true, stdio: 'ignore', cwd: skillRoot, env });
|
|
320
|
-
child.unref();
|
|
321
|
-
return child.pid ?? null;
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* The repositories whose ledgers the connectors read. `connectors.repos` when the owner listed any
|
|
329
|
-
* (relative entries resolve against the source root); otherwise the source root itself plus the Work
|
|
330
|
-
* owner of every .workspaces/projects/<p>/work.json binding — each kept only when it holds a ledger.
|
|
331
|
-
*/
|
|
332
|
-
export function askRepos(connectors, { env = process.env, extra = [] } = {}) {
|
|
333
|
-
const source = starciSourceRoot(env);
|
|
334
|
-
const listed = (connectors?.repos ?? []).map((repo) => path.resolve(source, repo));
|
|
335
|
-
let candidates = listed;
|
|
336
|
-
if (!listed.length) {
|
|
337
|
-
candidates = [source];
|
|
338
|
-
const projects = path.join(source, '.workspaces', 'projects');
|
|
339
|
-
let entries = [];
|
|
340
|
-
try { entries = fs.readdirSync(projects, { withFileTypes: true }); } catch { /* no bindings */ }
|
|
341
|
-
for (const entry of entries) {
|
|
342
|
-
if (!entry.isDirectory()) continue;
|
|
343
|
-
const doc = readJsonFile(path.join(projects, entry.name, 'work.json'));
|
|
344
|
-
const rel = doc?.schema === 'starci/workspace-binding@2' ? doc?.repository?.pathFromSource : null;
|
|
345
|
-
if (typeof rel === 'string' && rel.trim()) candidates.push(path.resolve(source, rel));
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
const seen = new Set(), out = [];
|
|
349
|
-
for (const repo of [...candidates, ...extra.map((r) => path.resolve(r))]) {
|
|
350
|
-
const key = process.platform === 'win32' ? repo.toLowerCase() : repo;
|
|
351
|
-
if (seen.has(key) || !hasLedger(repo)) continue;
|
|
352
|
-
seen.add(key); out.push(repo);
|
|
353
|
-
}
|
|
354
|
-
return out;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** The nonce path segment serve-ask binds (`/a-<hex>`). */
|
|
358
|
-
export const NONCE = /^a-[0-9a-f]{8,64}$/;
|
|
359
|
-
const nonceOf = (url) => {
|
|
360
|
-
try { const first = new URL(url).pathname.split('/')[1] ?? ''; return NONCE.test(first) ? first : null; } catch { return null; }
|
|
361
|
-
};
|
|
362
|
-
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
363
|
-
const isLoopbackUrl = (url) => {
|
|
364
|
-
try { const u = new URL(url); return u.protocol === 'http:' && LOOPBACK.has(u.hostname); } catch { return false; }
|
|
365
|
-
};
|
|
366
|
-
/** A credential ask names custody files or env variables to fill (serve-ask payload.fields). */
|
|
367
|
-
const isCredentialAsk = (fields) => Boolean((fields?.files?.length ?? 0) + (fields?.vars?.length ?? 0));
|
|
368
|
-
|
|
369
|
-
const parse = parseJson;
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Every ask whose serve-ask form is still open in one ledger, newest serving per dispatch:
|
|
373
|
-
* not answered, not expired or superseded after it was served, and inside its ttl.
|
|
374
|
-
*/
|
|
375
|
-
export function servingAsks(db, { now = Date.now() } = {}) {
|
|
376
|
-
const rows = db.prepare(`SELECT seq, workflow_id, payload_json, created_at FROM events WHERE kind='ask-serving' ORDER BY seq DESC`).all();
|
|
377
|
-
const seen = new Set(), out = [];
|
|
378
|
-
const closedAfter = db.prepare(`SELECT 1 FROM events WHERE workflow_id=? AND json_extract(payload_json,'$.dispatchId')=?
|
|
379
|
-
AND (kind='ask-answered' OR (kind IN ('ask-serving-expired','ask-superseded') AND seq>?)) LIMIT 1`);
|
|
380
|
-
for (const row of rows) {
|
|
381
|
-
const payload = parse(row.payload_json, {}) ?? {};
|
|
382
|
-
const dispatchId = payload.dispatchId;
|
|
383
|
-
if (!dispatchId || seen.has(`${row.workflow_id}\u0000${dispatchId}`)) continue;
|
|
384
|
-
seen.add(`${row.workflow_id}\u0000${dispatchId}`);
|
|
385
|
-
if (closedAfter.get(row.workflow_id, dispatchId, row.seq)) continue;
|
|
386
|
-
const ask = servingRecord(row, payload, now);
|
|
387
|
-
if (ask) out.push(ask);
|
|
388
|
-
}
|
|
389
|
-
return out;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// One ask-serving row as an open form, or null: past its ttl, not a loopback nonce URL, or its
|
|
393
|
-
// recorded serve-ask process is gone (the form stops being served the moment its process exits).
|
|
394
|
-
function servingRecord(row, payload, now) {
|
|
395
|
-
if (Number.isFinite(payload.ttlMs) && row.created_at + payload.ttlMs <= now) return null;
|
|
396
|
-
const nonce = nonceOf(payload.url);
|
|
397
|
-
if (!nonce || !isLoopbackUrl(payload.url)) return null;
|
|
398
|
-
if (Number.isInteger(payload.pid) && !pidAlive(payload.pid)) return null;
|
|
399
|
-
return {
|
|
400
|
-
seq: row.seq, workflowId: row.workflow_id, dispatchId: payload.dispatchId, url: payload.url, nonce, pid: payload.pid ?? null,
|
|
401
|
-
fields: payload.fields ?? { files: [], vars: [] }, credential: isCredentialAsk(payload.fields), onDemand: payload.onDemand === true,
|
|
402
|
-
createdAt: row.created_at, expiresAt: Number.isFinite(payload.ttlMs) ? row.created_at + payload.ttlMs : null,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* One filed ask as the owner-facing connectors see it (read-only on `db`): {workflowId, dispatchId,
|
|
408
|
-
* opId, title, question, closed, serving}, or null when no ask report was filed for it. `closed` is
|
|
409
|
-
* 'answered', 'superseded' (retired, and not re-parked since: a later ask-notified or ask-serving
|
|
410
|
-
* reopens it), else null. `serving` is the live form (servingAsks' rules) or null: an open ask
|
|
411
|
-
* with no live form is healthy, its link is generated on demand from Telegram.
|
|
412
|
-
*/
|
|
413
|
-
export function askState(db, workflowId, dispatchId, { now = Date.now() } = {}) {
|
|
414
|
-
const report = db.prepare("SELECT a.op_id,r.job_id,r.report_json FROM reports r JOIN op_attempts a ON a.attempt_id=r.attempt_id WHERE r.workflow_id=? AND r.dispatch_id=? AND r.outcome='ask' ORDER BY r.report_id DESC LIMIT 1").get(workflowId, dispatchId);
|
|
415
|
-
if (!report) return null;
|
|
416
|
-
const last = (kinds) => db.prepare(`SELECT seq, payload_json, created_at FROM events WHERE workflow_id=? AND kind IN (${kinds.map(() => '?').join(',')})
|
|
417
|
-
AND json_extract(payload_json,'$.dispatchId')=? ORDER BY seq DESC LIMIT 1`).get(workflowId, ...kinds, dispatchId) ?? null;
|
|
418
|
-
const answered = last(['ask-answered']), superseded = last(['ask-superseded']), reopened = last(['ask-serving', 'ask-notified']);
|
|
419
|
-
const closed = answered ? 'answered' : superseded && !(reopened && reopened.seq > superseded.seq) ? 'superseded' : null;
|
|
420
|
-
let serving = null;
|
|
421
|
-
const row = last(['ask-serving']);
|
|
422
|
-
if (!closed && row) {
|
|
423
|
-
const ended = last(['ask-serving-expired', 'ask-superseded']);
|
|
424
|
-
if (!(ended && ended.seq > row.seq)) serving = servingRecord({ ...row, workflow_id: workflowId }, parse(row.payload_json, {}) ?? {}, now);
|
|
425
|
-
}
|
|
426
|
-
const rj = parse(report.report_json, {}) ?? {};
|
|
427
|
-
return {
|
|
428
|
-
// title: the workflow's display name (starci kernel rename / define-goal), else its goal slug; jobName: the asking
|
|
429
|
-
// op job's `<op label> · <what> · <workflow name>` (scripts/lib/display-names.mjs).
|
|
430
|
-
workflowId, dispatchId, opId: report.op_id ?? null, title: workflowNameOf(db, workflowId), jobName: askJobName(db, workflowId, report),
|
|
431
|
-
question: rj.question ?? { text: rj.summary ?? '', options: [] }, closed, serving,
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** The display name of the op job that filed an ask report, or null. */
|
|
436
|
-
const askJobName = (db, workflowId, report) => {
|
|
437
|
-
try {
|
|
438
|
-
const job = db.prepare("SELECT * FROM jobs WHERE workflow_id=? AND job_id=? AND kind<>'kernel' LIMIT 1")
|
|
439
|
-
.get(workflowId, report.job_id);
|
|
440
|
-
return job ? jobDisplayNameOf(db, job) : null;
|
|
441
|
-
} catch { return null; }
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
/** Every open ask of one ledger (askState with closed null), newest report first, in live workflows only. */
|
|
445
|
-
export function openAskList(db, { now = Date.now() } = {}) {
|
|
446
|
-
const rows = db.prepare(`SELECT r.workflow_id, r.dispatch_id FROM reports r JOIN workflows w ON w.workflow_id=r.workflow_id
|
|
447
|
-
WHERE r.outcome='ask' AND w.archived_at IS NULL AND (w.phase IS NULL OR w.phase<>'finished') ORDER BY r.report_id DESC`).all();
|
|
448
|
-
const seen = new Set(), out = [];
|
|
449
|
-
for (const row of rows) {
|
|
450
|
-
const id = `${row.workflow_id}\u0000${row.dispatch_id}`;
|
|
451
|
-
if (seen.has(id)) continue;
|
|
452
|
-
seen.add(id);
|
|
453
|
-
const state = askState(db, row.workflow_id, row.dispatch_id, { now });
|
|
454
|
-
if (state && !state.closed) out.push(state);
|
|
455
|
-
}
|
|
456
|
-
return out;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** The repos the Telegram ask notices name (notifications kind 'ask'), so the gateway can route their forms. */
|
|
460
|
-
export const notifiedRepos = (env = process.env) => readMachine((m) => [...new Set(m.db.prepare("SELECT json_extract(ref,'$.repo') repo FROM notifications WHERE kind='ask' AND json_valid(ref)").all()
|
|
461
|
-
.map((r) => r.repo).filter((r) => typeof r === 'string' && r))], [], { env });
|
|
462
|
-
|
|
463
|
-
/** Open one repo's ledger read-only for `fn`, always closing it; a missing or unreadable ledger yields `fallback`. */
|
|
464
|
-
export function withLedgerRead(repo, fn, fallback = null) {
|
|
465
|
-
let handle = null;
|
|
466
|
-
try { handle = inspectLedger({ file: ledgerFileFor(repo) }); } catch { return fallback; }
|
|
467
|
-
try { return fn(handle.db); } finally { try { handle.close(); } catch { /* closed */ } }
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/** Open asks across repos, each tagged with its repo. */
|
|
471
|
-
export const servingAsksAcross = (repos, opts = {}) =>
|
|
472
|
-
repos.flatMap((repo) => withLedgerRead(repo, (db) => servingAsks(db, opts).map((ask) => ({ ...ask, repo })), []));
|
|
473
|
-
|
|
474
|
-
/** The owner config, or null when it cannot be read (a connector never dies on a broken file it only observes). */
|
|
475
|
-
export const ownerConfig = () => { try { return loadConfig(); } catch { return null; } };
|
|
476
|
-
|
|
477
|
-
export const argsOf = (argv) => {
|
|
478
|
-
const out = { _: [] };
|
|
479
|
-
for (let i = 0; i < argv.length; i++) {
|
|
480
|
-
const k = argv[i];
|
|
481
|
-
if (!k.startsWith('--')) { out._.push(k); continue; }
|
|
482
|
-
const key = k.slice(2), next = argv[i + 1];
|
|
483
|
-
let value = true;
|
|
484
|
-
if (next !== undefined && !next.startsWith('--')) { value = next; i++; }
|
|
485
|
-
out[key] = key in out ? [].concat(out[key], value) : value;
|
|
486
|
-
}
|
|
487
|
-
return out;
|
|
488
|
-
};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
// clip.mjs — shortening text for a message, a log line or a finding.
|
|
2
|
-
|
|
3
|
-
/** `text` cut to at most `n` characters; a cut text ends in an ellipsis. */
|
|
4
|
-
export const clip = (text, n) => { const s = String(text ?? ''); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
|
|
5
|
-
|
|
6
|
-
/** `text` squashed to one line: whitespace runs collapsed, ends trimmed. */
|
|
7
|
-
export const squash = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
8
|
-
|
|
9
|
-
/** `text` on one line (whitespace runs collapsed, ends trimmed), cut to at most `n` characters. */
|
|
10
|
-
export const clipLine = (text, n) => clip(squash(text), n);
|
|
11
|
-
|
|
12
|
-
/** `text` on one line (whitespace runs collapsed, ends trimmed), cut to at most `n` characters — a plain cut, no ellipsis. */
|
|
13
|
-
export const oneLine = (text, n = 380) => squash(text).slice(0, n);
|
|
14
|
-
|
|
15
|
-
/** The last `n` lines of `text` (ends trimmed) joined with `join`, cut to at most `max` characters. */
|
|
16
|
-
export const tailLines = (text, n = 30, { join = '\n', max = Infinity } = {}) => {
|
|
17
|
-
const tail = String(text ?? '').trim().split(/\r?\n/).slice(-n).join(join);
|
|
18
|
-
return Number.isFinite(max) ? tail.slice(0, max) : tail;
|
|
19
|
-
};
|