@volter/twin 0.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/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Bidirectional sync for twins (the twins architecture notes §4).
|
|
2
|
+
//
|
|
3
|
+
// Two directions, both over the kernel (events + egress); the vendor-specific
|
|
4
|
+
// part — the actual network call — is INJECTED by the caller, never bundled.
|
|
5
|
+
// This is the auth-boundary principle (hard-problem #6): the kernel touches no
|
|
6
|
+
// real credentials; the host supplies a fetch fn (pull) or write fn (push) that
|
|
7
|
+
// uses the user's own auth. The twin stays runnable fully offline (inject fakes).
|
|
8
|
+
//
|
|
9
|
+
// pull (real → twin): fold externally-observed resources into the event log
|
|
10
|
+
// as deltas, so twin state converges to what was observed.
|
|
11
|
+
// push (twin → real): enact a reconcile plan's `toPush` by calling the injected
|
|
12
|
+
// real write fn, idempotently, through the egress ledger.
|
|
13
|
+
//
|
|
14
|
+
// pull + reconcile + push compose into the round-trip the architecture calls for:
|
|
15
|
+
// pull(real) → fork → write-in-fork → reconcile(base, fork, freshPull) → push.
|
|
16
|
+
//
|
|
17
|
+
// push is gated exactly like applyPlan (TWIN-53 R-K2): approval is recomputed from
|
|
18
|
+
// the ReconcilePlan's own conflicts/field resolutions (reconcileRequiresApproval),
|
|
19
|
+
// never trusted off a stored bit — a ReconcilePlan carries no such bit at all, so
|
|
20
|
+
// there is nothing to tamper — and a mandatory lease (acquire → push → release)
|
|
21
|
+
// makes push single-writer the same way applyPlan is.
|
|
22
|
+
import { performExternalWrite } from './egress.ts';
|
|
23
|
+
import type { EgressWriteResult } from './egress.ts';
|
|
24
|
+
import { acquireLease, releaseLease } from './lease.ts';
|
|
25
|
+
import type { WorldRemoteRef } from './refs.ts';
|
|
26
|
+
import { buildShadowState, recordObservedDelta } from './shadow.ts';
|
|
27
|
+
import type { SubjectFields } from './shadow.ts';
|
|
28
|
+
import { twinResources } from './serve.ts';
|
|
29
|
+
import type { TwinResource } from './serve.ts';
|
|
30
|
+
import { reconcileRequiresApproval } from './reconcile.ts';
|
|
31
|
+
import type { ReconcilePlan } from './reconcile.ts';
|
|
32
|
+
|
|
33
|
+
// A resource observed from (or destined for) the real vendor.
|
|
34
|
+
export type SyncResource = { type: string; id: string; fields: SubjectFields };
|
|
35
|
+
|
|
36
|
+
export type PullResult = { observed: number; deltasAppended: number; unchanged: number };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pull: fold a set of externally-observed resources into the twin's event log.
|
|
40
|
+
* The caller has already fetched them from the real vendor (injected I/O); this
|
|
41
|
+
* is the pure fold. Only changed fields produce a delta (recordObservedDelta is
|
|
42
|
+
* a no-op when nothing differs), so re-pulling identical state appends nothing.
|
|
43
|
+
*
|
|
44
|
+
* `redact` (TWIN-45 dev/01 — implemented, not just documented) is an optional
|
|
45
|
+
* transform applied to each resource BEFORE it is folded into the event log, so
|
|
46
|
+
* a sensitive field never lands on disk in the first place — a redact-on-pull
|
|
47
|
+
* hook point, not a redaction policy: this library makes zero default redaction
|
|
48
|
+
* decisions (no built-in "PII field" list, no defaults). The caller supplies the
|
|
49
|
+
* hook deliberately, shaped for their own vendor/fields. See docs/DATA_AT_REST.md
|
|
50
|
+
* for the full data-at-rest story (what's implemented here vs. left as
|
|
51
|
+
* guidance) and for why day-count retention defaults are NOT hardcoded here —
|
|
52
|
+
* that is a policy call for the human owner, not this library.
|
|
53
|
+
*/
|
|
54
|
+
export function syncPull(opts: {
|
|
55
|
+
service: string;
|
|
56
|
+
resources: SyncResource[];
|
|
57
|
+
occurredAt: string;
|
|
58
|
+
root?: string;
|
|
59
|
+
actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string };
|
|
60
|
+
redact?: (resource: SyncResource) => SyncResource;
|
|
61
|
+
}): PullResult {
|
|
62
|
+
const { service, resources, occurredAt, root } = opts;
|
|
63
|
+
let deltasAppended = 0;
|
|
64
|
+
let unchanged = 0;
|
|
65
|
+
// Rebuild the shadow once; recordObservedDelta appends to the log and the next
|
|
66
|
+
// build reflects it, so process sequentially.
|
|
67
|
+
for (const raw of resources) {
|
|
68
|
+
const res = opts.redact ? opts.redact(raw) : raw;
|
|
69
|
+
const shadow = buildShadowState(service, () => null, root);
|
|
70
|
+
const result = recordObservedDelta(
|
|
71
|
+
shadow,
|
|
72
|
+
{ service, subject: { type: res.type, id: res.id }, observed: res.fields, occurredAt, ...(opts.actor ? { actor: opts.actor } : {}) },
|
|
73
|
+
root,
|
|
74
|
+
);
|
|
75
|
+
if (result.changed) deltasAppended += 1;
|
|
76
|
+
else unchanged += 1;
|
|
77
|
+
}
|
|
78
|
+
return { observed: resources.length, deltasAppended, unchanged };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type PushItemResult = { id: string; type: string; status: EgressWriteResult['status']; externalId?: string };
|
|
82
|
+
export type PushResult = { attempted: number; pushed: PushItemResult[] };
|
|
83
|
+
|
|
84
|
+
// Stable hash of the intended field change so identical pushes dedupe (replay)
|
|
85
|
+
// while a different change is a new intent.
|
|
86
|
+
function stableStringify(value: unknown): string {
|
|
87
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
88
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
89
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
90
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Push: enact a reconcile plan's twin→real changes by calling the injected real
|
|
95
|
+
* write fn, idempotently via the egress ledger. `write` is the ONLY place a real
|
|
96
|
+
* vendor API is touched; in tests/offline it's a fake. Re-pushing the same plan
|
|
97
|
+
* replays (no double-apply) because the egress idempotency key encodes the change.
|
|
98
|
+
*
|
|
99
|
+
* Gated the same way `applyPlan` is (TWIN-53 R-K2): `approve` and the lease fields
|
|
100
|
+
* (`remoteRef`/`holder`/`leaseId`/`acquiredAt`/`expiresAt`) are mandatory options —
|
|
101
|
+
* there is no implicit-approval, no-lease path. Approval is required when
|
|
102
|
+
* `reconcileRequiresApproval(plan)` is true (unresolved conflicts, or a
|
|
103
|
+
* twin-wins push that overwrites a real-side change) and `approve` is not set;
|
|
104
|
+
* refusal happens before the lease is acquired or `write` is ever called — zero
|
|
105
|
+
* side effects. The lease is acquired for `remoteRef` before any push and released
|
|
106
|
+
* (even on error) after.
|
|
107
|
+
*/
|
|
108
|
+
export async function syncPush(opts: {
|
|
109
|
+
service: string;
|
|
110
|
+
plan: ReconcilePlan;
|
|
111
|
+
write: (item: SyncResource) => Promise<{ externalId: string; data?: Record<string, unknown> }>;
|
|
112
|
+
remoteRef: WorldRemoteRef;
|
|
113
|
+
holder: { kind: 'agent' | 'human' | 'system'; id: string };
|
|
114
|
+
leaseId: string;
|
|
115
|
+
acquiredAt: string;
|
|
116
|
+
expiresAt: string;
|
|
117
|
+
approve: boolean;
|
|
118
|
+
root?: string;
|
|
119
|
+
actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string };
|
|
120
|
+
}): Promise<PushResult> {
|
|
121
|
+
const { service, plan, write, root } = opts;
|
|
122
|
+
if (reconcileRequiresApproval(plan) && !opts.approve) {
|
|
123
|
+
throw new Error(`sync push for ${service} requires approval (unresolved conflicts, or a twin-wins push overwriting a real-side change); pass approve:true to push`);
|
|
124
|
+
}
|
|
125
|
+
const lease = acquireLease({
|
|
126
|
+
service, provider: opts.remoteRef.provider, remoteRef: opts.remoteRef, planId: `sync-push:${service}`,
|
|
127
|
+
holder: opts.holder, id: opts.leaseId, acquiredAt: opts.acquiredAt, expiresAt: opts.expiresAt, root,
|
|
128
|
+
});
|
|
129
|
+
const pushed: PushItemResult[] = [];
|
|
130
|
+
try {
|
|
131
|
+
for (const item of plan.toPush) {
|
|
132
|
+
const syncItem: SyncResource = { type: item.type, id: item.id, fields: item.fields };
|
|
133
|
+
const result = await performExternalWrite(
|
|
134
|
+
{
|
|
135
|
+
service,
|
|
136
|
+
operation: `sync.push.${item.type}`,
|
|
137
|
+
provider: service,
|
|
138
|
+
subject: { type: item.type, id: item.id },
|
|
139
|
+
idempotencyKey: `sync-push:${item.id}:${stableStringify(item.fields)}`,
|
|
140
|
+
...(opts.actor ? { actor: opts.actor } : {}),
|
|
141
|
+
data: { fields: item.fields },
|
|
142
|
+
},
|
|
143
|
+
async () => {
|
|
144
|
+
const out = await write(syncItem);
|
|
145
|
+
return { externalId: out.externalId, data: out.data ?? { ...item.fields } };
|
|
146
|
+
},
|
|
147
|
+
{ root },
|
|
148
|
+
);
|
|
149
|
+
pushed.push({ id: item.id, type: item.type, status: result.status, ...(result.outcome?.externalId ? { externalId: result.outcome.externalId } : {}) });
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
releaseLease(service, lease.id, { at: opts.acquiredAt, root });
|
|
153
|
+
}
|
|
154
|
+
return { attempted: plan.toPush.length, pushed };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Convenience: the twin's current resources as the reconcile `fork`/state input. */
|
|
158
|
+
export function currentResources(service: string, root?: string): TwinResource[] {
|
|
159
|
+
return twinResources(service, root);
|
|
160
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type {
|
|
3
|
+
GenericWorldStateSchema,
|
|
4
|
+
WorldServiceEventSchema,
|
|
5
|
+
} from './schemas.ts';
|
|
6
|
+
|
|
7
|
+
export type WorldServiceEvent = z.infer<typeof WorldServiceEventSchema>;
|
|
8
|
+
export type GenericWorldState = z.infer<typeof GenericWorldStateSchema>;
|
|
9
|
+
|
|
10
|
+
export type AppendEventResult = {
|
|
11
|
+
event: WorldServiceEvent;
|
|
12
|
+
appended: boolean;
|
|
13
|
+
duplicateOf?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type QueuedWorldServiceEvent = {
|
|
17
|
+
id: string;
|
|
18
|
+
service: string;
|
|
19
|
+
receivedAt: string;
|
|
20
|
+
source: 'listener' | 'webhook' | 'broker' | 'capture' | 'manual' | 'replay';
|
|
21
|
+
event: WorldServiceEvent;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type EnqueueEventResult = {
|
|
25
|
+
queued: QueuedWorldServiceEvent;
|
|
26
|
+
appended: boolean;
|
|
27
|
+
duplicateOf?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CommitQueuedEventsResult = {
|
|
31
|
+
service: string;
|
|
32
|
+
queued: number;
|
|
33
|
+
committed: number;
|
|
34
|
+
skipped: number;
|
|
35
|
+
eventIds: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type WorldPaths = {
|
|
39
|
+
root: string;
|
|
40
|
+
service: string;
|
|
41
|
+
dir: string;
|
|
42
|
+
events: string;
|
|
43
|
+
state: string;
|
|
44
|
+
resources: string;
|
|
45
|
+
cursors: string;
|
|
46
|
+
ingests: string;
|
|
47
|
+
eventQueue: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type WorldReducer<State> = (state: State, event: WorldServiceEvent) => State;
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// World integrity validation — the TWIN's own invariants: egress write intents
|
|
2
|
+
// reconcile (no write left without a recorded result past the grace window). NOTE:
|
|
3
|
+
// annotation integrity (quotes resolve, events annotated, …) moved to
|
|
4
|
+
// @volter/tracker/world-annotations — that's a verification (tracker) concern.
|
|
5
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { listEgressLedger } from './egress.ts';
|
|
8
|
+
import { worldStateRoot } from './storage.ts';
|
|
9
|
+
|
|
10
|
+
export type WorldValidationFinding = {
|
|
11
|
+
level: 'error' | 'warning';
|
|
12
|
+
code: string;
|
|
13
|
+
message: string;
|
|
14
|
+
service?: string;
|
|
15
|
+
/** set by the tracker's annotation validator (reuses this finding shape). */
|
|
16
|
+
annotation?: string;
|
|
17
|
+
event?: string;
|
|
18
|
+
details?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WorldValidationSummary = {
|
|
22
|
+
status: 'pass' | 'warn' | 'fail';
|
|
23
|
+
errors: number;
|
|
24
|
+
warnings: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type WorldValidationReport = {
|
|
28
|
+
valid: boolean;
|
|
29
|
+
summary: WorldValidationSummary;
|
|
30
|
+
findings: WorldValidationFinding[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function stringValue(value: unknown): string {
|
|
34
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function discoverWorldServices(root: string): string[] {
|
|
38
|
+
const worldRoot = worldStateRoot(root);
|
|
39
|
+
if (!existsSync(worldRoot)) return [];
|
|
40
|
+
return readdirSync(worldRoot, { withFileTypes: true })
|
|
41
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(worldRoot, entry.name, 'events.jsonl')))
|
|
42
|
+
.map((entry) => entry.name)
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateWorldService(root: string, service: string): WorldValidationFinding[] {
|
|
47
|
+
// Twin integrity = egress reconciliation. (Annotation integrity moved to
|
|
48
|
+
// @volter/tracker/world-annotations.)
|
|
49
|
+
return validateEgressLedger(root, service);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const UNRECONCILED_INTENT_GRACE_MS = 10 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
function validateEgressLedger(root: string, service: string): WorldValidationFinding[] {
|
|
55
|
+
const findings: WorldValidationFinding[] = [];
|
|
56
|
+
for (const entry of listEgressLedger(service, root)) {
|
|
57
|
+
if (entry.results.length > 0) continue;
|
|
58
|
+
const ageMs = Date.now() - Date.parse(entry.intent.occurredAt);
|
|
59
|
+
if (Number.isFinite(ageMs) && ageMs < UNRECONCILED_INTENT_GRACE_MS) continue;
|
|
60
|
+
findings.push({
|
|
61
|
+
level: 'warning',
|
|
62
|
+
code: 'write_intent_unreconciled',
|
|
63
|
+
message: 'Egress write intent has no result event — the external write may have happened without being recorded. Verify external state and reconcile before retrying.',
|
|
64
|
+
service,
|
|
65
|
+
event: entry.intent.id,
|
|
66
|
+
details: {
|
|
67
|
+
operation: stringValue(entry.intent.data.operation),
|
|
68
|
+
provider: stringValue(entry.intent.data.provider),
|
|
69
|
+
occurredAt: entry.intent.occurredAt,
|
|
70
|
+
subject: entry.intent.subject,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function summarizeWorldFindings(findings: WorldValidationFinding[]): WorldValidationReport {
|
|
78
|
+
const errors = findings.filter((finding) => finding.level === 'error').length;
|
|
79
|
+
const warnings = findings.filter((finding) => finding.level === 'warning').length;
|
|
80
|
+
return {
|
|
81
|
+
valid: errors === 0,
|
|
82
|
+
summary: {
|
|
83
|
+
status: errors > 0 ? 'fail' : warnings > 0 ? 'warn' : 'pass',
|
|
84
|
+
errors,
|
|
85
|
+
warnings,
|
|
86
|
+
},
|
|
87
|
+
findings,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateWorld(options: { root?: string; services?: string[] } = {}): WorldValidationReport {
|
|
92
|
+
const root = resolve(options.root || process.env.PROJECT_ROOT || process.cwd());
|
|
93
|
+
const services = options.services && options.services.length > 0 ? options.services : discoverWorldServices(root);
|
|
94
|
+
return summarizeWorldFindings(services.flatMap((service) => validateWorldService(root, service)));
|
|
95
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Twin DEBUG INSPECTOR (NOT the mirror UI / scorecard R13).
|
|
2
|
+
//
|
|
3
|
+
// This renders a generic internal status page — resource/event/egress/divergence
|
|
4
|
+
// tables read directly from kernel state. It's a developer inspector, useful for
|
|
5
|
+
// any twin, but it is explicitly NOT the per-vendor "mirror UI" R13 calls for (a
|
|
6
|
+
// faithful clone of the vendor's product interface — a Slack-like chat UI, a
|
|
7
|
+
// Linear-like board — consuming the twin's API). The real mirror UI is a separate,
|
|
8
|
+
// per-vendor frontend. Keeping this as the honest, narrow thing it is.
|
|
9
|
+
// `renderTwinHtml` is pure (state → HTML string) so it's testable without a server.
|
|
10
|
+
import { listEgressLedger } from './egress.ts';
|
|
11
|
+
import { isFork, forkDivergence } from './fork.ts';
|
|
12
|
+
import { twinResources } from './serve.ts';
|
|
13
|
+
import { listEvents } from './storage.ts';
|
|
14
|
+
import { listActions } from './actions.ts';
|
|
15
|
+
|
|
16
|
+
function esc(value: unknown): string {
|
|
17
|
+
return String(value ?? '')
|
|
18
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fieldCell(value: unknown): string {
|
|
22
|
+
if (value == null) return '<span class="muted">—</span>';
|
|
23
|
+
if (Array.isArray(value)) return value.map((v) => `<span class="tag">${esc(v)}</span>`).join(' ');
|
|
24
|
+
return esc(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pure render: the twin's current state → a self-contained HTML page. */
|
|
28
|
+
export function renderTwinHtml(service: string, opts: { root?: string } = {}): string {
|
|
29
|
+
const { root } = opts;
|
|
30
|
+
const resources = twinResources(service, root).sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
31
|
+
const events = listEvents(service, root);
|
|
32
|
+
const ledger = listEgressLedger(service, root);
|
|
33
|
+
const actions = listActions(service, root); // R18: local actions (separate from observed events)
|
|
34
|
+
const fork = isFork(service, root ?? '') ? forkDivergence(service, root ?? '') : null;
|
|
35
|
+
|
|
36
|
+
// Union of data field names across resources (excluding identity/meta).
|
|
37
|
+
const fieldNames = [...new Set(resources.flatMap((r) => Object.keys(r)))].filter((k) => !['id', 'type', 'updatedAt'].includes(k)).sort();
|
|
38
|
+
|
|
39
|
+
const resourceRows = resources.map((r) => `
|
|
40
|
+
<tr>
|
|
41
|
+
<td class="mono">${esc(r.id)}</td>
|
|
42
|
+
${fieldNames.map((f) => `<td>${fieldCell((r as Record<string, unknown>)[f])}</td>`).join('')}
|
|
43
|
+
<td class="mono muted">${esc(r.updatedAt)}</td>
|
|
44
|
+
</tr>`).join('');
|
|
45
|
+
|
|
46
|
+
const timelineRows = events.slice(-25).reverse().map((e) => `
|
|
47
|
+
<tr>
|
|
48
|
+
<td class="mono muted">${esc(e.occurredAt)}</td>
|
|
49
|
+
<td class="mono">${esc(e.type)}</td>
|
|
50
|
+
<td class="mono">${esc(e.subject.type)}:${esc(e.subject.id)}</td>
|
|
51
|
+
<td class="mono muted">${esc(e.origin ?? '')}</td>
|
|
52
|
+
</tr>`).join('');
|
|
53
|
+
|
|
54
|
+
const ledgerRows = ledger.map((entry) => {
|
|
55
|
+
const ok = entry.results.some((r) => r.data?.status === 'success');
|
|
56
|
+
const op = entry.intent.data && typeof entry.intent.data === 'object' ? (entry.intent.data as Record<string, unknown>).operation : '';
|
|
57
|
+
return `
|
|
58
|
+
<tr>
|
|
59
|
+
<td class="mono">${esc(entry.intent.subject.type)}:${esc(entry.intent.subject.id)}</td>
|
|
60
|
+
<td class="mono">${esc(op || entry.intent.type)}</td>
|
|
61
|
+
<td>${entry.results.length}</td>
|
|
62
|
+
<td>${ok ? '<span class="ok">success</span>' : '<span class="warn">unreconciled</span>'}</td>
|
|
63
|
+
</tr>`;
|
|
64
|
+
}).join('');
|
|
65
|
+
|
|
66
|
+
const actionRows = actions.slice(-25).reverse().map((a) => `
|
|
67
|
+
<tr>
|
|
68
|
+
<td class="mono muted">${esc(a.occurredAt)}</td>
|
|
69
|
+
<td class="mono">${esc(a.op)}</td>
|
|
70
|
+
<td class="mono">${esc(a.subject.type)}:${esc(a.subject.id)}</td>
|
|
71
|
+
<td class="mono muted">${esc(a.fields ? Object.keys(a.fields).join(',') : (a.revertsActionId ?? a.confirmsActionId ?? ''))}</td>
|
|
72
|
+
</tr>`).join('');
|
|
73
|
+
|
|
74
|
+
const forkSection = fork ? `
|
|
75
|
+
<section>
|
|
76
|
+
<h2>Fork divergence <span class="muted">(${fork.changed.length} changed · ${fork.created.length} created)</span></h2>
|
|
77
|
+
${fork.changed.length + fork.created.length === 0 ? '<p class="muted">No divergence from base.</p>' : ''}
|
|
78
|
+
${fork.changed.map((c) => `<div class="diff"><span class="mono">${esc(c.id)}</span>: ${Object.entries(c.changed).map(([f, d]) => `${esc(f)} <span class="muted">${esc(JSON.stringify(d.before))}</span> → <span class="ok">${esc(JSON.stringify(d.after))}</span>`).join(', ')}</div>`).join('')}
|
|
79
|
+
${fork.created.map((c) => `<div class="diff"><span class="ok">+ ${esc(c.id)}</span> (created in fork)</div>`).join('')}
|
|
80
|
+
</section>` : '';
|
|
81
|
+
|
|
82
|
+
return `<!doctype html>
|
|
83
|
+
<html lang="en"><head><meta charset="utf-8"><title>${esc(service)} twin</title>
|
|
84
|
+
<style>
|
|
85
|
+
:root { color-scheme: light dark; }
|
|
86
|
+
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 2rem; max-width: 1100px; }
|
|
87
|
+
h1 { margin: 0 0 .25rem; } h2 { margin: 2rem 0 .5rem; font-size: 1.05rem; }
|
|
88
|
+
.badges span { display: inline-block; padding: .1rem .5rem; border-radius: 1rem; background: #8884; margin-right: .4rem; font-size: .8rem; }
|
|
89
|
+
table { border-collapse: collapse; width: 100%; }
|
|
90
|
+
th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #8883; vertical-align: top; }
|
|
91
|
+
th { font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; opacity: .7; }
|
|
92
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }
|
|
93
|
+
.muted { opacity: .55; } .ok { color: #1a7f37; } .warn { color: #b35900; }
|
|
94
|
+
.tag { background: #8884; border-radius: .4rem; padding: 0 .35rem; font-size: .8em; }
|
|
95
|
+
.diff { padding: .2rem 0; font-size: .9em; }
|
|
96
|
+
section { margin-bottom: .5rem; }
|
|
97
|
+
</style></head>
|
|
98
|
+
<body>
|
|
99
|
+
<h1>${esc(service)} twin</h1>
|
|
100
|
+
<div class="badges">
|
|
101
|
+
<span>twin: ${esc(service)}</span>
|
|
102
|
+
<span>${resources.length} resources</span>
|
|
103
|
+
<span>${events.length} events</span>
|
|
104
|
+
<span>${ledger.length} egress</span>
|
|
105
|
+
${fork ? '<span>fork</span>' : ''}
|
|
106
|
+
</div>
|
|
107
|
+
${forkSection}
|
|
108
|
+
<section>
|
|
109
|
+
<h2>Resources</h2>
|
|
110
|
+
<table><thead><tr><th>id</th>${fieldNames.map((f) => `<th>${esc(f)}</th>`).join('')}<th>updatedAt</th></tr></thead>
|
|
111
|
+
<tbody>${resourceRows || '<tr><td class="muted">no resources</td></tr>'}</tbody></table>
|
|
112
|
+
</section>
|
|
113
|
+
<section>
|
|
114
|
+
<h2>Local actions <span class="muted">(${actions.length} — simulator/fork writes, not yet pushed)</span></h2>
|
|
115
|
+
<table><thead><tr><th>occurredAt</th><th>op</th><th>subject</th><th>fields</th></tr></thead>
|
|
116
|
+
<tbody>${actionRows || '<tr><td class="muted">no local actions</td></tr>'}</tbody></table>
|
|
117
|
+
</section>
|
|
118
|
+
<section>
|
|
119
|
+
<h2>Egress ledger <span class="muted">(pushes to the real vendor)</span></h2>
|
|
120
|
+
<table><thead><tr><th>subject</th><th>operation</th><th>results</th><th>status</th></tr></thead>
|
|
121
|
+
<tbody>${ledgerRows || '<tr><td class="muted">no egress</td></tr>'}</tbody></table>
|
|
122
|
+
</section>
|
|
123
|
+
<section>
|
|
124
|
+
<h2>Observed event timeline <span class="muted">(latest 25 of ${events.length})</span></h2>
|
|
125
|
+
<table><thead><tr><th>occurredAt</th><th>type</th><th>subject</th><th>origin</th></tr></thead>
|
|
126
|
+
<tbody>${timelineRows || '<tr><td class="muted">no events</td></tr>'}</tbody></table>
|
|
127
|
+
</section>
|
|
128
|
+
</body></html>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Serve the visualizer over HTTP (re-rendered per request, so it tracks state). */
|
|
132
|
+
export function createVisualizerServer(options: { service: string; root?: string; port?: number }): { port: number; stop: () => void } {
|
|
133
|
+
const server = Bun.serve({
|
|
134
|
+
port: options.port ?? 0,
|
|
135
|
+
idleTimeout: 60,
|
|
136
|
+
fetch() {
|
|
137
|
+
const html = renderTwinHtml(options.service, { ...(options.root !== undefined ? { root: options.root } : {}) });
|
|
138
|
+
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
142
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// World service config — generic per-service settings for the twin runtime
|
|
2
|
+
// (`.volter/world/config.json`). Kept deliberately small + open: the index
|
|
3
|
+
// signature lets a service carry arbitrary extra config that downstream tools
|
|
4
|
+
// define. (The tracker's annotation adapter defines + reads `annotationPolicy` and
|
|
5
|
+
// `browseUrlTemplate` on top of this — those are verification concerns, not the
|
|
6
|
+
// twin's, so they are NOT typed here.)
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { worldStateRoot } from './storage.ts';
|
|
10
|
+
|
|
11
|
+
export type WorldServiceConfig = {
|
|
12
|
+
provider?: string;
|
|
13
|
+
tool?: string;
|
|
14
|
+
pollLimit?: number;
|
|
15
|
+
} & Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
export type WorldConfig = {
|
|
18
|
+
services: Record<string, WorldServiceConfig>;
|
|
19
|
+
} & Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
export function loadWorldConfig(root?: string): WorldConfig {
|
|
22
|
+
const path = join(worldStateRoot(root), 'config.json');
|
|
23
|
+
if (!existsSync(path)) return { services: {} };
|
|
24
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<WorldConfig>;
|
|
25
|
+
return { ...parsed, services: parsed.services ?? {} };
|
|
26
|
+
}
|