pi-roundtable 0.7.19 → 0.9.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 +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +7 -3
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +10 -0
- package/src/index.ts +65 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Tier } from "../speakers.ts";
|
|
2
|
+
import type {
|
|
3
|
+
IdentityRef,
|
|
4
|
+
LinkSource,
|
|
5
|
+
NewPrincipal,
|
|
6
|
+
PrincipalStore,
|
|
7
|
+
Pronouns,
|
|
8
|
+
RoleSource,
|
|
9
|
+
} from "./principal-store.ts";
|
|
10
|
+
|
|
11
|
+
/** How long a read of the store is reused; a change another process makes is seen after it. */
|
|
12
|
+
export const CACHE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The store with its reads reused for `CACHE_MS`; any write through it drops them all, so this
|
|
16
|
+
* process sees its own changes at once. Recording a principal as seen drops only theirs.
|
|
17
|
+
*/
|
|
18
|
+
export class CachingPrincipalStore implements PrincipalStore {
|
|
19
|
+
readonly #store: PrincipalStore;
|
|
20
|
+
readonly #now: () => number;
|
|
21
|
+
readonly #reads = new Map<string, { at: number; value: Promise<unknown> }>();
|
|
22
|
+
|
|
23
|
+
constructor(store: PrincipalStore, now: () => number) {
|
|
24
|
+
this.#store = store;
|
|
25
|
+
this.#now = now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#cached<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
29
|
+
const now = this.#now();
|
|
30
|
+
const hit = this.#reads.get(key);
|
|
31
|
+
if (hit && now - hit.at < CACHE_MS) return hit.value as Promise<T>;
|
|
32
|
+
const value = read();
|
|
33
|
+
this.#reads.set(key, { at: now, value });
|
|
34
|
+
value.catch(() => this.#reads.delete(key));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async #write<T>(write: Promise<T>): Promise<T> {
|
|
39
|
+
try {
|
|
40
|
+
return await write;
|
|
41
|
+
} finally {
|
|
42
|
+
this.#reads.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(id: string) {
|
|
47
|
+
return this.#cached(`p\0${id}`, () => this.#store.get(id));
|
|
48
|
+
}
|
|
49
|
+
list() {
|
|
50
|
+
return this.#store.list();
|
|
51
|
+
}
|
|
52
|
+
identity(provider: string, subject: string) {
|
|
53
|
+
return this.#cached(`i\0${provider}\0${subject}`, () =>
|
|
54
|
+
this.#store.identity(provider, subject),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
identitiesOf(principalId: string) {
|
|
58
|
+
return this.#store.identitiesOf(principalId);
|
|
59
|
+
}
|
|
60
|
+
rolesOf(principalId: string) {
|
|
61
|
+
return this.#cached(`r\0${principalId}`, () =>
|
|
62
|
+
this.#store.rolesOf(principalId),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
holders(role: Tier) {
|
|
66
|
+
return this.#store.holders(role);
|
|
67
|
+
}
|
|
68
|
+
linksFrom(source: LinkSource) {
|
|
69
|
+
return this.#store.linksFrom(source);
|
|
70
|
+
}
|
|
71
|
+
create(input: NewPrincipal) {
|
|
72
|
+
return this.#write(this.#store.create(input));
|
|
73
|
+
}
|
|
74
|
+
update(
|
|
75
|
+
id: string,
|
|
76
|
+
change: { displayName?: string; pronouns?: Pronouns | null },
|
|
77
|
+
) {
|
|
78
|
+
return this.#write(this.#store.update(id, change));
|
|
79
|
+
}
|
|
80
|
+
link(principalId: string, identity: IdentityRef, source: LinkSource) {
|
|
81
|
+
return this.#write(this.#store.link(principalId, identity, source));
|
|
82
|
+
}
|
|
83
|
+
claim(principalId: string, identity: IdentityRef, displayName?: string) {
|
|
84
|
+
return this.#write(this.#store.claim(principalId, identity, displayName));
|
|
85
|
+
}
|
|
86
|
+
admit(identity: IdentityRef, displayName: string, id?: string) {
|
|
87
|
+
return this.#write(this.#store.admit(identity, displayName, id));
|
|
88
|
+
}
|
|
89
|
+
unlink(provider: string, subject: string) {
|
|
90
|
+
return this.#write(this.#store.unlink(provider, subject));
|
|
91
|
+
}
|
|
92
|
+
grant(principalId: string, role: Tier, source: RoleSource) {
|
|
93
|
+
return this.#write(this.#store.grant(principalId, role, source));
|
|
94
|
+
}
|
|
95
|
+
revoke(principalId: string, role: Tier, source?: RoleSource) {
|
|
96
|
+
return this.#write(this.#store.revoke(principalId, role, source));
|
|
97
|
+
}
|
|
98
|
+
disable(id: string) {
|
|
99
|
+
return this.#write(this.#store.disable(id));
|
|
100
|
+
}
|
|
101
|
+
enable(id: string) {
|
|
102
|
+
return this.#write(this.#store.enable(id));
|
|
103
|
+
}
|
|
104
|
+
async touch(id: string, tier: Tier | null, at?: Date) {
|
|
105
|
+
try {
|
|
106
|
+
await this.#store.touch(id, tier, at);
|
|
107
|
+
} finally {
|
|
108
|
+
this.#reads.delete(`p\0${id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { ConfigError } from "../domain/errors.ts";
|
|
2
|
+
import type { Logger } from "../log.ts";
|
|
3
|
+
import type { AccessOwner } from "./access-policy.ts";
|
|
4
|
+
import { identityOf } from "./actor-facts.ts";
|
|
5
|
+
import type { DeclaredIdentity } from "./plugin-identities.ts";
|
|
6
|
+
import type {
|
|
7
|
+
IdentityLink,
|
|
8
|
+
IdentityRef,
|
|
9
|
+
PrincipalStore,
|
|
10
|
+
} from "./principal-store.ts";
|
|
11
|
+
|
|
12
|
+
/** A configured owner, where the configuration lists them, and what the boot knows of the plugins. */
|
|
13
|
+
export interface ConfiguredOwner {
|
|
14
|
+
owner: AccessOwner;
|
|
15
|
+
/** The owner's index in `access.owners`. */
|
|
16
|
+
index: number;
|
|
17
|
+
/** The owner's identities, parsed, in the configuration's order. */
|
|
18
|
+
refs: readonly IdentityRef[];
|
|
19
|
+
/** Each identity the plugins declare, with the plugin and the principal it names. */
|
|
20
|
+
declaredBy: ReadonlyMap<string, DeclaredIdentity>;
|
|
21
|
+
/** The primary owner's principal, which a plugin naming none binds to; undefined while syncing the primary owner. */
|
|
22
|
+
primary: string | undefined;
|
|
23
|
+
logger: Logger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A configured owner's identity that stops the start, and how to fix it. */
|
|
27
|
+
export interface OwnerConflict {
|
|
28
|
+
problem: string;
|
|
29
|
+
fix: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a configured owner may list an identity a plugin declares. The principal the plugin binds
|
|
34
|
+
* it to on this boot, its `principal` or else the primary owner, must be this owner, whatever it
|
|
35
|
+
* was bound to before: the boot moves it there. A plugin's credential never tells who the owner is,
|
|
36
|
+
* so an owner nothing else tells conflicts unless they are the primary owner the plugin defaults to
|
|
37
|
+
* and the credential is not a plugin's link to someone already. `primary` undefined with `first`
|
|
38
|
+
* false is a primary owner the start makes.
|
|
39
|
+
*/
|
|
40
|
+
export function declaredConflict({
|
|
41
|
+
index: n,
|
|
42
|
+
i,
|
|
43
|
+
identity,
|
|
44
|
+
id,
|
|
45
|
+
link,
|
|
46
|
+
declared: { plugin, principal },
|
|
47
|
+
primary,
|
|
48
|
+
first,
|
|
49
|
+
}: {
|
|
50
|
+
index: number;
|
|
51
|
+
i: number;
|
|
52
|
+
identity: string;
|
|
53
|
+
/** The principal this owner is, as everything but a plugin's credential tells; undefined for one the boot makes. */
|
|
54
|
+
id: string | undefined;
|
|
55
|
+
link: IdentityLink | undefined;
|
|
56
|
+
declared: DeclaredIdentity;
|
|
57
|
+
primary: string | undefined;
|
|
58
|
+
first: boolean;
|
|
59
|
+
}): OwnerConflict | undefined {
|
|
60
|
+
const at = `access.owners[${n}].identities[${i}]: ${identity} is an identity plugin ${plugin} declares`;
|
|
61
|
+
const remove = `Remove it from access.owners[${n}].identities`;
|
|
62
|
+
const untold: OwnerConflict = {
|
|
63
|
+
problem: "a plugin's credential does not tell who this owner is",
|
|
64
|
+
fix: `${remove}, or give this owner its principal and bind plugin ${plugin} to it`,
|
|
65
|
+
};
|
|
66
|
+
if (principal === undefined && first) {
|
|
67
|
+
// Bound to this owner, the primary owner: whoever they are, unless only that link would tell.
|
|
68
|
+
if (id !== undefined || link?.source !== "plugin") return undefined;
|
|
69
|
+
return {
|
|
70
|
+
...untold,
|
|
71
|
+
problem: `${at}, bound to principal ${link.principalId}, and ${untold.problem}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const target = principal ?? primary;
|
|
75
|
+
if (target !== undefined && target === id) return undefined;
|
|
76
|
+
const bound = `${at}, bound to ${target === undefined ? "the principal the start makes for access.owners[0]" : `principal ${target}`}`;
|
|
77
|
+
return id === undefined
|
|
78
|
+
? { ...untold, problem: `${bound}, and ${untold.problem}` }
|
|
79
|
+
: {
|
|
80
|
+
problem: `${bound}, not to this owner's ${id}`,
|
|
81
|
+
fix: `${remove}, or bind plugin ${plugin} to ${id} in its options`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The principal a configured owner is: their `principal`, else the principal an identity of theirs
|
|
87
|
+
* is linked to; undefined for someone the boot makes. A plugin's credential is not the person's own
|
|
88
|
+
* identity, so a plugin's link never tells who the owner is, and one no plugin declares any more is
|
|
89
|
+
* unlinked first, as the boot would anyway. An identity linked to another principal stops the
|
|
90
|
+
* boot, and so does an identity a plugin declares that `declaredConflict` refuses. One a plugin
|
|
91
|
+
* declares and binds to this owner on this boot, still the plugin's link to someone else, is moved
|
|
92
|
+
* now, before the owner's identities are linked.
|
|
93
|
+
*/
|
|
94
|
+
export async function configuredOwnerId(
|
|
95
|
+
store: PrincipalStore,
|
|
96
|
+
{ owner, index: n, refs, declaredBy, primary, logger }: ConfiguredOwner,
|
|
97
|
+
): Promise<string | undefined> {
|
|
98
|
+
const links = await Promise.all(
|
|
99
|
+
refs.map(async (ref) => {
|
|
100
|
+
const link = await store.identity(ref.provider, ref.subject);
|
|
101
|
+
if (link?.source !== "plugin" || declaredBy.has(identityOf(link)))
|
|
102
|
+
return link;
|
|
103
|
+
// No plugin declares it any more, so this boot unlinks it anyway: before it tells anyone anything.
|
|
104
|
+
await store.unlink(link.provider, link.subject);
|
|
105
|
+
logger.info(
|
|
106
|
+
`${identityOf(link)} is no longer an identity a plugin declares; it was unlinked from principal ${link.principalId}`,
|
|
107
|
+
);
|
|
108
|
+
return undefined;
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
let id = owner.principal;
|
|
112
|
+
links.forEach((link, i) => {
|
|
113
|
+
if (!link || link.source === "plugin") return;
|
|
114
|
+
id ??= link.principalId;
|
|
115
|
+
if (link.principalId !== id)
|
|
116
|
+
throw new ConfigError(
|
|
117
|
+
`config access.owners[${n}].identities[${i}]: ${owner.identities[i]} is linked to principal ${link.principalId}, not to this owner's ${id}. Unlink it with roundtable principal unlink ${owner.identities[i]}, or fix the configuration.`,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
for (const [i, ref] of refs.entries()) {
|
|
121
|
+
const declared = declaredBy.get(identityOf(ref));
|
|
122
|
+
if (!declared) continue;
|
|
123
|
+
const link = links[i];
|
|
124
|
+
const conflict = declaredConflict({
|
|
125
|
+
index: n,
|
|
126
|
+
i,
|
|
127
|
+
identity: owner.identities[i] ?? identityOf(ref),
|
|
128
|
+
id,
|
|
129
|
+
link,
|
|
130
|
+
declared,
|
|
131
|
+
primary,
|
|
132
|
+
first: n === 0,
|
|
133
|
+
});
|
|
134
|
+
// The CLI refuses to unlink a plugin's identity: the plugin's options move it.
|
|
135
|
+
if (conflict)
|
|
136
|
+
throw new ConfigError(`config ${conflict.problem}. ${conflict.fix}.`);
|
|
137
|
+
if (link?.source !== "plugin" || link.principalId === id) continue;
|
|
138
|
+
// The plugin binds it to this owner on this boot, and a link refuses an identity linked elsewhere.
|
|
139
|
+
await store.unlink(link.provider, link.subject);
|
|
140
|
+
logger.info(
|
|
141
|
+
`${identityOf(link)} of plugin ${declared.plugin} moved from principal ${link.principalId} to ${id}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
2
|
+
import type { Speaker } from "../speakers.ts";
|
|
3
|
+
import type { ActorFacts } from "./actor-facts.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A person a surface reports, assessed before any claim takes their message: who they would be,
|
|
7
|
+
* with nothing written yet.
|
|
8
|
+
*/
|
|
9
|
+
export interface Contact {
|
|
10
|
+
/** The speaker they would be: their principal's, or at a first contact the principal they would claim or be admitted as. */
|
|
11
|
+
readonly speaker: Speaker;
|
|
12
|
+
/**
|
|
13
|
+
* Records the contact once a claim takes their message: claims their 0.8 principal or admits
|
|
14
|
+
* them first, then records them as seen. The speaker they are now, whose principal differs from
|
|
15
|
+
* the assessed one only when another process linked them meanwhile; undefined when they are no
|
|
16
|
+
* one now.
|
|
17
|
+
*/
|
|
18
|
+
take(): Promise<Speaker | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Assesses the people of messages before a claim takes them, so nobody's message that no claim serves writes anything. Core-internal: the router's. */
|
|
22
|
+
export interface ContactAssessor {
|
|
23
|
+
assess(
|
|
24
|
+
facts: ActorFacts,
|
|
25
|
+
scope?: { conversation?: ChannelKey },
|
|
26
|
+
): Promise<Contact | undefined>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RoundtablePlugin } from "../plugin.ts";
|
|
2
|
+
import { IDENTITY } from "../services.ts";
|
|
3
|
+
import type { AccessRules } from "./access-policy.ts";
|
|
4
|
+
import { type BackfillSummary, identityMigrations } from "./identity-schema.ts";
|
|
5
|
+
import { PgIdentityService } from "./identity-service.ts";
|
|
6
|
+
import { identityView } from "./identity-view.ts";
|
|
7
|
+
import { declaredIdentities } from "./plugin-identities.ts";
|
|
8
|
+
import { PgPrincipalStore } from "./principal-store.ts";
|
|
9
|
+
|
|
10
|
+
/** The name of the identity plugin, as the migration ledger names it. */
|
|
11
|
+
export const IDENTITY_PLUGIN = "identity";
|
|
12
|
+
|
|
13
|
+
export interface IdentityOptions {
|
|
14
|
+
/** Who the host serves; the backfill makes the owners with a principal id first, by their names, and caps what a carried-over author of schedules is seen at by the tiers. */
|
|
15
|
+
rules: AccessRules;
|
|
16
|
+
/** The host's other plugins, whose declared `identities` setup links; checked when this plugin is made. */
|
|
17
|
+
plugins?: readonly RoundtablePlugin[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One line for the log: what the boot's backfill made, and from where. */
|
|
21
|
+
export function backfillLine(summary: BackfillSummary): string {
|
|
22
|
+
const sources = Object.entries(summary.sources)
|
|
23
|
+
.map(([source, count]) => `${source} ${count}`)
|
|
24
|
+
.join(", ");
|
|
25
|
+
return `principal backfill: ${summary.created} created; ids found: ${sources}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The principals, their identity links, and their roles: the tables, the backfill that makes a
|
|
30
|
+
* principal of every person 0.8 stored, and the identity service over them provided as
|
|
31
|
+
* `IDENTITY`, read-only, its configured owners and the plugins' declared identities synced at
|
|
32
|
+
* setup. It runs first, so every later plugin may read it.
|
|
33
|
+
*/
|
|
34
|
+
export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
|
|
35
|
+
const { rules } = options;
|
|
36
|
+
const identities = declaredIdentities(options.plugins ?? []);
|
|
37
|
+
let summary: BackfillSummary | undefined;
|
|
38
|
+
return {
|
|
39
|
+
name: IDENTITY_PLUGIN,
|
|
40
|
+
migrations: identityMigrations(rules, (made) => {
|
|
41
|
+
summary = made;
|
|
42
|
+
}),
|
|
43
|
+
provides: [IDENTITY],
|
|
44
|
+
setup: async ({ database, services, logger }) => {
|
|
45
|
+
if (summary) logger.info(backfillLine(summary));
|
|
46
|
+
const identity = new PgIdentityService(
|
|
47
|
+
await PgPrincipalStore.attach(database()),
|
|
48
|
+
rules,
|
|
49
|
+
{ logger, identities },
|
|
50
|
+
);
|
|
51
|
+
await identity.syncConfig();
|
|
52
|
+
services.provide(IDENTITY, identityView(identity));
|
|
53
|
+
return {};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "../db/migrations.ts";
|
|
3
|
+
import { type Tier, tierAtLeast } from "../speakers.ts";
|
|
4
|
+
import type { AccessRules, AccessTier } from "./access-policy.ts";
|
|
5
|
+
import { parseIdentity } from "./actor-facts.ts";
|
|
6
|
+
import {
|
|
7
|
+
LEGACY_PROVIDER,
|
|
8
|
+
LEGACY_REMOTE_SPEAKER,
|
|
9
|
+
SYSTEM_PRINCIPAL,
|
|
10
|
+
} from "./principal-store.ts";
|
|
11
|
+
import {
|
|
12
|
+
PRINCIPAL_PLUGIN_LINKS,
|
|
13
|
+
PRINCIPAL_TABLES,
|
|
14
|
+
PRINCIPALS_CLAIMABLE,
|
|
15
|
+
} from "./principal-tables.ts";
|
|
16
|
+
|
|
17
|
+
/** Where the backfill finds the ids of people 0.8 stored: the configured owners, then four tables. */
|
|
18
|
+
export type BackfillSource =
|
|
19
|
+
| "config"
|
|
20
|
+
| "owner_memory"
|
|
21
|
+
| "schedules"
|
|
22
|
+
| "conversations"
|
|
23
|
+
| "held_actions";
|
|
24
|
+
|
|
25
|
+
/** The rules the backfill reads: the owners with a principal id, made first by their names, and the tiers that cap what a carried-over author of schedules is seen at. */
|
|
26
|
+
export type BackfillRules = Pick<AccessRules, "owners" | "admins" | "members">;
|
|
27
|
+
|
|
28
|
+
/** What one backfill made: the principals it created, and how many distinct ids each source held. */
|
|
29
|
+
export interface BackfillSummary {
|
|
30
|
+
created: number;
|
|
31
|
+
sources: Record<BackfillSource, number>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The column each table keeps a person's id in, and the column naming them, where there is one.
|
|
36
|
+
* `principal` is a column a newer build fills with the principal beside an actor id: a row with
|
|
37
|
+
* it filled is not 0.8's, so its id makes no principal. Where `principalHold` names the hold the
|
|
38
|
+
* principal was written with, it counts only while that is the row's (`principalHold = held`),
|
|
39
|
+
* as 0.8 writes over a row and leaves the principal as it was.
|
|
40
|
+
*/
|
|
41
|
+
const TABLES: readonly {
|
|
42
|
+
table: Exclude<BackfillSource, "config">;
|
|
43
|
+
column: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
order?: string;
|
|
46
|
+
principal?: string;
|
|
47
|
+
principalHold?: { column: string; held: string };
|
|
48
|
+
}[] = [
|
|
49
|
+
{ table: "owner_memory", column: "speaker_id" },
|
|
50
|
+
{
|
|
51
|
+
table: "schedules",
|
|
52
|
+
column: "created_by_id",
|
|
53
|
+
name: "created_by_name",
|
|
54
|
+
order: "created_at DESC",
|
|
55
|
+
},
|
|
56
|
+
{ table: "conversations", column: "principal_id" },
|
|
57
|
+
{
|
|
58
|
+
table: "held_actions",
|
|
59
|
+
column: "speaker_id",
|
|
60
|
+
principal: "principal_id",
|
|
61
|
+
principalHold: { column: "principal_held_at", held: "held_at" },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Whether the tier's rules could hold the person of a 0.8 speaker id, whose surface and roles are unknown here. */
|
|
66
|
+
function couldHold(tier: AccessTier | undefined, id: string): boolean {
|
|
67
|
+
if (!tier) return false;
|
|
68
|
+
const { everyone } = tier;
|
|
69
|
+
if (everyone === true || (Array.isArray(everyone) && everyone.length > 0))
|
|
70
|
+
return true;
|
|
71
|
+
if ((tier.roles?.length ?? 0) > 0) return true;
|
|
72
|
+
return (tier.identities ?? []).some(
|
|
73
|
+
(identity) => identity === id || parseIdentity(identity)?.subject === id,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The most the rules could give the person of a 0.8 speaker id: admin, member, or nothing; never owner, which only the owners hold. */
|
|
78
|
+
function ceilingOf(
|
|
79
|
+
rules: BackfillRules,
|
|
80
|
+
id: string,
|
|
81
|
+
): Exclude<Tier, "owner"> | undefined {
|
|
82
|
+
if (couldHold(rules.admins, id)) return "admin";
|
|
83
|
+
if (couldHold(rules.members, id)) return "member";
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The highest tier each author created a schedule at, where the table records it. */
|
|
88
|
+
async function scheduledTiers(sql: SQL): Promise<Map<string, Tier>> {
|
|
89
|
+
const tiers = new Map<string, Tier>();
|
|
90
|
+
if (
|
|
91
|
+
!(
|
|
92
|
+
(await hasColumn(sql, "schedules", "created_by_id")) &&
|
|
93
|
+
(await hasColumn(sql, "schedules", "created_tier"))
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return tiers;
|
|
97
|
+
const rows = (await sql`
|
|
98
|
+
SELECT created_by_id AS id, created_tier AS tier FROM schedules
|
|
99
|
+
WHERE created_by_id IS NOT NULL`) as { id: string; tier: Tier }[];
|
|
100
|
+
for (const { id, tier } of rows) {
|
|
101
|
+
const best = tiers.get(id);
|
|
102
|
+
if (!best || tierAtLeast(tier, best)) tiers.set(id, tier);
|
|
103
|
+
}
|
|
104
|
+
return tiers;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function hasColumn(
|
|
108
|
+
sql: SQL,
|
|
109
|
+
table: string,
|
|
110
|
+
column: string,
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const rows = await sql`
|
|
113
|
+
SELECT 1 FROM information_schema.columns
|
|
114
|
+
WHERE table_schema = current_schema() AND table_name = ${table} AND column_name = ${column}`;
|
|
115
|
+
return rows.length > 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The tier a carried-over author of schedules is seen at: the highest they scheduled at, at most what the rules could give them. */
|
|
119
|
+
function seenTier(
|
|
120
|
+
rules: BackfillRules,
|
|
121
|
+
id: string,
|
|
122
|
+
scheduled: ReadonlyMap<string, Tier>,
|
|
123
|
+
): Tier | null {
|
|
124
|
+
const tier = scheduled.get(id);
|
|
125
|
+
const ceiling = tier && ceilingOf(rules, id);
|
|
126
|
+
if (!tier || !ceiling) return null;
|
|
127
|
+
return tierAtLeast(tier, ceiling) ? ceiling : tier;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The ids a table holds, each with the name it last gave them, or none. */
|
|
131
|
+
async function idsIn(
|
|
132
|
+
sql: SQL,
|
|
133
|
+
source: (typeof TABLES)[number],
|
|
134
|
+
): Promise<{ id: string; name: string | null }[]> {
|
|
135
|
+
if (!(await hasColumn(sql, source.table, source.column))) return [];
|
|
136
|
+
// The identifiers are this module's constants, never input.
|
|
137
|
+
const name = source.name ?? "NULL::text";
|
|
138
|
+
const order = source.order ? `, ${source.order}` : "";
|
|
139
|
+
const hold =
|
|
140
|
+
source.principalHold &&
|
|
141
|
+
(await hasColumn(sql, source.table, source.principalHold.column))
|
|
142
|
+
? ` OR ${source.principalHold.column} IS DISTINCT FROM ${source.principalHold.held}`
|
|
143
|
+
: "";
|
|
144
|
+
const legacy =
|
|
145
|
+
source.principal && (await hasColumn(sql, source.table, source.principal))
|
|
146
|
+
? ` AND (${source.principal} IS NULL${hold})`
|
|
147
|
+
: "";
|
|
148
|
+
return (await sql.unsafe(
|
|
149
|
+
`SELECT DISTINCT ON (${source.column}) ${source.column} AS id, ${name} AS name
|
|
150
|
+
FROM ${source.table} WHERE ${source.column} IS NOT NULL AND ${source.column} <> ''${legacy}
|
|
151
|
+
ORDER BY ${source.column}${order}`,
|
|
152
|
+
)) as { id: string; name: string | null }[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Makes a principal of the same id for every person 0.8 stored: the configured owners, and each
|
|
157
|
+
* id in `owner_memory`, `schedules`, `conversations`, and `held_actions`, skipping a table or a
|
|
158
|
+
* column the database does not have yet, and a held action that names its principal in
|
|
159
|
+
* `principal_id` for the hold it holds (`principal_held_at`), whose `speaker_id` is an actor id
|
|
160
|
+
* this version stored, and any id a surface
|
|
161
|
+
* knows a linked identity by (its subject, or `<provider>:<subject>`, as `ActorFacts.legacyId`
|
|
162
|
+
* names it): that person has a principal already, and this version wrote the row, so the boot
|
|
163
|
+
* after someone is admitted makes no second principal of their actor id. Each but a configured
|
|
164
|
+
* owner is claimable once, by the person of that id at their first contact
|
|
165
|
+
* (IdentityService.resolve); a configured owner never is, even once the configuration names
|
|
166
|
+
* another owner. 0.8's `remote-mcp` speaker, the owner writing over MCP, gets no principal: it is
|
|
167
|
+
* linked as `legacy:remote-mcp` to the primary owner's, so what it created stays the owner's. One that created
|
|
168
|
+
* schedules, other than a configured owner, is recorded as seen now at the highest tier it
|
|
169
|
+
* scheduled at, capped at the most the rules could give it, so its schedules keep running until
|
|
170
|
+
* `backgroundStaleDays` pass unseen. It only inserts, so it changes no row, and a second run
|
|
171
|
+
* makes nothing; an id an older build wrote since gets its principal at the next run. The owners
|
|
172
|
+
* are named as configured, a schedule's author as the schedule names them, anyone else by their
|
|
173
|
+
* id. `dryRun` counts and writes nothing. The system principal is never made.
|
|
174
|
+
*/
|
|
175
|
+
export async function backfillPrincipals(
|
|
176
|
+
sql: SQL,
|
|
177
|
+
rules: BackfillRules,
|
|
178
|
+
options: { dryRun?: boolean } = {},
|
|
179
|
+
): Promise<BackfillSummary> {
|
|
180
|
+
const owners = rules.owners.flatMap((owner) =>
|
|
181
|
+
owner.principal === undefined
|
|
182
|
+
? []
|
|
183
|
+
: [{ id: owner.principal, name: owner.name }],
|
|
184
|
+
);
|
|
185
|
+
const names = new Map<string, string | undefined>();
|
|
186
|
+
const sources = {
|
|
187
|
+
config: 0,
|
|
188
|
+
owner_memory: 0,
|
|
189
|
+
schedules: 0,
|
|
190
|
+
conversations: 0,
|
|
191
|
+
held_actions: 0,
|
|
192
|
+
} satisfies Record<BackfillSource, number>;
|
|
193
|
+
// 0.8's remote-mcp speaker was the owner over MCP: it stands for the primary owner.
|
|
194
|
+
const primary = owners[0]?.id;
|
|
195
|
+
let remote = false;
|
|
196
|
+
const add = (source: BackfillSource, id: string, name: string | null) => {
|
|
197
|
+
if (id === SYSTEM_PRINCIPAL) return;
|
|
198
|
+
sources[source]++;
|
|
199
|
+
if (primary !== undefined && id === LEGACY_REMOTE_SPEAKER) {
|
|
200
|
+
remote = true;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!names.get(id)) names.set(id, name?.trim() || undefined);
|
|
204
|
+
};
|
|
205
|
+
for (const owner of owners) add("config", owner.id, owner.name);
|
|
206
|
+
for (const source of TABLES)
|
|
207
|
+
for (const row of await idsIn(sql, source))
|
|
208
|
+
add(source.table, row.id, row.name);
|
|
209
|
+
|
|
210
|
+
const [table] = await sql`SELECT to_regclass('principals') AS made`;
|
|
211
|
+
const existing = new Set<string>(
|
|
212
|
+
table?.made
|
|
213
|
+
? (await sql`SELECT id FROM principals`).map(
|
|
214
|
+
(row: { id: string }) => row.id,
|
|
215
|
+
)
|
|
216
|
+
: [],
|
|
217
|
+
);
|
|
218
|
+
// The id a surface knows a linked person by is no 0.8 speaker's: this version wrote it.
|
|
219
|
+
const linked = new Set<string>(
|
|
220
|
+
table?.made
|
|
221
|
+
? (
|
|
222
|
+
(await sql`SELECT provider, subject FROM principal_identities`) as {
|
|
223
|
+
provider: string;
|
|
224
|
+
subject: string;
|
|
225
|
+
}[]
|
|
226
|
+
).flatMap((row) => [row.subject, `${row.provider}:${row.subject}`])
|
|
227
|
+
: [],
|
|
228
|
+
);
|
|
229
|
+
const missing = [...names].filter(
|
|
230
|
+
([id]) => !existing.has(id) && !linked.has(id),
|
|
231
|
+
);
|
|
232
|
+
if (options.dryRun) return { created: missing.length, sources };
|
|
233
|
+
const scheduled = await scheduledTiers(sql);
|
|
234
|
+
const configured = new Set(owners.map((owner) => owner.id));
|
|
235
|
+
const upgraded = new Date();
|
|
236
|
+
let created = 0;
|
|
237
|
+
for (const [id, displayName] of missing) {
|
|
238
|
+
const owner = configured.has(id);
|
|
239
|
+
const seen = owner ? null : seenTier(rules, id, scheduled);
|
|
240
|
+
const rows = await sql`
|
|
241
|
+
INSERT INTO principals (id, display_name, claimable, last_tier, last_seen_at)
|
|
242
|
+
VALUES (${id}, ${displayName ?? id}, ${!owner}, ${seen}, ${seen ? upgraded : null})
|
|
243
|
+
ON CONFLICT (id) DO NOTHING
|
|
244
|
+
RETURNING id`;
|
|
245
|
+
created += rows.length;
|
|
246
|
+
}
|
|
247
|
+
if (remote && primary !== undefined)
|
|
248
|
+
await sql`
|
|
249
|
+
INSERT INTO principal_identities (provider, subject, principal_id, source)
|
|
250
|
+
VALUES (${LEGACY_PROVIDER}, ${LEGACY_REMOTE_SPEAKER}, ${primary}, 'legacy')
|
|
251
|
+
ON CONFLICT (provider, subject) DO NOTHING`;
|
|
252
|
+
// An owner is one by the configuration, never by a claim, even after it names someone else.
|
|
253
|
+
if (configured.size > 0)
|
|
254
|
+
await sql`
|
|
255
|
+
UPDATE principals SET claimable = false
|
|
256
|
+
WHERE claimable AND id IN ${sql([...configured])}`;
|
|
257
|
+
return { created, sources };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The identity plugin's migrations: its tables once, the claimable column and the plugins' link
|
|
262
|
+
* source once each (apart, so a database an earlier build made the tables on gets them too), then
|
|
263
|
+
* the backfill at every boot, which hands its summary to `report`.
|
|
264
|
+
*/
|
|
265
|
+
export function identityMigrations(
|
|
266
|
+
rules: BackfillRules,
|
|
267
|
+
report?: (summary: BackfillSummary) => void,
|
|
268
|
+
): Migration[] {
|
|
269
|
+
return [
|
|
270
|
+
{ name: "principals", up: PRINCIPAL_TABLES },
|
|
271
|
+
{ name: "principals-claimable", up: PRINCIPALS_CLAIMABLE },
|
|
272
|
+
{ name: "principals-plugin-links", up: PRINCIPAL_PLUGIN_LINKS },
|
|
273
|
+
{
|
|
274
|
+
name: "backfill",
|
|
275
|
+
runs: "every-boot",
|
|
276
|
+
up: async (sql) => {
|
|
277
|
+
const summary = await backfillPrincipals(sql, rules);
|
|
278
|
+
report?.(summary);
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
}
|